Skip to content

Commit 6f11b95

Browse files
songoowclaude
andcommitted
Merge origin/main into codex/merge-candidate-visibility
No conflict; the branch was only behind. loopx-project#4628 memoized python_facts in the same module this branch extends, and the two changes compose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: song <22676124+songoow@users.noreply.github.com>
2 parents 6ed92d8 + a96c9aa commit 6f11b95

24 files changed

Lines changed: 1888 additions & 77 deletions

‎docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -869,18 +869,30 @@ state at which this RFC is complete; each row is a registry budget or a
869869
vocabulary property the smoke can check. Rows marked *open* wait on a Section
870870
12 decision and are the reason the plan is a skeleton until those are recorded.
871871

872-
| Surface | Baseline (`1dc6ad8d8`) | Target when this RFC closes | Reached by |
873-
| --- | --- | --- | --- |
874-
| `effective_action` values | 33 literals, no owner symbol | one enum owner; `skip`, `observe_replay`, `block_replay`, and the two `quota_action_selection_*` codes gone from the decision slot; 32 decision values after accounting for the five previously missed producers and retiring the synthetic operator_gate value | M1 |
875-
| `effective_action` slots in one envelope | 3 vocabularies under one field name | 1, or a registered union if Q6 keeps the field | M1 (Q6) |
876-
| Turn vocabularies | 3 sets, 28 values, 21 distinct, 7 redundant spellings | 3 sets kept; projection and decision table generated and checked; spellings unchanged unless Q10 sets a merge | M2 (Q2, Q10 *open*) |
877-
| Same-runtime forks, semantic | 18 names | 0 | baseline PRs |
878-
| Conflicting values, semantic | 2 names | 0 | baseline PRs |
879-
| Multi-value forks | 4 (1 misclassified) | 0 after `scope` declares bounded-context names | M0.5 + baseline PRs |
880-
| Multi-value twins | 19 | 0 | baseline PRs |
881-
| Legacy should-run fields | 6 fields, 124 py / 10 ts module mentions | 0 fields | M3, identifier-counted |
882-
| Merge-candidate groups | 32 unreviewed | every group classified; only `same_semantics` groups merged | classification PR, then per-group PRs |
883-
| Control-plane py/ts twins | 43 | follows the TypeScript migration RFC; no target here | M4 |
872+
| Surface | Baseline (`1dc6ad8d8`) | Measured by | Target when this RFC closes | Reached by |
873+
| --- | --- | --- | --- | --- |
874+
| `effective_action` values | 33 literals, no owner symbol | registry `vocabularies.effective_action.values`; `semantic-vocabulary-drift-smoke.py` fails on an unregistered literal | one enum owner; `skip`, `observe_replay`, `block_replay`, and the two `quota_action_selection_*` codes gone from the decision slot; 32 decision values after accounting for the five previously missed producers and retiring the synthetic operator_gate value | M1 |
875+
| `effective_action` slots in one envelope | 3 vocabularies under one field name | no counter: the slot split is a Q6 decision, not a number. Read `relations.shared_field_names` | 1, or a registered union if Q6 keeps the field | M1 (Q6) |
876+
| Turn vocabularies | 3 sets, 28 values, 21 distinct, 7 redundant spellings | registry `vocabularies`; spelling overlap is `relations.same_concept` | 3 sets kept; projection and decision table generated and checked; spellings unchanged unless Q10 sets a merge | M2 (Q2, Q10 *open*) |
877+
| Same-runtime forks, semantic | 18 names | `semantic-vocabulary-drift-smoke.py`: `same_runtime_forks_semantic` | 0 | baseline PRs |
878+
| Conflicting values, semantic | 2 names | `semantic-vocabulary-drift-smoke.py`: `conflicting_values_semantic` | 0 | baseline PRs |
879+
| Multi-value forks | 4 (1 misclassified) | `semantic-vocabulary-drift-smoke.py`: `multi_value_forks` and `multi_value_forks_semantic`. Only the count is printed today; #4614 adds `divergent_value_sets` to name the surviving forks | 0 after `scope` declares bounded-context names | M0.5 + baseline PRs |
880+
| Multi-value twins | 19 | `semantic-vocabulary-drift-smoke.py`: `multi_value_twins` | 0 | baseline PRs |
881+
| Legacy should-run fields | 6 fields, 124 py / 10 ts module mentions | `semantic-vocabulary-drift-smoke.py`: one `<field>.py` / `<field>.ts` pair per field | 0 fields | M3, identifier-counted |
882+
| Merge-candidate groups | 32 unreviewed | `merge_candidate_groups()` in `loopx/semantics/inventory.py`; no command prints it today, and #4630 adds the CLI line. Read the reviewable count, not the raw one -- a registered cross-runtime vocabulary owns both its Python and TypeScript symbols, so those pairs are required by I3 rather than debt | every group classified; only `same_semantics` groups merged | classification PR, then per-group PRs |
883+
| Control-plane py/ts twins | 43 | `semantic-vocabulary-drift-smoke.py`: `independently_maintained` | follows the TypeScript migration RFC; no target here | M4 |
884+
885+
*Measured by* names the command and field that print each surface today, the
886+
way Section 9 names a test for each claim. It deliberately does not carry the
887+
values: a transcribed number is stale on the next merge, and a reader who wants
888+
the current state runs the command rather than trusting a date. Dated values
889+
belong to the delivery tracker, issue #4447, which owns delivery status; this
890+
table stays a contract about where the truth is measured.
891+
892+
Read the reviewable merge-candidate count rather than the raw one. The raw
893+
grouping pairs any two names carrying identical values, which includes the
894+
Python and TypeScript symbols a registered cross-runtime vocabulary is required
895+
by I3 to have. Treating those as debt is a measurement artifact, not drift.
884896

885897
### Two-track execution and enforcement lanes
886898

‎docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -710,18 +710,27 @@ TypeScript effective-action 绑定与[术语表](../../reference/glossary.md)通
710710
注册表预算或 smoke 可检查的词表属性。标为*未决*的行等待第 12 节的决策,这也
711711
是计划在那些决策记录之前只是骨架的原因。
712712

713-
| 表面 | 基线(`1dc6ad8d8`) | 本 RFC 关闭时的目标 | 由谁达成 |
714-
| --- | --- | --- | --- |
715-
| `effective_action` 取值 | 33 个字面量,无 owner 符号 | 一个枚举 owner;`skip`、`observe_replay`、`block_replay` 与两个 `quota_action_selection_*` 码从判定槽位移出;计入五个此前漏记的生产值并移除合成 operator_gate 后,共 32 个决策值 | M1 |
716-
| 同一 envelope 里的 `effective_action` 槽位 | 一个字段名下 3 套词表 | 1,或在 Q6 保留字段时为一个已注册并集 | M1(Q6) |
717-
| Turn 词表 | 3 套、28 值、21 个不同值、7 个冗余拼法 | 保留 3 套;投影与决策表生成并校验;拼法不变,除非 Q10 决定合并 | M2(Q2、Q10 *未决*) |
718-
| 同运行时分叉(语义) | 18 个名字 | 0 | 基线窄 PR |
719-
| 冲突值(语义) | 2 个名字 | 0 | 基线窄 PR |
720-
| 多值分叉 | 4(1 个误分类) | `scope` 声明有界上下文名字后为 0 | M0.5 + 基线窄 PR |
721-
| 多值孪生 | 19 | 0 | 基线窄 PR |
722-
| 旧 should-run 字段 | 6 个字段,124 py / 10 ts 模块提及 | 0 个字段 | M3,按标识符计数 |
723-
| 合并候选组 | 32 组未评审 | 每组已分类;只合并 `same_semantics` 的组 | 分类表 PR,随后逐组 PR |
724-
| 控制面 py/ts 孪生 | 43 | 跟随 TypeScript 迁移 RFC;本 RFC 不设目标 | M4 |
713+
| 表面 | 基线(`1dc6ad8d8`) | 由什么度量 | 本 RFC 关闭时的目标 | 由谁达成 |
714+
| --- | --- | --- | --- | --- |
715+
| `effective_action` 取值 | 33 个字面量,无 owner 符号 | 注册表 `vocabularies.effective_action.values`;`semantic-vocabulary-drift-smoke.py` 在出现未注册字面量时失败 | 一个枚举 owner;`skip`、`observe_replay`、`block_replay` 与两个 `quota_action_selection_*` 码从判定槽位移出;计入五个此前漏记的生产值并移除合成 operator_gate 后,共 32 个决策值 | M1 |
716+
| 同一 envelope 里的 `effective_action` 槽位 | 一个字段名下 3 套词表 | 无计数器:拆槽是 Q6 的决策而非一个数字。读 `relations.shared_field_names` | 1,或在 Q6 保留字段时为一个已注册并集 | M1(Q6) |
717+
| Turn 词表 | 3 套、28 值、21 个不同值、7 个冗余拼法 | 注册表 `vocabularies`;拼法重叠见 `relations.same_concept` | 保留 3 套;投影与决策表生成并校验;拼法不变,除非 Q10 决定合并 | M2(Q2、Q10 *未决*) |
718+
| 同运行时分叉(语义) | 18 个名字 | `semantic-vocabulary-drift-smoke.py`:`same_runtime_forks_semantic` | 0 | 基线窄 PR |
719+
| 冲突值(语义) | 2 个名字 | `semantic-vocabulary-drift-smoke.py`:`conflicting_values_semantic` | 0 | 基线窄 PR |
720+
| 多值分叉 | 4(1 个误分类) | `semantic-vocabulary-drift-smoke.py`:`multi_value_forks` 与 `multi_value_forks_semantic`。今天只打印计数;#4614 增加 `divergent_value_sets` 以按名字列出存活的分叉 | `scope` 声明有界上下文名字后为 0 | M0.5 + 基线窄 PR |
721+
| 多值孪生 | 19 | `semantic-vocabulary-drift-smoke.py`:`multi_value_twins` | 0 | 基线窄 PR |
722+
| 旧 should-run 字段 | 6 个字段,124 py / 10 ts 模块提及 | `semantic-vocabulary-drift-smoke.py`:每个字段一对 `<字段>.py` / `<字段>.ts` | 0 个字段 | M3,按标识符计数 |
723+
| 合并候选组 | 32 组未评审 | `loopx/semantics/inventory.py` 的 `merge_candidate_groups()`;今天没有任何命令打印它,#4630 增加该 CLI 行。读可评审数而非原始数——注册的跨运行时词表本就同时拥有 Python 与 TypeScript 两个符号,这类配对是 I3 的要求而不是债务 | 每组已分类;只合并 `same_semantics` 的组 | 分类表 PR,随后逐组 PR |
724+
| 控制面 py/ts 孪生 | 43 | `semantic-vocabulary-drift-smoke.py`:`independently_maintained` | 跟随 TypeScript 迁移 RFC;本 RFC 不设目标 | M4 |
725+
726+
*由什么度量* 列点明今天打印每个表面的命令与字段,与第 9 节为每条断言点明一个
727+
测试的写法一致。它**刻意不携带数值**:誊抄来的数字在下一次合并时就过期,而想
728+
知道当前状态的读者应当去跑那条命令,而不是相信一个日期。带日期的数值归交付
729+
追踪(issue #4447,它拥有交付状态);本表保持为「真值在哪里被度量」的契约。
730+
731+
合并候选要读**可评审数**而非原始数。原始分组会把任意两个携带相同值集的名字配
732+
成一组,其中包含注册的跨运行时词表按 I3 **必须**同时拥有的 Python 与 TypeScript
733+
两个符号。把它们当作债务是度量伪影,不是漂移。
725734

726735
### 两条执行轨道与强制层级
727736

‎examples/control_plane/status-collection-readmodel-smoke.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
import sys
99
import tempfile
10+
from types import SimpleNamespace
1011
from typing import Any
1112

1213

@@ -117,6 +118,7 @@ def assert_wrapper_parity(registry_path: Path, runtime_root: Path, scan_root: Pa
117118
def assert_context_orchestration() -> None:
118119
calls: list[tuple[str, dict[str, Any]]] = []
119120
runtime_root = Path("/tmp/status-collection-runtime")
121+
history_audit = object()
120122

121123
def record(name: str, value: Any) -> Any:
122124
calls.append((name, value if isinstance(value, dict) else {"value": value}))
@@ -136,23 +138,28 @@ def resolve_runtime_root(
136138
assert registry_path == Path("registry.json"), registry_path
137139
return runtime_root
138140

139-
def collect_history(**kwargs: Any) -> dict[str, Any]:
141+
def collect_status_history(**kwargs: Any) -> SimpleNamespace:
140142
assert kwargs["limit"] == 20, kwargs
141143
assert kwargs["goal_id"] == GOAL_ID, kwargs
142-
assert kwargs["include_runtime_goals"] is True, kwargs
143-
return record(
144-
"collect_history",
144+
assert kwargs["status_include_runtime_goals"] is True, kwargs
145+
history = record(
146+
"collect_status_history",
145147
{
146148
"goal_count": 1,
147149
"run_count": 0,
148150
"goals": [],
149151
"activation_state_filter": kwargs.get("activation_state_filter"),
150152
},
151153
)
154+
return SimpleNamespace(
155+
status_history=history,
156+
contract_audit=history_audit,
157+
)
152158

153159
def check_contract(**kwargs: Any) -> dict[str, Any]:
154160
assert kwargs["limit"] == 2, kwargs
155161
assert kwargs["goal_id_filter"] == GOAL_ID, kwargs
162+
assert kwargs["history_audit"] is history_audit, kwargs
156163
return record(
157164
"check_contract",
158165
{
@@ -186,7 +193,7 @@ def build_attention_queue(**kwargs: Any) -> dict[str, Any]:
186193
"collect_global_registry_health",
187194
{"ok": True, "current_registry_is_global": True},
188195
),
189-
collect_history=collect_history,
196+
collect_status_history=collect_status_history,
190197
check_contract=check_contract,
191198
build_attention_queue=build_attention_queue,
192199
build_runtime_summaries=lambda **kwargs: record(
@@ -229,7 +236,9 @@ def build_attention_queue(**kwargs: Any) -> dict[str, Any]:
229236
context=context,
230237
)
231238

232-
history_call = next(call for call in calls if call[0] == "collect_history")
239+
history_call = next(
240+
call for call in calls if call[0] == "collect_status_history"
241+
)
233242
assert history_call[1]["activation_state_filter"] == "active", history_call
234243
contract_call = next(call for call in calls if call[0] == "check_contract")
235244
assert contract_call[1]["activation_state_filter"] == "active", contract_call
@@ -257,7 +266,7 @@ def build_attention_queue(**kwargs: Any) -> dict[str, Any]:
257266
assert [name for name, _ in calls][:4] == [
258267
"load_registry",
259268
"collect_global_registry_health",
260-
"collect_history",
269+
"collect_status_history",
261270
"check_contract",
262271
], calls
263272

‎loopx/canary/maintainability_ratchet.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,83 @@ def collect_module_metric_findings(
612612
return sorted(findings, key=lambda item: str(item["id"]))
613613

614614

615+
def _git_show_text(repository_root: Path, rev: str, path: str) -> str | None:
616+
result = subprocess.run(
617+
["git", "show", f"{rev}:{path}"],
618+
cwd=repository_root, capture_output=True,
619+
text=True, encoding="utf-8", errors="replace", check=False,
620+
)
621+
return result.stdout if result.returncode == 0 else None
622+
623+
624+
def _rev_baseline_ceilings(repository_root: Path, rev: str) -> dict[str, dict[str, int]]:
625+
"""Mirror ``module_metric_baseline`` for a committed revision."""
626+
text = _git_show_text(repository_root, rev, "loopx/canary/module_metric_baseline.json")
627+
if text is None:
628+
return {}
629+
payload = json.loads(text)
630+
ceilings = payload.get("module_metric_ceilings")
631+
if not isinstance(ceilings, dict):
632+
return {}
633+
return {
634+
str(path): {key: int(metrics[key]) for key in ("lines", "any_count", "dict_any_count") if key in metrics}
635+
for path, metrics in ceilings.items()
636+
if isinstance(metrics, dict)
637+
}
638+
639+
640+
def diff_scoped_module_ceiling_violations(
641+
repository_root: Path,
642+
changed_files: Sequence[str],
643+
*,
644+
base_ref: str,
645+
) -> list[dict[str, Any]]:
646+
"""Module growth that crossed a reviewed ceiling must settle in the same diff.
647+
648+
A module may exceed its pre-diff ceiling without this check firing, as long
649+
as this diff did not cause the crossing; and a module whose ceiling this diff
650+
raises can stay silent. The single case this flags is the one that previously
651+
merged and turned ``main`` red until a separate reconciliation PR refreshed
652+
the ledger: this diff grew a module past the ceiling it inherited, without
653+
settling that ceiling here.
654+
"""
655+
head_ceilings = module_metric_baseline(
656+
repository_root / "loopx" / "canary" / "module_metric_baseline.json"
657+
)
658+
base_ceilings = _rev_baseline_ceilings(repository_root, (base_ref or "origin/main").strip() or "origin/main")
659+
violations: list[dict[str, Any]] = []
660+
for changed in changed_files:
661+
relative = str(changed)
662+
if not relative.startswith("loopx/") or not relative.endswith(".py"):
663+
continue
664+
checkout_path = repository_root / relative
665+
if not checkout_path.is_file():
666+
continue
667+
head_lines = module_metrics(checkout_path)["lines"]
668+
base_lines = _git_show_text(repository_root, (base_ref or "origin/main").strip() or "origin/main", relative)
669+
if base_lines is None:
670+
base_ceiling = MODULE_LINE_LIMIT
671+
was_within_budget = True
672+
else:
673+
base_ceiling = base_ceilings.get(relative, {}).get("lines", MODULE_LINE_LIMIT)
674+
was_within_budget = len(base_lines.splitlines()) <= base_ceiling
675+
head_ceiling = head_ceilings.get(relative, {}).get("lines", MODULE_LINE_LIMIT)
676+
crossed_inherited_ceiling = was_within_budget and head_lines > base_ceiling
677+
if crossed_inherited_ceiling and head_ceiling < head_lines:
678+
violations.append(
679+
{
680+
"id": _finding_id("module_metric_budget", relative),
681+
"category": "module_metric_budget",
682+
"path": relative,
683+
"base_lines": 0 if base_lines is None else len(base_lines.splitlines()),
684+
"base_ceiling": base_ceiling,
685+
"head_lines": head_lines,
686+
"head_ceiling": head_ceiling,
687+
}
688+
)
689+
return sorted(violations, key=lambda item: str(item["id"]))
690+
691+
615692
def evaluate_maintainability_findings(
616693
findings: Sequence[Mapping[str, Any]],
617694
*,

‎loopx/canary/premerge.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,55 @@ def _diff_hygiene_checks(
502502
return checks
503503

504504

505+
def _module_ceiling_colocation_check(
506+
*,
507+
changed_files: list[str],
508+
base_ref: str,
509+
execute: bool,
510+
repo_root: Path = REPO_ROOT,
511+
) -> dict[str, Any] | None:
512+
python_files = [
513+
str(path) for path in changed_files
514+
if str(path).startswith("loopx/")
515+
and str(path).endswith(".py")
516+
and (repo_root / str(path)).is_file()
517+
]
518+
if not python_files:
519+
return None
520+
base = (base_ref or "origin/main").strip() or "origin/main"
521+
if not execute:
522+
return {
523+
"id": "module_ceiling_colocation",
524+
"kind": "direct_import",
525+
"command": "python3 examples/control_plane/control-plane-maintainability-ratchet-smoke.py",
526+
"reason": "module growth that crossed its reviewed ceiling must settle in the same diff",
527+
"status": "ready",
528+
"ok": True,
529+
}
530+
from loopx.canary.maintainability_ratchet import diff_scoped_module_ceiling_violations
531+
532+
violations = diff_scoped_module_ceiling_violations(
533+
repo_root, python_files, base_ref=base
534+
)
535+
ok = not violations
536+
check: dict[str, Any] = {
537+
"id": "module_ceiling_colocation",
538+
"kind": "direct_import",
539+
"command": "python3 examples/control_plane/control-plane-maintainability-ratchet-smoke.py",
540+
"reason": "module growth that crossed its reviewed ceiling must settle in the same diff",
541+
"status": "passed" if ok else "failed",
542+
"ok": ok,
543+
}
544+
if violations:
545+
check["detail"] = [
546+
f"{item['path']}: grew {item['base_lines']} -> {item['head_lines']} lines "
547+
f"past its inherited ceiling {item['base_ceiling']}; settle the ceiling in "
548+
f"this diff (loopx/canary/module_metric_baseline.json)"
549+
for item in violations
550+
]
551+
return check
552+
553+
505554
def _py_compile_check(
506555
*,
507556
python_files: list[str],
@@ -805,6 +854,15 @@ def build_premerge_validation_gate(
805854
if py_compile is not None:
806855
direct_checks.append(py_compile)
807856

857+
module_colocation = _module_ceiling_colocation_check(
858+
changed_files=files,
859+
base_ref=base_ref,
860+
execute=execute,
861+
repo_root=target_repo_root,
862+
)
863+
if module_colocation is not None:
864+
direct_checks.append(module_colocation)
865+
808866
if files:
809867
catalog_progress = _section_progress_callback(
810868
progress_callback,

0 commit comments

Comments
 (0)