its-magic 0.1.2-42 → 0.1.2-48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Downstream CI drift guard CLI (BUG-0009 / DEC-0075).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ _REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
15
+
16
+ if _SCRIPT_DIR not in sys.path:
17
+ sys.path.insert(0, _SCRIPT_DIR)
18
+
19
+ import downstream_ci_guard_lib as dci # noqa: E402
20
+
21
+
22
+ def main() -> int:
23
+ parser = argparse.ArgumentParser(
24
+ description="Downstream CI drift guard (template forbidden scan + active inventory)."
25
+ )
26
+ parser.add_argument(
27
+ "--repo",
28
+ default=_REPO_ROOT,
29
+ help="Target repository root (default: parent of scripts/).",
30
+ )
31
+ parser.add_argument(
32
+ "--self-test",
33
+ action="store_true",
34
+ help="Run stable marker subtests.",
35
+ )
36
+ parser.add_argument(
37
+ "--report",
38
+ action="store_true",
39
+ help="Emit JSON inventory to stdout.",
40
+ )
41
+ args = parser.parse_args()
42
+
43
+ if args.self_test:
44
+ try:
45
+ dci.self_test()
46
+ except AssertionError as exc:
47
+ print(f"self-test failed: {exc}", file=sys.stderr)
48
+ return 2
49
+ print("[DOWNSTREAM_CI_GUARD_SELF_TEST_OK]")
50
+ return 0
51
+
52
+ target = os.path.abspath(args.repo)
53
+ report, stderr_lines = dci.build_report(target)
54
+
55
+ if args.report:
56
+ print(json.dumps(dci.report_to_dict(report), sort_keys=True, separators=(",", ":")))
57
+
58
+ for line in stderr_lines:
59
+ print(line, file=sys.stderr)
60
+
61
+ if not report.ok:
62
+ return 1
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """Verify active vs template/scripts/ bytes match for DEC-0063 intake gate modules (BUG-0001)."""
2
+ """Verify active vs template/scripts/ bytes match for DEC-0063 intake gate modules (BUG-0001).
3
+
4
+ Scoped modes (DEC-0073 §10 / US-0090):
5
+ --scope=intake (default) DEC-0063 intake pair table.
6
+ --scope=caveman-compress DEC-0073 caveman input-compression pair table.
7
+ --scope=readme-feature-coverage DEC-0074 README feature-coverage pair table.
8
+ --scope=downstream-ci-guard DEC-0075 downstream CI guard script pair table.
9
+ --scope=us-0092 DEC-0078 full-autonomy outer driver + probe surfaces.
10
+ --scope=all union of all tables.
11
+ """
3
12
 
4
13
  from __future__ import annotations
5
14
 
@@ -7,7 +16,6 @@ import argparse
7
16
  import sys
8
17
  from pathlib import Path
9
18
 
10
- # Normative pairs: repo scripts/ (canonical dev) → template/scripts/ (packaged ship path).
11
19
  INTAKE_TEMPLATE_PAIRS: tuple[tuple[str, str], ...] = (
12
20
  ("scripts/intake_evidence_validate.py", "template/scripts/intake_evidence_validate.py"),
13
21
  ("scripts/intake_evidence_lib.py", "template/scripts/intake_evidence_lib.py"),
@@ -16,6 +24,86 @@ INTAKE_TEMPLATE_PAIRS: tuple[tuple[str, str], ...] = (
16
24
  ("scripts/check_intake_template_parity.py", "template/scripts/check_intake_template_parity.py"),
17
25
  )
18
26
 
27
+ # DEC-0073 §10 / US-0090 — Caveman input-compression surface pairs. Contents
28
+ # must be byte-identical between active and template paths; installer delivers
29
+ # template copies (BUG-0003 / DEC-0066).
30
+ CAVEMAN_COMPRESS_PAIRS: tuple[tuple[str, str], ...] = (
31
+ ("scripts/caveman_compress_input.py", "template/scripts/caveman_compress_input.py"),
32
+ ("docs/engineering/context/installer-owned-paths.manifest",
33
+ "template/docs/engineering/context/installer-owned-paths.manifest"),
34
+ ("docs/engineering/runbook.md", "template/docs/engineering/runbook.md"),
35
+ ("docs/engineering/auto-orchestration-reference.md",
36
+ "template/docs/engineering/auto-orchestration-reference.md"),
37
+ )
38
+
39
+ README_FEATURE_COVERAGE_PAIRS: tuple[tuple[str, str], ...] = (
40
+ (
41
+ "scripts/validate_readme_feature_coverage.py",
42
+ "template/scripts/validate_readme_feature_coverage.py",
43
+ ),
44
+ (
45
+ "scripts/readme_feature_coverage_lib.py",
46
+ "template/scripts/readme_feature_coverage_lib.py",
47
+ ),
48
+ (
49
+ "docs/engineering/context/readme-section-affinity.json",
50
+ "template/docs/engineering/context/readme-section-affinity.json",
51
+ ),
52
+ (".cursor/commands/release.md", "template/.cursor/commands/release.md"),
53
+ ("docs/engineering/runbook.md", "template/docs/engineering/runbook.md"),
54
+ (
55
+ "docs/engineering/context/installer-owned-paths.manifest",
56
+ "template/docs/engineering/context/installer-owned-paths.manifest",
57
+ ),
58
+ (
59
+ "scripts/check_intake_template_parity.py",
60
+ "template/scripts/check_intake_template_parity.py",
61
+ ),
62
+ )
63
+
64
+ US0092_PAIRS: tuple[tuple[str, str], ...] = (
65
+ ("scripts/auto_outer_driver.py", "template/scripts/auto_outer_driver.py"),
66
+ ("scripts/uat_probe_lib.py", "template/scripts/uat_probe_lib.py"),
67
+ (
68
+ "docs/engineering/context/installer-owned-paths.manifest",
69
+ "template/docs/engineering/context/installer-owned-paths.manifest",
70
+ ),
71
+ (".cursor/commands/auto.md", "template/.cursor/commands/auto.md"),
72
+ (".cursor/commands/verify-work.md", "template/.cursor/commands/verify-work.md"),
73
+ (".cursor/commands/qa.md", "template/.cursor/commands/qa.md"),
74
+ (
75
+ "docs/engineering/auto-orchestration-reference.md",
76
+ "template/docs/engineering/auto-orchestration-reference.md",
77
+ ),
78
+ ("docs/engineering/runbook.md", "template/docs/engineering/runbook.md"),
79
+ )
80
+
81
+ DOWNSTREAM_CI_GUARD_PAIRS: tuple[tuple[str, str], ...] = (
82
+ (
83
+ "scripts/check_downstream_ci_guard.py",
84
+ "template/scripts/check_downstream_ci_guard.py",
85
+ ),
86
+ (
87
+ "scripts/downstream_ci_guard_lib.py",
88
+ "template/scripts/downstream_ci_guard_lib.py",
89
+ ),
90
+ )
91
+
92
+ SCOPES: dict[str, tuple[tuple[str, str], ...]] = {
93
+ "intake": INTAKE_TEMPLATE_PAIRS,
94
+ "caveman-compress": CAVEMAN_COMPRESS_PAIRS,
95
+ "readme-feature-coverage": README_FEATURE_COVERAGE_PAIRS,
96
+ "downstream-ci-guard": DOWNSTREAM_CI_GUARD_PAIRS,
97
+ "us-0092": US0092_PAIRS,
98
+ "all": (
99
+ INTAKE_TEMPLATE_PAIRS
100
+ + CAVEMAN_COMPRESS_PAIRS
101
+ + README_FEATURE_COVERAGE_PAIRS
102
+ + DOWNSTREAM_CI_GUARD_PAIRS
103
+ + US0092_PAIRS
104
+ ),
105
+ }
106
+
19
107
 
20
108
  def main() -> int:
21
109
  p = argparse.ArgumentParser(description=__doc__)
@@ -25,10 +113,17 @@ def main() -> int:
25
113
  default=Path(__file__).resolve().parent.parent,
26
114
  help="Repository root",
27
115
  )
116
+ p.add_argument(
117
+ "--scope",
118
+ choices=sorted(SCOPES.keys()),
119
+ default="intake",
120
+ help="Parity pair table to verify.",
121
+ )
28
122
  args = p.parse_args()
29
123
  root: Path = args.repo
124
+ pairs = SCOPES[args.scope]
30
125
  failed = False
31
- for rel_active, rel_tpl in INTAKE_TEMPLATE_PAIRS:
126
+ for rel_active, rel_tpl in pairs:
32
127
  a = root / rel_active
33
128
  t = root / rel_tpl
34
129
  if not a.is_file() or not t.is_file():
@@ -45,7 +140,7 @@ def main() -> int:
45
140
  failed = True
46
141
  if failed:
47
142
  return 2
48
- print("[INTAKE_TEMPLATE_PARITY_OK]")
143
+ print(f"[INTAKE_TEMPLATE_PARITY_OK] scope={args.scope}")
49
144
  return 0
50
145
 
51
146
 
@@ -0,0 +1,222 @@
1
+ """
2
+ Downstream CI drift guard — template forbidden-pattern scan + active inventory (BUG-0009 / DEC-0075).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ JOB_KEY_RE = re.compile(r"^\s{2}(\w[\w-]*):", re.MULTILINE)
14
+
15
+ FORBIDDEN_JOB_IDS = frozenset({"npm-test", "brew-test", "choco-test"})
16
+ ALLOWED_TEMPLATE_JOBS = frozenset({"checks", "auto-fix"})
17
+ REQUIRED_ACTIVE_JOBS = frozenset(
18
+ {"checks", "auto-fix", "npm-test", "brew-test", "choco-test"}
19
+ )
20
+
21
+ FORBIDDEN_SUBSTRINGS: Tuple[str, ...] = (
22
+ "npm-test",
23
+ "brew-test",
24
+ "choco-test",
25
+ "npm pack",
26
+ "its-magic-*.tgz",
27
+ "installer.sh",
28
+ "packaging/chocolatey",
29
+ "packaging/homebrew",
30
+ "choco pack",
31
+ "brew style",
32
+ )
33
+
34
+ REASON_FORBIDDEN_PATTERN = "DOWNSTREAM_CI_FORBIDDEN_PATTERN"
35
+ REASON_JOB_LEAK = "DOWNSTREAM_CI_JOB_LEAK"
36
+ REASON_PACKAGING_MISSING = "KIT_CI_PACKAGING_JOBS_MISSING"
37
+
38
+ REPORT_SCHEMA_VERSION = 1
39
+
40
+
41
+ @dataclass
42
+ class GuardViolation:
43
+ reason_code: str
44
+ detail: str
45
+
46
+
47
+ @dataclass
48
+ class GuardReport:
49
+ template_job_keys: List[str] = field(default_factory=list)
50
+ active_job_keys: List[str] = field(default_factory=list)
51
+ forbidden_hits: List[str] = field(default_factory=list)
52
+ violations: List[GuardViolation] = field(default_factory=list)
53
+ ok: bool = True
54
+
55
+
56
+ def read_utf8(path: str) -> str:
57
+ with open(path, "r", encoding="utf-8") as f:
58
+ return f.read()
59
+
60
+
61
+ def extract_job_keys(yaml_text: str) -> List[str]:
62
+ """Extract top-level job ids from a GitHub Actions workflow (stdlib regex only)."""
63
+ keys: List[str] = []
64
+ in_jobs = False
65
+ for line in yaml_text.splitlines():
66
+ if re.match(r"^jobs:\s*$", line):
67
+ in_jobs = True
68
+ continue
69
+ if not in_jobs:
70
+ continue
71
+ m = JOB_KEY_RE.match(line)
72
+ if m:
73
+ keys.append(m.group(1))
74
+ elif line and not line.startswith(" ") and not line.startswith("#"):
75
+ break
76
+ return keys
77
+
78
+
79
+ def scan_forbidden_patterns(yaml_text: str) -> List[str]:
80
+ hits: List[str] = []
81
+ for pattern in FORBIDDEN_SUBSTRINGS:
82
+ if pattern in yaml_text:
83
+ hits.append(pattern)
84
+ return hits
85
+
86
+
87
+ def check_template_ci(yaml_text: str) -> List[GuardViolation]:
88
+ violations: List[GuardViolation] = []
89
+ job_keys = extract_job_keys(yaml_text)
90
+ extra_jobs = set(job_keys) - ALLOWED_TEMPLATE_JOBS
91
+ if extra_jobs:
92
+ violations.append(
93
+ GuardViolation(
94
+ REASON_JOB_LEAK,
95
+ f"template ci.yml job keys {sorted(job_keys)!r} exceed allowed "
96
+ f"{sorted(ALLOWED_TEMPLATE_JOBS)!r}",
97
+ )
98
+ )
99
+ forbidden = scan_forbidden_patterns(yaml_text)
100
+ if forbidden:
101
+ violations.append(
102
+ GuardViolation(
103
+ REASON_FORBIDDEN_PATTERN,
104
+ f"template ci.yml contains forbidden pattern(s): {forbidden}",
105
+ )
106
+ )
107
+ return violations
108
+
109
+
110
+ def check_active_ci(yaml_text: str) -> List[GuardViolation]:
111
+ violations: List[GuardViolation] = []
112
+ job_keys = set(extract_job_keys(yaml_text))
113
+ missing = REQUIRED_ACTIVE_JOBS - job_keys
114
+ if missing:
115
+ violations.append(
116
+ GuardViolation(
117
+ REASON_PACKAGING_MISSING,
118
+ f"active ci.yml missing required job id(s): {sorted(missing)!r}; "
119
+ f"found {sorted(job_keys)!r}",
120
+ )
121
+ )
122
+ return violations
123
+
124
+
125
+ def build_report(repo_root: str) -> Tuple[GuardReport, List[str]]:
126
+ report = GuardReport()
127
+ stderr_lines: List[str] = []
128
+
129
+ template_ci = os.path.join(
130
+ repo_root, "template", ".github", "workflows", "ci.yml"
131
+ )
132
+ active_ci = os.path.join(repo_root, ".github", "workflows", "ci.yml")
133
+
134
+ if not os.path.isfile(template_ci):
135
+ stderr_lines.append(f"missing template ci.yml: {template_ci}")
136
+ report.ok = False
137
+ return report, stderr_lines
138
+ if not os.path.isfile(active_ci):
139
+ stderr_lines.append(f"missing active ci.yml: {active_ci}")
140
+ report.ok = False
141
+ return report, stderr_lines
142
+
143
+ template_text = read_utf8(template_ci)
144
+ active_text = read_utf8(active_ci)
145
+
146
+ report.template_job_keys = extract_job_keys(template_text)
147
+ report.active_job_keys = extract_job_keys(active_text)
148
+ report.forbidden_hits = scan_forbidden_patterns(template_text)
149
+
150
+ for v in check_template_ci(template_text):
151
+ report.violations.append(v)
152
+ stderr_lines.append(f"{v.reason_code}: {v.detail}")
153
+ for v in check_active_ci(active_text):
154
+ report.violations.append(v)
155
+ stderr_lines.append(f"{v.reason_code}: {v.detail}")
156
+
157
+ report.ok = len(report.violations) == 0
158
+ return report, stderr_lines
159
+
160
+
161
+ def report_to_dict(report: GuardReport) -> Dict[str, Any]:
162
+ return {
163
+ "schema_version": REPORT_SCHEMA_VERSION,
164
+ "template_job_keys": report.template_job_keys,
165
+ "active_job_keys": report.active_job_keys,
166
+ "forbidden_hits": report.forbidden_hits,
167
+ "violations": [
168
+ {"reason_code": v.reason_code, "detail": v.detail}
169
+ for v in report.violations
170
+ ],
171
+ "ok": report.ok,
172
+ }
173
+
174
+
175
+ def self_test() -> None:
176
+ sample_template = """name: ci
177
+ jobs:
178
+ checks:
179
+ runs-on: ubuntu-latest
180
+ auto-fix:
181
+ runs-on: ubuntu-latest
182
+ """
183
+ keys = extract_job_keys(sample_template)
184
+ assert keys == ["checks", "auto-fix"], keys
185
+
186
+ bad_template = """name: ci
187
+ jobs:
188
+ checks:
189
+ runs-on: ubuntu-latest
190
+ npm-test:
191
+ runs-on: ubuntu-latest
192
+ steps:
193
+ - run: npm pack
194
+ """
195
+ v = check_template_ci(bad_template)
196
+ assert any(x.reason_code == REASON_JOB_LEAK for x in v)
197
+ assert any(x.reason_code == REASON_FORBIDDEN_PATTERN for x in v)
198
+
199
+ good_active = """name: ci
200
+ jobs:
201
+ checks:
202
+ runs-on: ubuntu-latest
203
+ auto-fix:
204
+ runs-on: ubuntu-latest
205
+ npm-test:
206
+ runs-on: ubuntu-latest
207
+ brew-test:
208
+ runs-on: ubuntu-latest
209
+ choco-test:
210
+ runs-on: ubuntu-latest
211
+ """
212
+ assert not check_active_ci(good_active)
213
+
214
+ bad_active = """name: ci
215
+ jobs:
216
+ checks:
217
+ runs-on: ubuntu-latest
218
+ auto-fix:
219
+ runs-on: ubuntu-latest
220
+ """
221
+ v2 = check_active_ci(bad_active)
222
+ assert len(v2) == 1 and v2[0].reason_code == REASON_PACKAGING_MISSING
@@ -45,7 +45,9 @@ PO_ARCH_DIR = Path("handoffs/archive")
45
45
  ARCH_ARCH_DIR = Path("docs/engineering/architecture-archive")
46
46
 
47
47
  CHECKPOINT_HEADING = re.compile(r"^## .*\bcheckpoint\b.*$", re.I)
48
- STORY_HEADING = re.compile(r"^# US-\d{4}\s*[:\u2014\-].+$")
48
+ STORY_HEADING_H1 = re.compile(r"^# (?:US|BUG)-\d{4}\s*[:\u2014\-].+$")
49
+ STORY_HEADING_H2 = re.compile(r"^## US-\d{4}\s*[:\u2014\-].+$")
50
+ _STORY_ID_FROM_LINE = re.compile(r"(?:US|BUG)-\d{4}")
49
51
 
50
52
 
51
53
  class PolicyError(Exception):
@@ -135,9 +137,34 @@ def split_po_sections(text: str) -> List[str]:
135
137
  return sections
136
138
 
137
139
 
140
+ def _story_boundary_candidates(text: str) -> List[Tuple[int, str, int]]:
141
+ """Collect (line_idx, story_id, level) for H1/H2 story-heading matches."""
142
+ lines = text.splitlines(keepends=True)
143
+ candidates: List[Tuple[int, str, int]] = []
144
+ for i, ln in enumerate(lines):
145
+ stripped = ln.rstrip("\r\n")
146
+ if STORY_HEADING_H1.match(stripped):
147
+ m = _STORY_ID_FROM_LINE.search(stripped)
148
+ if m:
149
+ candidates.append((i, m.group(0), 1))
150
+ elif STORY_HEADING_H2.match(stripped):
151
+ m = _STORY_ID_FROM_LINE.search(stripped)
152
+ if m:
153
+ candidates.append((i, m.group(0), 2))
154
+ return candidates
155
+
156
+
138
157
  def split_arch_stories(text: str) -> Tuple[str, List[str]]:
139
158
  lines = text.splitlines(keepends=True)
140
- idxs = [i for i, ln in enumerate(lines) if STORY_HEADING.match(ln.rstrip("\r\n"))]
159
+ candidates = _story_boundary_candidates(text)
160
+ if not candidates:
161
+ return text, []
162
+ h1_ids = {story_id for _, story_id, level in candidates if level == 1}
163
+ idxs = sorted(
164
+ idx
165
+ for idx, story_id, level in candidates
166
+ if not (level == 2 and story_id in h1_ids)
167
+ )
141
168
  if not idxs:
142
169
  return text, []
143
170
  preamble = "".join(lines[: idxs[0]])
@@ -148,6 +175,18 @@ def split_arch_stories(text: str) -> Tuple[str, List[str]]:
148
175
  return preamble, blocks
149
176
 
150
177
 
178
+ def count_h2_story_headings(text: str) -> int:
179
+ return sum(
180
+ 1 for ln in text.splitlines() if STORY_HEADING_H2.match(ln.rstrip("\r\n"))
181
+ )
182
+
183
+
184
+ def check_arch_heading_policy(text_after: str, baseline_h2_count: int) -> Optional[str]:
185
+ if count_h2_story_headings(text_after) > baseline_h2_count:
186
+ return "ARCH_STORY_HEADING_LEVEL_INVALID"
187
+ return None
188
+
189
+
151
190
  def next_pack_path(repo: Path, archive_dir: Path, stem: str) -> Path:
152
191
  archive_dir.mkdir(parents=True, exist_ok=True)
153
192
  day = datetime.now(timezone.utc).strftime("%Y%m%d")
@@ -352,7 +391,7 @@ def rollover_architecture(repo: Path, policy: Dict[str, str], dry_run: bool) ->
352
391
  return None
353
392
  raise PolicyError(
354
393
  "STATE_ARCHIVE_BOUNDARY_AMBIGUOUS",
355
- "architecture file exceeds line cap but has no US story headings to archive",
394
+ "architecture file exceeds line cap but has no story headings to archive",
356
395
  )
357
396
  moved = 0
358
397
  work = list(stories)
@@ -491,11 +530,43 @@ def cmd_self_test() -> int:
491
530
  if [line_count(s) for s in secs] != [3, 3, 3]:
492
531
  fail("po section split line counts unexpected")
493
532
 
494
- # --- arch stories ---
533
+ # --- arch stories (H1 non-regression) ---
495
534
  arch = "# Preamble line\n\n# US-0001: One\nx\n# US-0002: Two\ny\n"
496
535
  apre, stories = split_arch_stories(arch)
497
536
  if "Preamble" not in apre or len(stories) != 2:
498
- fail("architecture story split failed")
537
+ fail("architecture H1 story split failed")
538
+
539
+ # --- mixed H1+H2 same id: H1 wins ---
540
+ mixed = "# US-0067: Alpha\nbody\n## US-0067: Legacy\nmore\n# US-0068: Next\nz\n"
541
+ _, mixed_stories = split_arch_stories(mixed)
542
+ if len(mixed_stories) != 2:
543
+ fail("mixed H1+H2 same id should yield two blocks (H1-wins)")
544
+ if not mixed_stories[0].startswith("# US-0067"):
545
+ fail("mixed file first block should start at H1 US-0067")
546
+
547
+ # --- inner ## subheading inside # US- block is not a boundary ---
548
+ inner = "# US-0001: Story\n## Details\nnested\n# US-0002: Two\ny\n"
549
+ _, inner_stories = split_arch_stories(inner)
550
+ if len(inner_stories) != 2:
551
+ fail("inner ## Details must not create extra story boundary")
552
+ if "## Details" not in inner_stories[0]:
553
+ fail("inner subheading should remain inside first story block")
554
+
555
+ # --- BUG H1 parity ---
556
+ bug_arch = "# BUG-0009: Defect\nbody\n# US-0010: Story\nmore\n"
557
+ _, bug_stories = split_arch_stories(bug_arch)
558
+ if len(bug_stories) != 2 or not bug_stories[0].startswith("# BUG-0009"):
559
+ fail("BUG H1 story boundary not recognized")
560
+
561
+ # --- heading policy enforcement delta ---
562
+ baseline_text = "## US-0001: A\nx\n"
563
+ increased = baseline_text + "## US-0099: New H2\ny\n"
564
+ if check_arch_heading_policy(baseline_text, 1) is not None:
565
+ fail("stable H2 count should not fail policy")
566
+ if check_arch_heading_policy(increased, 1) != "ARCH_STORY_HEADING_LEVEL_INVALID":
567
+ fail("H2 count increase must return ARCH_STORY_HEADING_LEVEL_INVALID")
568
+ if check_arch_heading_policy(increased, 2) is not None:
569
+ fail("count decrease/normalization should pass policy")
499
570
 
500
571
  with tempfile.TemporaryDirectory() as tmp:
501
572
  root = Path(tmp)
@@ -543,14 +614,33 @@ def cmd_self_test() -> int:
543
614
  (root / ARCH_REL).write_text(arch_big, encoding="utf-8")
544
615
  a1 = rollover_architecture(root, policy_arch, dry_run=False)
545
616
  if not a1:
546
- fail("architecture rollover expected")
617
+ fail("architecture H1 rollover expected")
547
618
  a2 = rollover_architecture(root, policy_arch, dry_run=False)
548
619
  if a2 is not None:
549
- fail("architecture idempotent")
620
+ fail("architecture H1 idempotent")
550
621
  merged_arch = dict(DEFAULTS)
551
622
  merged_arch.update(policy_arch)
552
623
  if run_check(root, merged_arch):
553
- fail("architecture check should pass")
624
+ fail("architecture H1 check should pass")
625
+
626
+ # --- ##-only rollover (legacy H2 sections) ---
627
+ policy_h2 = dict(DEFAULTS)
628
+ policy_h2["ARCH_HOT_MAX_LINES"] = "12"
629
+ policy_h2["ARCH_HOT_MAX_STORY_SECTIONS"] = "2"
630
+ h2_big = "# Top\n\n" + "".join(
631
+ f"## US-100{i}: Legacy\nL{i}\n\n" for i in range(4)
632
+ )
633
+ (root / ARCH_REL).write_text(h2_big, encoding="utf-8")
634
+ h2_1 = rollover_architecture(root, policy_h2, dry_run=False)
635
+ if not h2_1 or h2_1.get("moved", 0) < 1:
636
+ fail("##-only architecture rollover should move at least one block")
637
+ h2_2 = rollover_architecture(root, policy_h2, dry_run=False)
638
+ if h2_2 is not None:
639
+ fail("##-only architecture rollover should be idempotent")
640
+ merged_h2 = dict(DEFAULTS)
641
+ merged_h2.update(policy_h2)
642
+ if run_check(root, merged_h2):
643
+ fail("##-only architecture check should pass after rollover")
554
644
 
555
645
  if errors:
556
646
  for e in errors:
@@ -566,6 +656,16 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
566
656
  mx.add_argument("--check", action="store_true", help="verify caps, no writes")
567
657
  mx.add_argument("--rollover", action="store_true", help="archive oldest units when over cap")
568
658
  mx.add_argument("--self-test", action="store_true", help="internal regression fixtures")
659
+ mx.add_argument(
660
+ "--check-arch-heading-policy",
661
+ action="store_true",
662
+ help="fail when H2 story-heading count increased vs baseline",
663
+ )
664
+ p.add_argument(
665
+ "--baseline-h2-count",
666
+ type=int,
667
+ help="baseline H2 story-heading count (required with --check-arch-heading-policy)",
668
+ )
569
669
  p.add_argument(
570
670
  "--json",
571
671
  action="store_true",
@@ -603,6 +703,33 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
603
703
  return 1
604
704
  return 0
605
705
 
706
+ if args.check_arch_heading_policy:
707
+ if args.baseline_h2_count is None:
708
+ print(
709
+ "STATE_ARCHIVE_VERIFICATION_FAILED "
710
+ "missing --baseline-h2-count for --check-arch-heading-policy",
711
+ file=sys.stderr,
712
+ )
713
+ return 2
714
+ try:
715
+ arch_text = (repo / ARCH_REL).read_text(encoding="utf-8")
716
+ except OSError as exc:
717
+ print(
718
+ f"STATE_ARCHIVE_WRITE_FAILED could_not_read_architecture detail={exc}",
719
+ file=sys.stderr,
720
+ )
721
+ return 2
722
+ code = check_arch_heading_policy(arch_text, args.baseline_h2_count)
723
+ if code:
724
+ print(
725
+ f"{code} h2_story_heading_count_increased "
726
+ f"baseline={args.baseline_h2_count} "
727
+ f"after={count_h2_story_headings(arch_text)}",
728
+ file=sys.stderr,
729
+ )
730
+ return 1
731
+ return 0
732
+
606
733
  if args.rollover:
607
734
  try:
608
735
  outs = run_rollover_all(repo, policy, dry_run=args.dry_run)