superlab 0.1.63 → 0.1.65
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/lib/auto_state.cjs +3 -0
- package/lib/i18n.cjs +6 -4
- package/lib/install.cjs +1 -1
- package/lib/lab_write_contract.json +4 -4
- package/lib/rule_preflight.cjs +49 -1
- package/package-assets/claude/commands/lab/write.md +1 -1
- package/package-assets/claude/commands/lab-write.md +1 -1
- package/package-assets/claude/commands/lab:write.md +1 -1
- package/package-assets/claude/commands/lab/357/274/232write.md +1 -1
- package/package-assets/codex/prompts/lab/write.md +1 -1
- package/package-assets/codex/prompts/lab-write.md +1 -1
- package/package-assets/codex/prompts/lab:write.md +1 -1
- package/package-assets/codex/prompts/lab/357/274/232write.md +1 -1
- package/package-assets/shared/lab/.managed/scripts/extract_reference_paper_structure.py +910 -0
- package/package-assets/shared/lab/.managed/scripts/paper_topology.py +91 -0
- package/package-assets/shared/lab/.managed/scripts/render_rule_preflight.py +115 -0
- package/package-assets/shared/lab/.managed/scripts/validate_manuscript_delivery.py +59 -0
- package/package-assets/shared/lab/.managed/scripts/validate_paper_topology.py +83 -0
- package/package-assets/shared/lab/.managed/scripts/validate_rule_preflight.py +183 -0
- package/package-assets/shared/lab/.managed/scripts/validate_section_draft.py +134 -12
- package/package-assets/shared/lab/.managed/templates/iteration-report.md +1 -0
- package/package-assets/shared/lab/.managed/templates/reference-template-intake.md +40 -0
- package/package-assets/shared/lab/.managed/templates/write-iteration.md +28 -0
- package/package-assets/shared/lab/context/auto-status.md +1 -0
- package/package-assets/shared/skills/lab/SKILL.md +2 -0
- package/package-assets/shared/skills/lab/stages/auto.md +1 -1
- package/package-assets/shared/skills/lab/stages/write.md +21 -3
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEPRECATION_MARKERS = ("archived", "deprecated", "legacy", "out-of-band", "归档", "废弃", "旧层", "旁路", "历史")
|
|
9
|
+
PATH_TOKEN_PATTERN = re.compile(
|
|
10
|
+
r"(/[^ \t\n\r)]+|(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.\-/]+)"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_project_root(start_path: Path) -> Path | None:
|
|
15
|
+
for parent in (start_path, *start_path.parents):
|
|
16
|
+
workflow_config = parent / ".lab" / "config" / "workflow.json"
|
|
17
|
+
if workflow_config.exists():
|
|
18
|
+
return parent
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_workflow_config(project_root: Path | None) -> dict:
|
|
23
|
+
if project_root is None:
|
|
24
|
+
return {}
|
|
25
|
+
workflow_config = project_root / ".lab" / "config" / "workflow.json"
|
|
26
|
+
if not workflow_config.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(workflow_config.read_text(encoding="utf-8"))
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_paper_topology(project_root: Path, workflow_config: dict | None = None) -> dict:
|
|
35
|
+
config = workflow_config or load_workflow_config(project_root)
|
|
36
|
+
deliverables_root = (project_root / config.get("deliverables_root", "docs/research")).resolve()
|
|
37
|
+
canonical_root = (deliverables_root / "paper").resolve()
|
|
38
|
+
workflow_language = config.get("workflow_language", "")
|
|
39
|
+
paper_language = config.get("paper_language", "")
|
|
40
|
+
workflow_language_active = bool(workflow_language and paper_language and workflow_language != paper_language)
|
|
41
|
+
workflow_root = (canonical_root / "workflow-language").resolve() if workflow_language_active else None
|
|
42
|
+
return {
|
|
43
|
+
"project_root": project_root.resolve(),
|
|
44
|
+
"deliverables_root": deliverables_root,
|
|
45
|
+
"canonical_root": canonical_root,
|
|
46
|
+
"workflow_root": workflow_root,
|
|
47
|
+
"workflow_language_active": workflow_language_active,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_path(path_value: str | Path, project_root: Path) -> Path:
|
|
52
|
+
path_obj = Path(path_value)
|
|
53
|
+
return path_obj.resolve() if path_obj.is_absolute() else (project_root / path_obj).resolve()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_within(candidate: Path, root: Path | None) -> bool:
|
|
57
|
+
if root is None:
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
candidate.relative_to(root)
|
|
61
|
+
return True
|
|
62
|
+
except ValueError:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_paper_path(path_value: str | Path, topology: dict) -> tuple[str, Path]:
|
|
67
|
+
candidate = normalize_path(path_value, topology["project_root"])
|
|
68
|
+
if topology["workflow_language_active"] and is_within(candidate, topology["workflow_root"]):
|
|
69
|
+
return "active-workflow-language", candidate
|
|
70
|
+
if is_within(candidate, topology["canonical_root"]):
|
|
71
|
+
return "active-canonical", candidate
|
|
72
|
+
|
|
73
|
+
parts = candidate.parts
|
|
74
|
+
if any(part.startswith("review_") or part.startswith("translation_") or part.startswith("sections_") for part in parts):
|
|
75
|
+
return "legacy-paper-layer", candidate
|
|
76
|
+
if "deliverables" in parts and ("sections" in parts or "workflow-language" in parts):
|
|
77
|
+
return "legacy-paper-layer", candidate
|
|
78
|
+
if "docs" in parts and "lab" in parts and "paper" in parts:
|
|
79
|
+
return "legacy-paper-layer", candidate
|
|
80
|
+
return "out-of-band", candidate
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def line_is_deprecated(line: str) -> bool:
|
|
84
|
+
lowered = line.lower()
|
|
85
|
+
return any(marker in lowered for marker in DEPRECATION_MARKERS)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def iter_path_tokens(text: str):
|
|
89
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
90
|
+
for match in PATH_TOKEN_PATTERN.finditer(line):
|
|
91
|
+
yield line_number, line, match.group(1)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from validate_rule_preflight import compute_preflight_stamp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_args():
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Render or inject a machine-generated Rule Preflight block for a managed lab stage artifact."
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("--artifact", required=True, help="Markdown artifact to update in place")
|
|
17
|
+
parser.add_argument("--stage", required=True, help="Stage name, e.g. write or auto")
|
|
18
|
+
parser.add_argument("--project-root", required=True, help="Project root containing .lab/.managed/rule-manifest.json")
|
|
19
|
+
parser.add_argument("--mode", required=True, help="Resolved mode for this round")
|
|
20
|
+
parser.add_argument("--target", required=True, help="Resolved target for this round")
|
|
21
|
+
parser.add_argument("--override-reason", default="", help="Optional override reason")
|
|
22
|
+
parser.add_argument("--lang", choices=("en", "zh"), default="en", help="Render language for the section")
|
|
23
|
+
return parser.parse_args()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_manifest(project_root: Path, stage: str) -> dict:
|
|
27
|
+
manifest_path = project_root / ".lab" / ".managed" / "rule-manifest.json"
|
|
28
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
29
|
+
stage_entry = (manifest.get("stages") or {}).get(stage)
|
|
30
|
+
if not stage_entry:
|
|
31
|
+
raise SystemExit(f"stage `{stage}` is missing from {manifest_path}")
|
|
32
|
+
return {
|
|
33
|
+
"rule_source_file": stage_entry.get("rule_source_file", ""),
|
|
34
|
+
"rule_source_revision": stage_entry.get("rule_source_revision", ""),
|
|
35
|
+
"project_version": manifest.get("package_version", ""),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_preflight_block(values: dict[str, str], lang: str) -> str:
|
|
40
|
+
values = dict(values)
|
|
41
|
+
values["preflight_stamp"] = compute_preflight_stamp(values)
|
|
42
|
+
if lang == "zh":
|
|
43
|
+
lines = [
|
|
44
|
+
"## 规则预检",
|
|
45
|
+
"",
|
|
46
|
+
f"- 规则来源文件: {values['rule_source_file']}",
|
|
47
|
+
f"- 规则来源修订: {values['rule_source_revision']}",
|
|
48
|
+
f"- 项目版本: {values['project_version']}",
|
|
49
|
+
f"- 解析后的阶段: {values['resolved_stage']}",
|
|
50
|
+
f"- 解析后的模式: {values['resolved_mode']}",
|
|
51
|
+
f"- 解析后的目标: {values['resolved_target']}",
|
|
52
|
+
f"- 预检签名: {values['preflight_stamp']}",
|
|
53
|
+
f"- 如有覆盖理由: {values['override_reason']}",
|
|
54
|
+
"",
|
|
55
|
+
]
|
|
56
|
+
else:
|
|
57
|
+
lines = [
|
|
58
|
+
"## Rule Preflight",
|
|
59
|
+
"",
|
|
60
|
+
f"- Rule source file: {values['rule_source_file']}",
|
|
61
|
+
f"- Rule source revision: {values['rule_source_revision']}",
|
|
62
|
+
f"- Project version: {values['project_version']}",
|
|
63
|
+
f"- Resolved stage: {values['resolved_stage']}",
|
|
64
|
+
f"- Resolved mode: {values['resolved_mode']}",
|
|
65
|
+
f"- Resolved target: {values['resolved_target']}",
|
|
66
|
+
f"- Preflight stamp: {values['preflight_stamp']}",
|
|
67
|
+
f"- Override reason, if any: {values['override_reason']}",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def replace_or_insert_rule_preflight(text: str, block: str) -> str:
|
|
74
|
+
pattern = re.compile(r"^##\s+(?:Rule Preflight|规则预检)\s*$", flags=re.MULTILINE)
|
|
75
|
+
match = pattern.search(text)
|
|
76
|
+
if match:
|
|
77
|
+
start = match.start()
|
|
78
|
+
next_heading = re.search(r"^##\s+", text[match.end():], flags=re.MULTILINE)
|
|
79
|
+
end = match.end() + next_heading.start() if next_heading else len(text)
|
|
80
|
+
prefix = text[:start].rstrip() + "\n\n"
|
|
81
|
+
suffix = text[end:].lstrip("\n")
|
|
82
|
+
return prefix + block.rstrip() + ("\n\n" + suffix if suffix else "\n")
|
|
83
|
+
|
|
84
|
+
title_match = re.search(r"^# .*$", text, flags=re.MULTILINE)
|
|
85
|
+
if not title_match:
|
|
86
|
+
return block.rstrip() + "\n\n" + text.lstrip()
|
|
87
|
+
insert_at = title_match.end()
|
|
88
|
+
prefix = text[:insert_at].rstrip() + "\n\n"
|
|
89
|
+
suffix = text[insert_at:].lstrip("\n")
|
|
90
|
+
return prefix + block.rstrip() + ("\n\n" + suffix if suffix else "\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
args = parse_args()
|
|
95
|
+
artifact_path = Path(args.artifact)
|
|
96
|
+
project_root = Path(args.project_root)
|
|
97
|
+
values = read_manifest(project_root, args.stage)
|
|
98
|
+
values.update(
|
|
99
|
+
{
|
|
100
|
+
"resolved_stage": args.stage,
|
|
101
|
+
"resolved_mode": args.mode,
|
|
102
|
+
"resolved_target": args.target,
|
|
103
|
+
"override_reason": args.override_reason,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
block = render_preflight_block(values, args.lang)
|
|
107
|
+
original = artifact_path.read_text(encoding="utf-8") if artifact_path.exists() else ""
|
|
108
|
+
updated = replace_or_insert_rule_preflight(original, block)
|
|
109
|
+
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
artifact_path.write_text(updated, encoding="utf-8")
|
|
111
|
+
print("rule preflight written")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from validate_rule_preflight import validate_rule_preflight
|
|
8
|
+
from validate_paper_topology import validate_topology_artifacts
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
ABSOLUTE_PATH_MARKERS = ("/Users/", "/home/", "/tmp/", "/private/tmp/")
|
|
@@ -148,6 +149,54 @@ def extract_table_note(text: str) -> str:
|
|
|
148
149
|
return "\n".join(line.strip() for line in text.splitlines() if line.strip().startswith("%"))
|
|
149
150
|
|
|
150
151
|
|
|
152
|
+
def extract_tabular_specs(text: str) -> list[str]:
|
|
153
|
+
return re.findall(r"\\begin\{tabular\}(?:\[[^\]]*\])?\{([^}]*)\}", text)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def extract_tabular_bodies(text: str) -> list[str]:
|
|
157
|
+
return [
|
|
158
|
+
match.group(1)
|
|
159
|
+
for match in re.finditer(
|
|
160
|
+
r"\\begin\{tabular\}(?:\[[^\]]*\])?\{[^}]*\}([\s\S]*?)\\end\{tabular\}",
|
|
161
|
+
text,
|
|
162
|
+
)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def strip_latex_for_table_cell(cell: str) -> str:
|
|
167
|
+
cell = re.sub(r"%.*", "", cell)
|
|
168
|
+
cell = re.sub(r"\\(?:textbf|mathbf|emph)\{([^}]*)\}", r"\1", cell)
|
|
169
|
+
cell = re.sub(r"\\[A-Za-z@*]+(?:\[[^\]]*\])?", " ", cell)
|
|
170
|
+
cell = cell.replace("$", " ")
|
|
171
|
+
return cell.strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def check_numeric_precision_consistency(text: str, issues: list[str], label: str):
|
|
175
|
+
for body in extract_tabular_bodies(text):
|
|
176
|
+
column_precisions: dict[int, set[int]] = {}
|
|
177
|
+
rows = re.split(r"\\\\", body)
|
|
178
|
+
for row in rows:
|
|
179
|
+
row = re.sub(r"\\(?:toprule|midrule|bottomrule|cmidrule)(?:\{[^}]*\})?", " ", row)
|
|
180
|
+
if "&" not in row:
|
|
181
|
+
continue
|
|
182
|
+
cells = [strip_latex_for_table_cell(cell) for cell in row.split("&")]
|
|
183
|
+
for index, cell in enumerate(cells):
|
|
184
|
+
matches = re.findall(r"(?<![A-Za-z])[-+]?\d+\.(\d+)", cell)
|
|
185
|
+
if not matches:
|
|
186
|
+
continue
|
|
187
|
+
column_precisions.setdefault(index, set()).update(len(match) for match in matches)
|
|
188
|
+
inconsistent_columns = [
|
|
189
|
+
str(index + 1)
|
|
190
|
+
for index, precisions in sorted(column_precisions.items())
|
|
191
|
+
if len(precisions) > 1
|
|
192
|
+
]
|
|
193
|
+
if inconsistent_columns:
|
|
194
|
+
issues.append(
|
|
195
|
+
f"{label} has inconsistent numeric precision in column(s): {', '.join(inconsistent_columns)}"
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
|
|
151
200
|
def detect_uppercase_abbreviations(text: str) -> set[str]:
|
|
152
201
|
return {
|
|
153
202
|
token
|
|
@@ -209,8 +258,16 @@ def check_table_file(path: Path, issues: list[str], label: str):
|
|
|
209
258
|
issues.append(f"{label} must contain a table environment")
|
|
210
259
|
if r"\caption{" not in text or r"\label{" not in text:
|
|
211
260
|
issues.append(f"{label} must contain both caption and label")
|
|
261
|
+
first_caption = text.find(r"\caption{")
|
|
262
|
+
first_tabular = text.find(r"\begin{tabular}")
|
|
263
|
+
if first_caption != -1 and first_tabular != -1 and first_caption > first_tabular:
|
|
264
|
+
issues.append(f"{label} should place its caption before the tabular body")
|
|
212
265
|
if not all(token in text for token in (r"\toprule", r"\midrule", r"\bottomrule")):
|
|
213
266
|
issues.append(f"{label} must use booktabs structure")
|
|
267
|
+
if any("|" in spec for spec in extract_tabular_specs(text)) or r"\vline" in text:
|
|
268
|
+
issues.append(f"{label} must not use vertical table rules; use booktabs spacing instead")
|
|
269
|
+
if r"\hline" in text or r"\cline" in text:
|
|
270
|
+
issues.append(f"{label} must not mix legacy \\hline/\\cline rules with booktabs tables")
|
|
214
271
|
if not all(marker in text for marker in REQUIRED_TABLE_NOTE_MARKERS):
|
|
215
272
|
issues.append(f"{label} must include a local table note scaffold")
|
|
216
273
|
caption_text = extract_caption(text)
|
|
@@ -233,6 +290,7 @@ def check_table_file(path: Path, issues: list[str], label: str):
|
|
|
233
290
|
continue
|
|
234
291
|
if value < 3.0:
|
|
235
292
|
issues.append(f"{label} sets \\tabcolsep below the safe range for paper-facing main tables")
|
|
293
|
+
check_numeric_precision_consistency(text, issues, label)
|
|
236
294
|
|
|
237
295
|
|
|
238
296
|
def check_figure_file(path: Path, issues: list[str], label: str):
|
|
@@ -609,6 +667,7 @@ def check_latest_write_iteration_preflight(paper_dir: Path, issues: list[str]):
|
|
|
609
667
|
issues.append("missing latest write iteration needed for rule preflight validation")
|
|
610
668
|
return
|
|
611
669
|
issues.extend(validate_rule_preflight(latest_iteration, "write", project_root=project_root))
|
|
670
|
+
issues.extend(validate_topology_artifacts(project_root))
|
|
612
671
|
|
|
613
672
|
|
|
614
673
|
def main():
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from paper_topology import (
|
|
9
|
+
classify_paper_path,
|
|
10
|
+
find_project_root,
|
|
11
|
+
iter_path_tokens,
|
|
12
|
+
line_is_deprecated,
|
|
13
|
+
resolve_paper_topology,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_args():
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
description="Validate that plan/context artifacts point at the active paper topology instead of legacy side layers."
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--project-root", required=True, help="Project root containing .lab/config/workflow.json")
|
|
22
|
+
return parser.parse_args()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_text(path: Path) -> str:
|
|
26
|
+
return path.read_text(encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_file(file_path: Path, topology: dict, issues: list[str]):
|
|
30
|
+
if not file_path.exists():
|
|
31
|
+
return
|
|
32
|
+
text = read_text(file_path)
|
|
33
|
+
current_heading_deprecated = False
|
|
34
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
35
|
+
stripped = line.strip()
|
|
36
|
+
if stripped.startswith("#"):
|
|
37
|
+
current_heading_deprecated = line_is_deprecated(stripped)
|
|
38
|
+
for _, _, token in iter_path_tokens(line):
|
|
39
|
+
role, normalized_path = classify_paper_path(token, topology)
|
|
40
|
+
if role in {"active-canonical", "active-workflow-language"}:
|
|
41
|
+
continue
|
|
42
|
+
if current_heading_deprecated or line_is_deprecated(line):
|
|
43
|
+
continue
|
|
44
|
+
if role == "legacy-paper-layer":
|
|
45
|
+
issues.append(
|
|
46
|
+
f"{file_path}:line {line_number} treats legacy-paper-layer path as active topology: {token} -> {normalized_path}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_topology_artifacts(project_root: Path) -> list[str]:
|
|
51
|
+
topology = resolve_paper_topology(project_root)
|
|
52
|
+
files_to_check = [
|
|
53
|
+
project_root / ".lab" / "writing" / "plan.md",
|
|
54
|
+
topology["deliverables_root"] / "artifact-status.md",
|
|
55
|
+
project_root / ".lab" / "context" / "evidence-index.md",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
issues: list[str] = []
|
|
59
|
+
for file_path in files_to_check:
|
|
60
|
+
validate_file(file_path, topology, issues)
|
|
61
|
+
return issues
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
args = parse_args()
|
|
66
|
+
project_root = find_project_root(Path(args.project_root).resolve())
|
|
67
|
+
if project_root is None:
|
|
68
|
+
print("could not find .lab/config/workflow.json for project root", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
issues = validate_topology_artifacts(project_root)
|
|
72
|
+
|
|
73
|
+
if issues:
|
|
74
|
+
for issue in issues:
|
|
75
|
+
print(issue, file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
print("paper topology is consistent")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SECTION_PATTERNS = (r"^##\s+Rule Preflight\s*$", r"^##\s+规则预检\s*$")
|
|
11
|
+
FIELD_LABELS = {
|
|
12
|
+
"rule_source_file": ("Rule source file", "规则来源文件"),
|
|
13
|
+
"rule_source_revision": ("Rule source revision", "规则来源修订"),
|
|
14
|
+
"project_version": ("Project version", "项目版本"),
|
|
15
|
+
"resolved_stage": ("Resolved stage", "解析后的阶段"),
|
|
16
|
+
"resolved_mode": ("Resolved mode", "解析后的模式"),
|
|
17
|
+
"resolved_target": ("Resolved target", "解析后的目标"),
|
|
18
|
+
"preflight_stamp": ("Preflight stamp", "预检签名"),
|
|
19
|
+
"override_reason": ("Override reason, if any", "Override 理由(如有)"),
|
|
20
|
+
}
|
|
21
|
+
BASE_REQUIRED_FIELD_KEYS = (
|
|
22
|
+
"rule_source_file",
|
|
23
|
+
"rule_source_revision",
|
|
24
|
+
"project_version",
|
|
25
|
+
"resolved_stage",
|
|
26
|
+
"resolved_mode",
|
|
27
|
+
"resolved_target",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_args():
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
description="Validate that a managed stage artifact records a consistent Rule Preflight block."
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--artifact", required=True, help="Path to the managed stage artifact")
|
|
36
|
+
parser.add_argument("--stage", required=True, help="Stage name, e.g. write or auto")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--project-root",
|
|
39
|
+
required=False,
|
|
40
|
+
help="Optional project root; if omitted, infer it by walking upward for .lab/config/workflow.json",
|
|
41
|
+
)
|
|
42
|
+
return parser.parse_args()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_text(path: Path) -> str:
|
|
46
|
+
return path.read_text(encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_project_root(start_path: Path) -> Path | None:
|
|
50
|
+
for parent in (start_path.parent, *start_path.parents):
|
|
51
|
+
workflow_config = parent / ".lab" / "config" / "workflow.json"
|
|
52
|
+
if workflow_config.exists():
|
|
53
|
+
return parent
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_section_body(text: str, patterns: tuple[str, ...]) -> str:
|
|
58
|
+
for pattern in patterns:
|
|
59
|
+
match = re.search(pattern, text, flags=re.MULTILINE)
|
|
60
|
+
if not match:
|
|
61
|
+
continue
|
|
62
|
+
start = match.end()
|
|
63
|
+
next_heading = re.search(r"^##\s+", text[start:], flags=re.MULTILINE)
|
|
64
|
+
end = start + next_heading.start() if next_heading else len(text)
|
|
65
|
+
return text[start:end].strip()
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_field_value(body: str, labels: tuple[str, ...]) -> str | None:
|
|
70
|
+
for label in labels:
|
|
71
|
+
pattern = re.compile(
|
|
72
|
+
rf"^\s*(?:-|\d+\.)\s*{re.escape(label)}[::][ \t]*([^\n]+?)\s*$",
|
|
73
|
+
flags=re.MULTILINE,
|
|
74
|
+
)
|
|
75
|
+
for match in pattern.finditer(body):
|
|
76
|
+
return match.group(1).strip()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def has_meaningful_value(value: str | None) -> bool:
|
|
81
|
+
if value is None:
|
|
82
|
+
return False
|
|
83
|
+
normalized = value.strip().lower()
|
|
84
|
+
return normalized not in {"", "-", "n/a", "na", "none", "null", "todo", "tbd"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compute_preflight_stamp(values: dict[str, str | None]) -> str:
|
|
88
|
+
payload = {
|
|
89
|
+
"ruleSourceFile": values.get("rule_source_file") or "",
|
|
90
|
+
"ruleSourceRevision": values.get("rule_source_revision") or "",
|
|
91
|
+
"projectVersion": values.get("project_version") or "",
|
|
92
|
+
"resolvedStage": values.get("resolved_stage") or "",
|
|
93
|
+
"resolvedMode": values.get("resolved_mode") or "",
|
|
94
|
+
"resolvedTarget": values.get("resolved_target") or "",
|
|
95
|
+
"overrideReason": values.get("override_reason") or "",
|
|
96
|
+
}
|
|
97
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_rule_preflight(artifact_path: Path, stage: str, project_root: Path | None = None) -> list[str]:
|
|
101
|
+
issues: list[str] = []
|
|
102
|
+
if project_root is None:
|
|
103
|
+
project_root = find_project_root(artifact_path)
|
|
104
|
+
if project_root is None:
|
|
105
|
+
issues.append("could not infer project root for rule preflight validation")
|
|
106
|
+
return issues
|
|
107
|
+
|
|
108
|
+
manifest_path = project_root / ".lab" / ".managed" / "rule-manifest.json"
|
|
109
|
+
if not manifest_path.exists():
|
|
110
|
+
issues.append(f"missing managed rule manifest: {manifest_path.as_posix()}")
|
|
111
|
+
return issues
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
manifest = json.loads(read_text(manifest_path))
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
issues.append(f"managed rule manifest is invalid JSON: {manifest_path.as_posix()}")
|
|
117
|
+
return issues
|
|
118
|
+
|
|
119
|
+
stage_entry = (manifest.get("stages") or {}).get(stage)
|
|
120
|
+
if not stage_entry:
|
|
121
|
+
issues.append(f"managed rule manifest does not define stage `{stage}`")
|
|
122
|
+
return issues
|
|
123
|
+
|
|
124
|
+
if not artifact_path.exists():
|
|
125
|
+
issues.append(f"artifact does not exist: {artifact_path.as_posix()}")
|
|
126
|
+
return issues
|
|
127
|
+
|
|
128
|
+
body = extract_section_body(read_text(artifact_path), SECTION_PATTERNS)
|
|
129
|
+
if not body:
|
|
130
|
+
issues.append("missing Rule Preflight section")
|
|
131
|
+
return issues
|
|
132
|
+
|
|
133
|
+
values = {
|
|
134
|
+
key: extract_field_value(body, labels)
|
|
135
|
+
for key, labels in FIELD_LABELS.items()
|
|
136
|
+
}
|
|
137
|
+
required_field_keys = list(BASE_REQUIRED_FIELD_KEYS)
|
|
138
|
+
if int(manifest.get("layout_version") or 0) >= 2:
|
|
139
|
+
required_field_keys.append("preflight_stamp")
|
|
140
|
+
missing = [key for key in required_field_keys if not has_meaningful_value(values[key])]
|
|
141
|
+
if missing:
|
|
142
|
+
pretty = ", ".join(FIELD_LABELS[key][0] for key in missing)
|
|
143
|
+
issues.append(f"missing required rule preflight fields: {pretty}")
|
|
144
|
+
return issues
|
|
145
|
+
|
|
146
|
+
if values["rule_source_file"] != stage_entry.get("rule_source_file"):
|
|
147
|
+
issues.append(
|
|
148
|
+
f"rule preflight source file mismatch: expected {stage_entry.get('rule_source_file')}, got {values['rule_source_file']}"
|
|
149
|
+
)
|
|
150
|
+
if values["rule_source_revision"] != stage_entry.get("rule_source_revision"):
|
|
151
|
+
issues.append(
|
|
152
|
+
"rule preflight source revision mismatch"
|
|
153
|
+
)
|
|
154
|
+
if values["project_version"] != manifest.get("package_version"):
|
|
155
|
+
issues.append(
|
|
156
|
+
f"rule preflight project version mismatch: expected {manifest.get('package_version')}, got {values['project_version']}"
|
|
157
|
+
)
|
|
158
|
+
if values["resolved_stage"].strip().lower() != stage.strip().lower():
|
|
159
|
+
issues.append(
|
|
160
|
+
f"rule preflight stage mismatch: expected {stage}, got {values['resolved_stage']}"
|
|
161
|
+
)
|
|
162
|
+
if int(manifest.get("layout_version") or 0) >= 2:
|
|
163
|
+
expected_stamp = compute_preflight_stamp(values)
|
|
164
|
+
if values["preflight_stamp"] != expected_stamp:
|
|
165
|
+
issues.append("rule preflight stamp mismatch")
|
|
166
|
+
return issues
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
args = parse_args()
|
|
171
|
+
artifact_path = Path(args.artifact)
|
|
172
|
+
project_root = Path(args.project_root) if args.project_root else None
|
|
173
|
+
issues = validate_rule_preflight(artifact_path, args.stage, project_root=project_root)
|
|
174
|
+
if not issues:
|
|
175
|
+
print("rule preflight is valid")
|
|
176
|
+
return 0
|
|
177
|
+
for issue in issues:
|
|
178
|
+
print(issue, file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
raise SystemExit(main())
|