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.
- package/README.md +119 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +99 -4
- package/template/.cursor/commands/architecture.md +16 -6
- package/template/.cursor/commands/auto.md +42 -0
- package/template/.cursor/commands/qa.md +8 -0
- package/template/.cursor/commands/release.md +11 -0
- package/template/.cursor/commands/verify-work.md +16 -1
- package/template/.cursor/rules/caveman.mdc +184 -0
- package/template/.cursor/scratchpad.local.example.md +257 -225
- package/template/.cursor/scratchpad.md +14 -4
- package/template/.github/workflows/ci.yml +4 -114
- package/template/README.md +113 -0
- package/template/docs/engineering/auto-orchestration-reference.md +1004 -934
- package/template/docs/engineering/context/installer-owned-paths.manifest +15 -0
- package/template/docs/engineering/context/readme-section-affinity.json +30 -0
- package/template/docs/engineering/runbook.md +2051 -1772
- package/template/scripts/auto_outer_driver.py +521 -0
- package/template/scripts/caveman_compress_input.py +903 -0
- package/template/scripts/check_downstream_ci_guard.py +67 -0
- package/template/scripts/check_intake_template_parity.py +99 -4
- package/template/scripts/downstream_ci_guard_lib.py +222 -0
- package/template/scripts/enforce-triad-hot-surface.py +135 -8
- package/template/scripts/readme_feature_coverage_lib.py +608 -0
- package/template/scripts/uat_probe_lib.py +317 -0
- package/template/scripts/validate_readme_feature_coverage.py +140 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|