code-factory-2-forge 0.9.0__tar.gz → 0.9.2__tar.gz

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.
Files changed (39) hide show
  1. {code_factory_2_forge-0.9.0/code_factory_2_forge.egg-info → code_factory_2_forge-0.9.2}/PKG-INFO +21 -3
  2. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/README.md +20 -2
  3. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2/code_factory_2_forge.egg-info}/PKG-INFO +21 -3
  4. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/__init__.py +1 -1
  5. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/cli.py +7 -1
  6. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/typescript_mutants.py +21 -1
  7. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/orchestrator.py +47 -7
  8. code_factory_2_forge-0.9.2/forgeline/ssat.py +345 -0
  9. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/pyproject.toml +1 -1
  10. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/tests/test_forgeline.py +151 -1
  11. code_factory_2_forge-0.9.0/forgeline/ssat.py +0 -93
  12. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/LICENSE-APACHE +0 -0
  13. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/LICENSE-MIT +0 -0
  14. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/NOTICE +0 -0
  15. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/SOURCES.txt +0 -0
  16. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/dependency_links.txt +0 -0
  17. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/entry_points.txt +0 -0
  18. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/requires.txt +0 -0
  19. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/top_level.txt +0 -0
  20. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/adapters.py +0 -0
  21. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/adoption.py +0 -0
  22. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/attribution.py +0 -0
  23. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/demo.py +0 -0
  24. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/demo_learning.py +0 -0
  25. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/__init__.py +0 -0
  26. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/adversary.py +0 -0
  27. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/judge.py +0 -0
  28. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/qa_audit.py +0 -0
  29. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/reverse_classical.py +0 -0
  30. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/runtime_smoke.py +0 -0
  31. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/skill_check.py +0 -0
  32. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/intent_thread.py +0 -0
  33. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/learning.py +0 -0
  34. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/pr_optimizer.py +0 -0
  35. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/refinement.py +0 -0
  36. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/run_store.py +0 -0
  37. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/skill_memory.py +0 -0
  38. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/states.py +0 -0
  39. {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-factory-2-forge
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
4
  Summary: ForgeLine — the autonomous outer loop for AI software factories. A CLI-backed state machine that drives intent -> spec -> plan -> code -> adversarial review -> ship, orchestrating SpecLine (spec governance) and Harness Software Factory (compiled decisions), with self-improving skills and architecture-as-a-CI-gate.
5
5
  License-Expression: MIT OR Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/zrk222/code-factory-2-forge
@@ -77,7 +77,7 @@ forge init
77
77
  forge agent claude # wire your agent (see full list below)
78
78
  forge status <feature> # the state machine names the ONE next action
79
79
  forge optimize-pr <feature> # bounded PR hardening loop plan
80
- pytest -q # 29 tests
80
+ pytest -q # full ForgeLine suite
81
81
  ```
82
82
 
83
83
  ## PR hardening loop
@@ -123,6 +123,22 @@ as the CI gate: `check_erosion()` detects signature drift (`E_SIG_DRIFT`),
123
123
  illegal dependencies (`E_ILLEGAL_DEP`), and invariant violations
124
124
  (`E_INVARIANT`) — structural erosion caught before merge.
125
125
 
126
+ **Scaffolding is fail-closed.** `forge architect` will not overwrite an
127
+ existing module. Start with a plan:
128
+
129
+ ```bash
130
+ forge architect <feature> <ssat> --dry-run --root .
131
+ ```
132
+
133
+ The JSON report separates `created`, `skipped`, `conflicts`, and `overwritten`
134
+ files and includes before/after SHA-256 values. Existing targets are reported as
135
+ conflicts and leave both the files and feature state unchanged. An intentional
136
+ replacement requires `--force`; ForgeLine first writes timestamped backups under
137
+ `.forge/scaffold-backups/`, validates every generated file, and restores every
138
+ modified target if any replacement fails. Python, TypeScript, and TSX scaffolds
139
+ use extension-specific generators and structure checks; unsupported extensions
140
+ are rejected rather than receiving Python syntax.
141
+
126
142
  **The grumpy adversary.** A review agent that *assumes your code is broken and
127
143
  insecure* and makes the generator prove otherwise. Executable heuristics catch
128
144
  eval/exec/shell-injection/hard-coded-secrets/bare-excepts, and it refuses to
@@ -157,7 +173,9 @@ forge agent claude|codex wire the entry-point skill
157
173
  forge status <feature> current state + the ONE next action
158
174
  forge expand <feature> draft use cases (→ human gate)
159
175
  forge gate architected <feature> record explicit architecture approval
160
- forge architect <feature> <ssat> generate scaffold from architecture-as-code
176
+ forge architect <feature> <ssat> --dry-run show create/conflict/overwrite plan without writes
177
+ forge architect <feature> <ssat> generate new files; existing files fail closed
178
+ forge architect <feature> <ssat> --force back up then intentionally replace existing targets
161
179
  forge fill <feature> <ssat> prove bodies are implemented; enter FILLED
162
180
  forge review <feature> <ssat> judge + grumpy adversary + arch erosion (refine loop)
163
181
  forge arch-gate <feature> <ssat> architecture CI gate
@@ -59,7 +59,7 @@ forge init
59
59
  forge agent claude # wire your agent (see full list below)
60
60
  forge status <feature> # the state machine names the ONE next action
61
61
  forge optimize-pr <feature> # bounded PR hardening loop plan
62
- pytest -q # 29 tests
62
+ pytest -q # full ForgeLine suite
63
63
  ```
64
64
 
65
65
  ## PR hardening loop
@@ -105,6 +105,22 @@ as the CI gate: `check_erosion()` detects signature drift (`E_SIG_DRIFT`),
105
105
  illegal dependencies (`E_ILLEGAL_DEP`), and invariant violations
106
106
  (`E_INVARIANT`) — structural erosion caught before merge.
107
107
 
108
+ **Scaffolding is fail-closed.** `forge architect` will not overwrite an
109
+ existing module. Start with a plan:
110
+
111
+ ```bash
112
+ forge architect <feature> <ssat> --dry-run --root .
113
+ ```
114
+
115
+ The JSON report separates `created`, `skipped`, `conflicts`, and `overwritten`
116
+ files and includes before/after SHA-256 values. Existing targets are reported as
117
+ conflicts and leave both the files and feature state unchanged. An intentional
118
+ replacement requires `--force`; ForgeLine first writes timestamped backups under
119
+ `.forge/scaffold-backups/`, validates every generated file, and restores every
120
+ modified target if any replacement fails. Python, TypeScript, and TSX scaffolds
121
+ use extension-specific generators and structure checks; unsupported extensions
122
+ are rejected rather than receiving Python syntax.
123
+
108
124
  **The grumpy adversary.** A review agent that *assumes your code is broken and
109
125
  insecure* and makes the generator prove otherwise. Executable heuristics catch
110
126
  eval/exec/shell-injection/hard-coded-secrets/bare-excepts, and it refuses to
@@ -139,7 +155,9 @@ forge agent claude|codex wire the entry-point skill
139
155
  forge status <feature> current state + the ONE next action
140
156
  forge expand <feature> draft use cases (→ human gate)
141
157
  forge gate architected <feature> record explicit architecture approval
142
- forge architect <feature> <ssat> generate scaffold from architecture-as-code
158
+ forge architect <feature> <ssat> --dry-run show create/conflict/overwrite plan without writes
159
+ forge architect <feature> <ssat> generate new files; existing files fail closed
160
+ forge architect <feature> <ssat> --force back up then intentionally replace existing targets
143
161
  forge fill <feature> <ssat> prove bodies are implemented; enter FILLED
144
162
  forge review <feature> <ssat> judge + grumpy adversary + arch erosion (refine loop)
145
163
  forge arch-gate <feature> <ssat> architecture CI gate
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-factory-2-forge
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
4
  Summary: ForgeLine — the autonomous outer loop for AI software factories. A CLI-backed state machine that drives intent -> spec -> plan -> code -> adversarial review -> ship, orchestrating SpecLine (spec governance) and Harness Software Factory (compiled decisions), with self-improving skills and architecture-as-a-CI-gate.
5
5
  License-Expression: MIT OR Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/zrk222/code-factory-2-forge
@@ -77,7 +77,7 @@ forge init
77
77
  forge agent claude # wire your agent (see full list below)
78
78
  forge status <feature> # the state machine names the ONE next action
79
79
  forge optimize-pr <feature> # bounded PR hardening loop plan
80
- pytest -q # 29 tests
80
+ pytest -q # full ForgeLine suite
81
81
  ```
82
82
 
83
83
  ## PR hardening loop
@@ -123,6 +123,22 @@ as the CI gate: `check_erosion()` detects signature drift (`E_SIG_DRIFT`),
123
123
  illegal dependencies (`E_ILLEGAL_DEP`), and invariant violations
124
124
  (`E_INVARIANT`) — structural erosion caught before merge.
125
125
 
126
+ **Scaffolding is fail-closed.** `forge architect` will not overwrite an
127
+ existing module. Start with a plan:
128
+
129
+ ```bash
130
+ forge architect <feature> <ssat> --dry-run --root .
131
+ ```
132
+
133
+ The JSON report separates `created`, `skipped`, `conflicts`, and `overwritten`
134
+ files and includes before/after SHA-256 values. Existing targets are reported as
135
+ conflicts and leave both the files and feature state unchanged. An intentional
136
+ replacement requires `--force`; ForgeLine first writes timestamped backups under
137
+ `.forge/scaffold-backups/`, validates every generated file, and restores every
138
+ modified target if any replacement fails. Python, TypeScript, and TSX scaffolds
139
+ use extension-specific generators and structure checks; unsupported extensions
140
+ are rejected rather than receiving Python syntax.
141
+
126
142
  **The grumpy adversary.** A review agent that *assumes your code is broken and
127
143
  insecure* and makes the generator prove otherwise. Executable heuristics catch
128
144
  eval/exec/shell-injection/hard-coded-secrets/bare-excepts, and it refuses to
@@ -157,7 +173,9 @@ forge agent claude|codex wire the entry-point skill
157
173
  forge status <feature> current state + the ONE next action
158
174
  forge expand <feature> draft use cases (→ human gate)
159
175
  forge gate architected <feature> record explicit architecture approval
160
- forge architect <feature> <ssat> generate scaffold from architecture-as-code
176
+ forge architect <feature> <ssat> --dry-run show create/conflict/overwrite plan without writes
177
+ forge architect <feature> <ssat> generate new files; existing files fail closed
178
+ forge architect <feature> <ssat> --force back up then intentionally replace existing targets
161
179
  forge fill <feature> <ssat> prove bodies are implemented; enter FILLED
162
180
  forge review <feature> <ssat> judge + grumpy adversary + arch erosion (refine loop)
163
181
  forge arch-gate <feature> <ssat> architecture CI gate
@@ -11,4 +11,4 @@ hard CI gate, and learning from every run via an evolving skill memory.
11
11
  └──────── refine loop ─────────┘
12
12
  skill memory learns each pass
13
13
  """
14
- __version__ = "0.7.0"
14
+ __version__ = "0.9.1"
@@ -28,6 +28,9 @@ def main(argv=None):
28
28
  if name == "init": sp.add_argument("--root", default=".")
29
29
  elif name in {"architect","fill","review","arch-gate","verify-tests"}:
30
30
  sp.add_argument("feature"); sp.add_argument("ssat", nargs="?"); sp.add_argument("--root", default=".")
31
+ if name == "architect":
32
+ sp.add_argument("--dry-run", action="store_true", help="plan scaffold actions without writing files")
33
+ sp.add_argument("--force", action="store_true", help="overwrite existing targets after creating backups")
31
34
  elif name == "adopt":
32
35
  sp.add_argument("feature"); sp.add_argument("--root", default="."); sp.add_argument("--out", default=None); sp.add_argument("--force", action="store_true")
33
36
  elif name == "verify-tests-ts":
@@ -75,7 +78,10 @@ def main(argv=None):
75
78
  raise SystemExit(1)
76
79
  elif a.cmd == "architect":
77
80
  o = Orchestrator(root, a.feature)
78
- print(json.dumps(o.architect(Path(a.ssat)), indent=2))
81
+ result = o.architect(Path(a.ssat), force=a.force, dry_run=a.dry_run)
82
+ print(json.dumps(result, indent=2))
83
+ if not result["scaffolded"] and not a.dry_run:
84
+ raise SystemExit(1)
79
85
  elif a.cmd == "fill":
80
86
  result = Orchestrator(root, a.feature).fill(Path(a.ssat))
81
87
  print(json.dumps(result, indent=2))
@@ -27,13 +27,33 @@ def verify_typescript_tests(root: Path, feature: str, manifest_path: Path | None
27
27
  shutil.copytree(root, tmp, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".git", "node_modules", "dist", "build", ".next", "coverage", ".forge"))
28
28
  units = []
29
29
  for mutant in mutants:
30
+ required = ("name", "path", "replace_regex", "command")
31
+ if (not isinstance(mutant, dict) or
32
+ not all(isinstance(mutant.get(key), str) and mutant[key] for key in required) or
33
+ not isinstance(mutant.get("replacement"), str)):
34
+ units.append(UnitResult("typescript:manifest", "verify_tests_ts", False, "each mutant requires non-empty name, path, replace_regex, and command strings plus a replacement string", FailureClass.HOLLOW_MANIFEST))
35
+ continue
30
36
  name = mutant["name"]
31
37
  target = tmp / mutant["path"]
32
38
  if not target.exists():
33
39
  units.append(UnitResult(f"typescript:{name}", "verify_tests_ts", False, f"mutant target missing: {mutant['path']}", FailureClass.HOLLOW_MANIFEST))
34
40
  continue
41
+ # A failing command before mutation is not proof that the mutant was killed.
42
+ try:
43
+ baseline = subprocess.run(mutant["command"], cwd=tmp, shell=True, capture_output=True, text=True,
44
+ timeout=int(mutant.get("timeout_s", 60)))
45
+ except subprocess.TimeoutExpired:
46
+ units.append(UnitResult(f"typescript:{name}", "verify_tests_ts", False, "baseline test command timed out", FailureClass.HOLLOW_MANIFEST))
47
+ continue
48
+ if baseline.returncode != 0:
49
+ units.append(UnitResult(f"typescript:{name}", "verify_tests_ts", False, f"baseline test command failed before mutation (exit {baseline.returncode})", FailureClass.HOLLOW_MANIFEST))
50
+ continue
35
51
  source = target.read_text(encoding="utf-8")
36
- changed, count = re.subn(mutant["replace_regex"], mutant["replacement"], source, count=1, flags=re.S)
52
+ try:
53
+ changed, count = re.subn(mutant["replace_regex"], mutant["replacement"], source, count=1, flags=re.S)
54
+ except re.error as error:
55
+ units.append(UnitResult(f"typescript:{name}", "verify_tests_ts", False, f"invalid reviewed mutation regex: {error}", FailureClass.HOLLOW_MANIFEST))
56
+ continue
37
57
  if count != 1:
38
58
  units.append(UnitResult(f"typescript:{name}", "verify_tests_ts", False, "reviewed mutation did not match exactly once", FailureClass.HOLLOW_MANIFEST))
39
59
  continue
@@ -7,7 +7,7 @@ import shutil, subprocess, sys
7
7
  from pathlib import Path
8
8
  from .states import State, can_transition, IllegalTransition, HUMAN_GATES
9
9
  from .run_store import RunStore
10
- from .ssat import load_ssat, scaffold_from_ssat, check_erosion
10
+ from .ssat import ScaffoldError, load_ssat, scaffold_from_ssat, check_erosion
11
11
  from .gates import judge_consistency, grumpy_review, skill_check
12
12
  from .gates.qa_audit import qa_audit
13
13
  from .skill_memory import record_lesson, inject_lessons_block, lessons_for
@@ -16,6 +16,20 @@ from .attribution import Attribution, FailureClass, UnitResult
16
16
 
17
17
  MAX_REFINE = 3
18
18
 
19
+ _NEXT_ACTION = {
20
+ State.INTENT: "forge expand <feature> --root .",
21
+ State.EXPANDED: "forge gate architected <feature> --root .",
22
+ State.ARCHITECTED: "forge architect <feature> <ssat.yaml> --root .",
23
+ State.SCAFFOLDED: "forge fill <feature> <ssat.yaml> --root .",
24
+ State.FILLED: "forge review <feature> <ssat.yaml> --root .",
25
+ State.REVIEWED: "forge arch-gate <feature> <ssat.yaml> --root .",
26
+ State.ARCH_GATED: "forge verify-tests <feature> <ssat.yaml> --root .",
27
+ State.TESTS_VERIFIED: "forge smoke <feature> --root .",
28
+ State.SMOKED: "forge ship <feature> --root .",
29
+ State.BLOCKED: "inspect the failed receipt, repair it, then rerun its gate",
30
+ State.SHIPPED: "no further action; feature is shipped",
31
+ }
32
+
19
33
 
20
34
  def _gate_attribution(stage: str, checks: list[tuple[str, bool, str, FailureClass]]) -> dict:
21
35
  units = [
@@ -67,12 +81,38 @@ class Orchestrator:
67
81
  self._advance(State.FILLED, "implemented bodies verified", attribution=attr)
68
82
  return {"filled": True, "attribution": attr}
69
83
 
70
- def architect(self, ssat_path: Path) -> dict:
71
- ssat = load_ssat(ssat_path)
72
- created = scaffold_from_ssat(ssat, self.root)
73
- self._advance(State.SCAFFOLDED, "scaffold from SSAT",
74
- modules=len(ssat.get("modules", [])), files=[str(c) for c in created])
75
- return {"scaffolded": [str(c) for c in created]}
84
+ def architect(self, ssat_path: Path, *, force: bool = False, dry_run: bool = False) -> dict:
85
+ """Safely materialize the SSAT scaffold only from an allowed state."""
86
+ current = self.store.state
87
+ if current not in {State.ARCHITECTED, State.BLOCKED}:
88
+ return {
89
+ "scaffolded": False,
90
+ "error": {
91
+ "code": "E_ILLEGAL_TRANSITION",
92
+ "current_state": current.value,
93
+ "requested_state": State.SCAFFOLDED.value,
94
+ "next": _NEXT_ACTION[current],
95
+ },
96
+ }
97
+ try:
98
+ report = scaffold_from_ssat(
99
+ load_ssat(ssat_path), self.root, force=force, dry_run=dry_run,
100
+ )
101
+ except ScaffoldError as error:
102
+ return {"scaffolded": False, "error": {"code": "E_SCAFFOLD", "message": str(error)},
103
+ "report": error.report.to_dict()}
104
+ result = {"scaffolded": not dry_run, "report": report.to_dict()}
105
+ if dry_run:
106
+ return result
107
+ self._advance(
108
+ State.SCAFFOLDED,
109
+ "scaffold from SSAT",
110
+ modules=len(load_ssat(ssat_path).get("modules", [])),
111
+ created=[item.to_dict() for item in report.created],
112
+ overwritten=[item.to_dict() for item in report.overwritten],
113
+ skipped=[item.to_dict() for item in report.skipped],
114
+ )
115
+ return result
76
116
 
77
117
  def review(self, ssat_path: Path) -> dict:
78
118
  """Judge + grumpy adversary + arch erosion + deep QA audit, with a
@@ -0,0 +1,345 @@
1
+ """Semantic Software Architecture Tree: executable, language-aware contracts."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import shutil
9
+ import tempfile
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+
17
+ @dataclass
18
+ class ArchViolation:
19
+ code: str
20
+ message: str
21
+ where: str = ""
22
+
23
+
24
+ class ScaffoldError(RuntimeError):
25
+ """A safe scaffold failure with a machine-readable report."""
26
+
27
+ def __init__(self, message: str, report: "ScaffoldReport"):
28
+ super().__init__(message)
29
+ self.report = report
30
+
31
+
32
+ @dataclass
33
+ class ScaffoldFile:
34
+ path: str
35
+ action: str
36
+ before_sha256: str | None = None
37
+ after_sha256: str | None = None
38
+ backup: str | None = None
39
+ detail: str | None = None
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "path": self.path,
44
+ "action": self.action,
45
+ "before_sha256": self.before_sha256,
46
+ "after_sha256": self.after_sha256,
47
+ "backup": self.backup,
48
+ "detail": self.detail,
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class ScaffoldReport:
54
+ dry_run: bool
55
+ created: list[ScaffoldFile] = field(default_factory=list)
56
+ skipped: list[ScaffoldFile] = field(default_factory=list)
57
+ conflicts: list[ScaffoldFile] = field(default_factory=list)
58
+ overwritten: list[ScaffoldFile] = field(default_factory=list)
59
+ rollback_performed: bool = False
60
+
61
+ @property
62
+ def created_paths(self) -> list[Path]:
63
+ return [Path(item.path) for item in self.created]
64
+
65
+ def __len__(self) -> int:
66
+ """Compatibility for callers that previously received ``list[Path]``."""
67
+ return len(self.created)
68
+
69
+ def __iter__(self):
70
+ return iter(self.created_paths)
71
+
72
+ def to_dict(self) -> dict:
73
+ return {
74
+ "dry_run": self.dry_run,
75
+ "created": [item.to_dict() for item in self.created],
76
+ "skipped": [item.to_dict() for item in self.skipped],
77
+ "conflicts": [item.to_dict() for item in self.conflicts],
78
+ "overwritten": [item.to_dict() for item in self.overwritten],
79
+ "rollback_performed": self.rollback_performed,
80
+ }
81
+
82
+
83
+ _LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "typescript"}
84
+
85
+
86
+ def load_ssat(path: Path) -> dict:
87
+ return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
88
+
89
+
90
+ def _sha256_bytes(value: bytes) -> str:
91
+ return hashlib.sha256(value).hexdigest()
92
+
93
+
94
+ def _sha256_file(path: Path) -> str:
95
+ return _sha256_bytes(path.read_bytes())
96
+
97
+
98
+ def _language_for(path: Path) -> str:
99
+ language = _LANGUAGE_BY_SUFFIX.get(path.suffix.lower())
100
+ if language is None:
101
+ raise ValueError(f"unsupported SSAT module extension: {path.suffix or '<none>'}")
102
+ return language
103
+
104
+
105
+ def _python_import(imp: str) -> str:
106
+ return f"import {imp}" if "." not in imp else f"from {imp.rsplit('.', 1)[0]} import {imp.rsplit('.', 1)[1]}"
107
+
108
+
109
+ def _typescript_import(imp: str) -> str:
110
+ if imp.startswith("import "):
111
+ return imp
112
+ alias = re.sub(r"[^A-Za-z0-9_]", "_", imp.rsplit("/", 1)[-1]) or "module_"
113
+ source = imp if imp.startswith((".", "/")) else f"./{imp}"
114
+ return f'import * as {alias} from "{source}";'
115
+
116
+
117
+ def _render_python(module: dict) -> str:
118
+ lines = ['"""AUTO-SCAFFOLD from SSAT. Fill bodies only; do not change signatures."""']
119
+ lines.extend(_python_import(imp) for imp in module.get("imports", []))
120
+ lines.append("")
121
+ for function in module.get("functions", []):
122
+ args = ", ".join(function.get("args", []))
123
+ returns = function.get("returns", "None")
124
+ lines.extend([
125
+ f"def {function['name']}({args}) -> {returns}:",
126
+ f' """{function.get("doc", "TODO")}"""',
127
+ " raise NotImplementedError # FILL",
128
+ "",
129
+ ])
130
+ return "\n".join(lines)
131
+
132
+
133
+ def _render_typescript(module: dict) -> str:
134
+ lines = ["/** AUTO-SCAFFOLD from SSAT. Fill bodies only; do not change signatures. */"]
135
+ lines.extend(_typescript_import(imp) for imp in module.get("imports", []))
136
+ lines.append("")
137
+ for function in module.get("functions", []):
138
+ args = ", ".join(function.get("args", []))
139
+ returns = function.get("returns", "void")
140
+ lines.extend([
141
+ f"/** {function.get('doc', 'TODO')} */",
142
+ f"export function {function['name']}({args}): {returns} {{",
143
+ ' throw new Error("NotImplementedError: FILL");',
144
+ "}",
145
+ "",
146
+ ])
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _render_module(module: dict, target: Path) -> str:
151
+ language = _language_for(target)
152
+ if language == "python":
153
+ return _render_python(module)
154
+ return _render_typescript(module)
155
+
156
+
157
+ def _validate_generated_source(source: str, target: Path) -> None:
158
+ language = _language_for(target)
159
+ if language == "python":
160
+ ast.parse(source)
161
+ return
162
+ if re.search(r"^\s*def\s+", source, flags=re.MULTILINE) or source.count("{") != source.count("}"):
163
+ raise ValueError(f"generated invalid TypeScript for {target}")
164
+ for line in source.splitlines():
165
+ if "export function " in line and not re.search(r"export function \w+\(.*\):\s*[^\s]+\s*\{", line):
166
+ raise ValueError(f"generated invalid TypeScript signature for {target}")
167
+
168
+
169
+ def scaffold_from_ssat(
170
+ ssat: dict,
171
+ out_dir: Path,
172
+ *,
173
+ force: bool = False,
174
+ dry_run: bool = False,
175
+ backup_root: Path | None = None,
176
+ ) -> ScaffoldReport:
177
+ """Plan, validate, and atomically materialize a language-aware SSAT scaffold.
178
+
179
+ Existing files are conflicts by default. ``force=True`` creates a timestamped
180
+ backup before an atomic replacement. No target is changed until every module
181
+ has rendered and validated; any write failure restores every earlier target.
182
+ """
183
+ out_dir = Path(out_dir).resolve()
184
+ report = ScaffoldReport(dry_run=dry_run)
185
+ plan: list[tuple[dict, Path, str, bytes | None]] = []
186
+ seen: set[Path] = set()
187
+
188
+ try:
189
+ for module in ssat.get("modules", []):
190
+ target = (out_dir / module["path"]).resolve()
191
+ if out_dir not in target.parents and target != out_dir:
192
+ raise ValueError(f"SSAT module path escapes root: {module['path']}")
193
+ if target in seen:
194
+ raise ValueError(f"SSAT declares duplicate module path: {module['path']}")
195
+ seen.add(target)
196
+ source = _render_module(module, target)
197
+ _validate_generated_source(source, target)
198
+ previous = target.read_bytes() if target.exists() else None
199
+ action = "overwrite" if previous is not None and force else "conflict" if previous is not None else "create"
200
+ item = ScaffoldFile(
201
+ path=str(target),
202
+ action=action,
203
+ before_sha256=_sha256_bytes(previous) if previous is not None else None,
204
+ after_sha256=_sha256_bytes(source.encode("utf-8")),
205
+ )
206
+ if action == "conflict":
207
+ report.conflicts.append(item)
208
+ elif action == "overwrite":
209
+ report.overwritten.append(item)
210
+ else:
211
+ report.created.append(item)
212
+ plan.append((module, target, source, previous))
213
+ except (KeyError, TypeError, ValueError) as exc:
214
+ raise ScaffoldError(str(exc), report) from exc
215
+
216
+ if report.conflicts:
217
+ raise ScaffoldError("existing SSAT targets require --force", report)
218
+ if dry_run:
219
+ return report
220
+
221
+ temp_paths: list[tuple[Path, Path]] = []
222
+ backups: dict[Path, Path] = {}
223
+ created_targets = [target for _, target, _, previous in plan if previous is None]
224
+ try:
225
+ # Validate every temp file before changing a single target.
226
+ for _, target, source, _ in plan:
227
+ target.parent.mkdir(parents=True, exist_ok=True)
228
+ fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".forge-tmp", dir=target.parent)
229
+ temp = Path(temp_name)
230
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
231
+ handle.write(source)
232
+ handle.flush()
233
+ os.fsync(handle.fileno())
234
+ temp_paths.append((temp, target))
235
+
236
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
237
+ backup_root = Path(backup_root) if backup_root else out_dir / ".forge" / "scaffold-backups" / timestamp
238
+ for _, target, _, previous in plan:
239
+ if previous is not None:
240
+ backup = backup_root / target.relative_to(out_dir)
241
+ backup.parent.mkdir(parents=True, exist_ok=True)
242
+ shutil.copy2(target, backup)
243
+ backups[target] = backup
244
+ next(item for item in report.overwritten if item.path == str(target)).backup = str(backup)
245
+
246
+ for temp, target in temp_paths:
247
+ os.replace(temp, target)
248
+ return report
249
+ except Exception as exc:
250
+ for target, backup in backups.items():
251
+ if backup.exists():
252
+ shutil.copy2(backup, target)
253
+ for target in created_targets:
254
+ if target.exists() and target not in backups:
255
+ target.unlink()
256
+ report.rollback_performed = bool(backups or created_targets)
257
+ raise ScaffoldError(f"scaffold transaction rolled back: {exc}", report) from exc
258
+ finally:
259
+ for temp, _ in temp_paths:
260
+ if temp.exists():
261
+ temp.unlink()
262
+
263
+
264
+ def _module_of(path: str, ssat: dict) -> str | None:
265
+ for module in ssat.get("modules", []):
266
+ if path.endswith(module["path"]):
267
+ return module["name"]
268
+ return None
269
+
270
+
271
+ def _typescript_erosion(module: dict, path: Path, names: dict, allowed: set[tuple[str, str]]) -> list[ArchViolation]:
272
+ """A TypeScript-specific structure check. Python's AST is never used here."""
273
+ source = path.read_text(encoding="utf-8")
274
+ violations: list[ArchViolation] = []
275
+ if re.search(r"^\s*def\s+", source, flags=re.MULTILINE) or source.count("{") != source.count("}"):
276
+ return [ArchViolation("E_TS_SYNTAX", "invalid TypeScript structure", module["path"])]
277
+ for function in module.get("functions", []):
278
+ pattern = re.compile(rf"(?:export\s+)?(?:async\s+)?function\s+{re.escape(function['name'])}\s*\(([^)]*)\)", re.MULTILINE)
279
+ match = pattern.search(source)
280
+ if match is None:
281
+ violations.append(ArchViolation("E_SIG_MISSING", f"{function['name']} declared but not implemented", module["path"]))
282
+ continue
283
+ got_args = [part.split(":", 1)[0].strip().rstrip("?") for part in match.group(1).split(",") if part.strip()]
284
+ want_args = [arg.split(":", 1)[0].strip().rstrip("?") for arg in function.get("args", [])]
285
+ if got_args != want_args:
286
+ violations.append(ArchViolation("E_SIG_DRIFT", f"{function['name']}({got_args}) != SSAT ({want_args})", module["path"]))
287
+ this = module["name"]
288
+ for imported in re.findall(r"(?:from\s+|import\s+)['\"]([^'\"]+)['\"]", source):
289
+ target = names.get(Path(imported).stem) or names.get(imported)
290
+ target_name = target["name"] if target else None
291
+ if target_name and target_name != this and (this, target_name) not in allowed:
292
+ violations.append(ArchViolation("E_ILLEGAL_DEP", f"{this} -> {target_name} not in SSAT dependency allowlist", module["path"]))
293
+ return violations
294
+
295
+
296
+ def check_erosion(ssat: dict, src_dir: Path) -> list[ArchViolation]:
297
+ """Detect signature drift and illegal dependency edges per source language."""
298
+ src_dir = Path(src_dir)
299
+ violations: list[ArchViolation] = []
300
+ allowed = {(edge["from"], edge["to"]) for edge in ssat.get("dependencies", [])}
301
+ names = {module["name"]: module for module in ssat.get("modules", [])}
302
+ for module in ssat.get("modules", []):
303
+ path = src_dir / module["path"]
304
+ if not path.exists():
305
+ violations.append(ArchViolation("E_MISSING_MODULE", f"{module['path']} declared in SSAT but absent"))
306
+ continue
307
+ try:
308
+ language = _language_for(path)
309
+ except ValueError as exc:
310
+ violations.append(ArchViolation("E_UNSUPPORTED_LANGUAGE", str(exc), module["path"]))
311
+ continue
312
+ if language == "typescript":
313
+ violations.extend(_typescript_erosion(module, path, names, allowed))
314
+ continue
315
+ try:
316
+ tree = ast.parse(path.read_text(encoding="utf-8"))
317
+ except SyntaxError as exc:
318
+ violations.append(ArchViolation("E_SYNTAX", str(exc), module["path"]))
319
+ continue
320
+ declared = {function["name"]: function for function in module.get("functions", [])}
321
+ found = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)}
322
+ for name, spec in declared.items():
323
+ if name not in found:
324
+ violations.append(ArchViolation("E_SIG_MISSING", f"{name} declared but not implemented", module["path"]))
325
+ continue
326
+ got_args = [arg.arg for arg in found[name].args.args]
327
+ want_args = [arg.split(":", 1)[0].strip() for arg in spec.get("args", [])]
328
+ if got_args != want_args:
329
+ violations.append(ArchViolation("E_SIG_DRIFT", f"{name}({got_args}) != SSAT ({want_args})", module["path"]))
330
+ this = module["name"]
331
+ for node in ast.walk(tree):
332
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
333
+ imports = [alias.name for alias in node.names] if isinstance(node, ast.Import) else [node.module or ""]
334
+ for imported in imports:
335
+ target = names.get(imported.split(".")[-1]) or names.get(imported)
336
+ target_name = target["name"] if target else None
337
+ if target_name and target_name != this and (this, target_name) not in allowed:
338
+ violations.append(ArchViolation("E_ILLEGAL_DEP", f"{this} -> {target_name} not in SSAT dependency allowlist", module["path"]))
339
+ for invariant in ssat.get("invariants", []):
340
+ pattern = invariant["forbid_pattern"]
341
+ for module in ssat.get("modules", []):
342
+ path = src_dir / module["path"]
343
+ if path.exists() and re.search(pattern, path.read_text(encoding="utf-8")):
344
+ violations.append(ArchViolation("E_INVARIANT", f"{invariant['name']}: forbidden pattern {pattern!r}", module["path"]))
345
+ return violations
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "code-factory-2-forge"
7
- version = "0.9.0"
7
+ version = "0.9.2"
8
8
  description = "ForgeLine — the autonomous outer loop for AI software factories. A CLI-backed state machine that drives intent -> spec -> plan -> code -> adversarial review -> ship, orchestrating SpecLine (spec governance) and Harness Software Factory (compiled decisions), with self-improving skills and architecture-as-a-CI-gate."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,11 +1,14 @@
1
1
  import json
2
2
  import hashlib
3
+ import shutil
4
+ import subprocess
5
+ import yaml
3
6
  from pathlib import Path
4
7
  import pytest
5
8
  from forgeline.states import State, can_transition, IllegalTransition
6
9
  from forgeline.run_store import RunStore
7
10
  from forgeline.orchestrator import Orchestrator
8
- from forgeline.ssat import load_ssat, scaffold_from_ssat, check_erosion
11
+ from forgeline.ssat import ScaffoldError, load_ssat, scaffold_from_ssat, check_erosion
9
12
  from conftest import fill_good, write_smoke_manifest
10
13
 
11
14
  def test_state_machine_rejects_illegal_transitions():
@@ -47,6 +50,126 @@ def test_scaffold_generates_signatures_from_ssat(proj):
47
50
  text = (proj/"slices"/"notifier"/"formatter.py").read_text()
48
51
  assert "def format_message(event: dict) -> str:" in text and "NotImplementedError" in text
49
52
 
53
+
54
+ def _typescript_ssat(paths: list[str]) -> dict:
55
+ return {
56
+ "name": "typescript-safety",
57
+ "modules": [
58
+ {
59
+ "name": Path(path).stem.replace("-", "_"),
60
+ "path": path,
61
+ "imports": [],
62
+ "functions": [{"name": "render", "args": ["name: string"], "returns": "string"}],
63
+ }
64
+ for path in paths
65
+ ],
66
+ "dependencies": [],
67
+ "invariants": [],
68
+ }
69
+
70
+
71
+ def test_existing_typescript_target_conflicts_without_force_and_preserves_hash(proj, capsys):
72
+ target = proj / "src" / "memory.ts"
73
+ target.parent.mkdir(parents=True)
74
+ target.write_text("export const preserved = true;\n", encoding="utf-8")
75
+ before = hashlib.sha256(target.read_bytes()).hexdigest()
76
+ orchestrator = Orchestrator(proj, "notifier")
77
+ orchestrator.store.set_state(State.ARCHITECTED)
78
+ spec = proj / "typescript.ssat.yaml"
79
+ spec.write_text(yaml.safe_dump(_typescript_ssat(["src/memory.ts"])), encoding="utf-8")
80
+
81
+ from forgeline.cli import main
82
+ with pytest.raises(SystemExit) as exited:
83
+ main(["architect", "notifier", str(spec), "--root", str(proj)])
84
+ assert exited.value.code == 1
85
+ result = json.loads(capsys.readouterr().out)
86
+
87
+ assert result["scaffolded"] is False
88
+ assert result["error"]["code"] == "E_SCAFFOLD"
89
+ assert result["report"]["conflicts"][0]["path"] == str(target)
90
+ assert hashlib.sha256(target.read_bytes()).hexdigest() == before
91
+ assert orchestrator.store.state == State.ARCHITECTED
92
+
93
+
94
+ def test_typescript_ssat_generates_typescript_and_compiles_when_tsc_is_available(proj):
95
+ report = scaffold_from_ssat(_typescript_ssat(["src/memory.ts"]), proj)
96
+ target = proj / "src" / "memory.ts"
97
+ source = target.read_text(encoding="utf-8")
98
+ assert len(report) == 1
99
+ assert "export function render(name: string): string" in source
100
+ assert "def render" not in source
101
+ tsc = shutil.which("tsc")
102
+ if tsc is None:
103
+ pytest.skip("tsc is installed by CI for the TypeScript scaffold regression")
104
+ completed = subprocess.run([tsc, "--noEmit", "--pretty", "false", str(target)], capture_output=True, text=True)
105
+ assert completed.returncode == 0, completed.stdout + completed.stderr
106
+
107
+
108
+ def test_mixed_language_ssat_generates_each_language_and_rejects_unknown_extensions(proj):
109
+ report = scaffold_from_ssat(_typescript_ssat(["src/memory.ts", "src/worker.py"]), proj)
110
+ assert len(report.created) == 2
111
+ assert "export function render" in (proj / "src" / "memory.ts").read_text(encoding="utf-8")
112
+ assert "def render" in (proj / "src" / "worker.py").read_text(encoding="utf-8")
113
+ with pytest.raises(ScaffoldError) as raised:
114
+ scaffold_from_ssat(_typescript_ssat(["src/not-supported.go"]), proj)
115
+ assert "unsupported SSAT module extension" in str(raised.value)
116
+ assert not (proj / "src" / "not-supported.go").exists()
117
+
118
+
119
+ def test_scaffold_second_write_failure_restores_prior_files_byte_for_byte(proj, monkeypatch):
120
+ spec = _typescript_ssat(["src/a.ts", "src/b.ts"])
121
+ first = proj / "src" / "a.ts"
122
+ second = proj / "src" / "b.ts"
123
+ first.parent.mkdir(parents=True)
124
+ first.write_text("export const originalA = true;\n", encoding="utf-8")
125
+ second.write_text("export const originalB = true;\n", encoding="utf-8")
126
+ original_first, original_second = first.read_bytes(), second.read_bytes()
127
+
128
+ from forgeline import ssat as ssat_module
129
+ real_replace = ssat_module.os.replace
130
+
131
+ def fail_second_temp(source, target):
132
+ if Path(target) == second and str(source).endswith(".forge-tmp"):
133
+ raise OSError("simulated second-file replace failure")
134
+ return real_replace(source, target)
135
+
136
+ monkeypatch.setattr(ssat_module.os, "replace", fail_second_temp)
137
+ with pytest.raises(ScaffoldError) as raised:
138
+ scaffold_from_ssat(spec, proj, force=True)
139
+
140
+ assert raised.value.report.rollback_performed is True
141
+ assert first.read_bytes() == original_first
142
+ assert second.read_bytes() == original_second
143
+
144
+
145
+ def test_scaffold_report_never_calls_preexisting_target_created(proj):
146
+ target = proj / "src" / "present.ts"
147
+ target.parent.mkdir(parents=True)
148
+ target.write_text("export const before = true;\n", encoding="utf-8")
149
+ report = scaffold_from_ssat(_typescript_ssat(["src/present.ts", "src/new.ts"]), proj, force=True)
150
+ assert [Path(item.path).name for item in report.created] == ["new.ts"]
151
+ assert [Path(item.path).name for item in report.overwritten] == ["present.ts"]
152
+
153
+
154
+ def test_architect_illegal_transition_returns_structured_error_without_traceback(proj, capsys):
155
+ orchestrator = Orchestrator(proj, "notifier")
156
+ orchestrator.store.set_state(State.EXPANDED)
157
+ from forgeline.cli import main
158
+ with pytest.raises(SystemExit) as exited:
159
+ main(["architect", "notifier", str(proj / "notifier.ssat.yaml"), "--root", str(proj)])
160
+ assert exited.value.code == 1
161
+ captured = capsys.readouterr()
162
+ assert "Traceback" not in captured.err
163
+ result = json.loads(captured.out)
164
+ assert result["scaffolded"] is False
165
+ assert result["error"] == {
166
+ "code": "E_ILLEGAL_TRANSITION",
167
+ "current_state": "expanded",
168
+ "requested_state": "scaffolded",
169
+ "next": "forge gate architected <feature> --root .",
170
+ }
171
+ assert not (proj / "slices" / "notifier" / "formatter.py").exists()
172
+
50
173
  def test_arch_erosion_detects_signature_drift(proj):
51
174
  ssat = load_ssat(proj/"notifier.ssat.yaml")
52
175
  scaffold_from_ssat(ssat, proj)
@@ -535,6 +658,33 @@ def test_typescript_mutant_verification_requires_existing_test_to_fail(tmp_path)
535
658
  assert result.attribution.n_passed == 1
536
659
 
537
660
 
661
+ def test_typescript_mutant_verification_refuses_a_broken_baseline(tmp_path):
662
+ from forgeline.gates.typescript_mutants import verify_typescript_tests
663
+
664
+ (tmp_path / "src").mkdir()
665
+ (tmp_path / ".forge" / "math").mkdir(parents=True)
666
+ (tmp_path / "src" / "sum.ts").write_text("export const sum = () => 2;")
667
+ manifest = {"mutants": [{
668
+ "name": "sum_stub", "path": "src/sum.ts", "replace_regex": "=> 2", "replacement": "=> 0",
669
+ "command": "python -c \"raise SystemExit(1)\"",
670
+ }]}
671
+ (tmp_path / ".forge" / "math" / "typescript-mutants.json").write_text(json.dumps(manifest))
672
+ result = verify_typescript_tests(tmp_path, "math")
673
+ assert result.passed is False
674
+ assert result.attribution.failures[0].failure_class.value == "hollow_manifest"
675
+ assert "before mutation" in result.attribution.failures[0].evidence
676
+
677
+
678
+ def test_typescript_mutant_verification_rejects_incomplete_manifest(tmp_path):
679
+ from forgeline.gates.typescript_mutants import verify_typescript_tests
680
+
681
+ (tmp_path / ".forge" / "math").mkdir(parents=True)
682
+ (tmp_path / ".forge" / "math" / "typescript-mutants.json").write_text(json.dumps({"mutants": [{"name": "missing-fields"}]}))
683
+ result = verify_typescript_tests(tmp_path, "math")
684
+ assert result.passed is False
685
+ assert result.attribution.failures[0].failure_class.value == "hollow_manifest"
686
+
687
+
538
688
  def test_verify_tests_temp_root_is_cleaned_up(proj):
539
689
  import tempfile
540
690
  o = _to_arch_gated(proj)
@@ -1,93 +0,0 @@
1
- """Semantic Software Architecture Tree — architecture as code, not a doc.
2
-
3
- An SSAT is a YAML contract: modules, their public signatures, allowed
4
- dependency edges, and invariants. It is BOTH the scaffold source AND the
5
- CI gate — the same artifact that generates the skeleton also detects
6
- 'structural erosion' when filled code violates the declared boundaries.
7
- """
8
- from __future__ import annotations
9
- import ast, re
10
- from dataclasses import dataclass, field
11
- from pathlib import Path
12
- import yaml
13
-
14
- @dataclass
15
- class ArchViolation:
16
- code: str
17
- message: str
18
- where: str = ""
19
-
20
- def load_ssat(path: Path) -> dict:
21
- return yaml.safe_load(Path(path).read_text())
22
-
23
- def scaffold_from_ssat(ssat: dict, out_dir: Path) -> list[Path]:
24
- """Generate signature-only files with valid imports from the SSAT."""
25
- out_dir = Path(out_dir); created = []
26
- for mod in ssat.get("modules", []):
27
- p = out_dir/mod["path"]
28
- p.parent.mkdir(parents=True, exist_ok=True)
29
- lines = ['"""AUTO-SCAFFOLD from SSAT. Fill bodies only; do not change signatures."""']
30
- for imp in mod.get("imports", []):
31
- lines.append(f"import {imp}" if "." not in imp else f"from {imp.rsplit('.',1)[0]} import {imp.rsplit('.',1)[1]}")
32
- lines.append("")
33
- for fn in mod.get("functions", []):
34
- args = ", ".join(fn.get("args", []))
35
- ret = fn.get("returns", "None")
36
- lines.append(f"def {fn['name']}({args}) -> {ret}:")
37
- lines.append(f' """{fn.get("doc","TODO")}"""')
38
- lines.append(" raise NotImplementedError # FILL")
39
- lines.append("")
40
- p.write_text("\n".join(lines)); created.append(p)
41
- return created
42
-
43
- def _module_of(path: str, ssat: dict) -> str | None:
44
- for mod in ssat.get("modules", []):
45
- if path.endswith(mod["path"]):
46
- return mod["name"]
47
- return None
48
-
49
- def check_erosion(ssat: dict, src_dir: Path) -> list[ArchViolation]:
50
- """Architecture-as-CI-gate: detect signature drift + illegal dependency edges."""
51
- src_dir = Path(src_dir); violations = []
52
- allowed = {(e["from"], e["to"]) for e in ssat.get("dependencies", [])}
53
- names = {m["name"]: m for m in ssat.get("modules", [])}
54
- path_to_name = {m["path"]: m["name"] for m in ssat.get("modules", [])}
55
- for mod in ssat.get("modules", []):
56
- p = src_dir/mod["path"]
57
- if not p.exists():
58
- violations.append(ArchViolation("E_MISSING_MODULE", f"{mod['path']} declared in SSAT but absent"))
59
- continue
60
- try:
61
- tree = ast.parse(p.read_text())
62
- except SyntaxError as e:
63
- violations.append(ArchViolation("E_SYNTAX", str(e), mod["path"])); continue
64
- # signature drift
65
- declared = {f["name"]: f for f in mod.get("functions", [])}
66
- found = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
67
- for fname, fspec in declared.items():
68
- if fname not in found:
69
- violations.append(ArchViolation("E_SIG_MISSING", f"{fname} declared but not implemented", mod["path"]))
70
- continue
71
- got_args = [a.arg for a in found[fname].args.args]
72
- want_args = [a.split(":")[0].strip() for a in fspec.get("args", [])]
73
- if got_args != want_args:
74
- violations.append(ArchViolation("E_SIG_DRIFT",
75
- f"{fname}({got_args}) != SSAT ({want_args})", mod["path"]))
76
- # illegal dependency edges
77
- this = mod["name"]
78
- for node in ast.walk(tree):
79
- if isinstance(node, (ast.Import, ast.ImportFrom)):
80
- mods = [a.name for a in node.names] if isinstance(node, ast.Import) else [node.module or ""]
81
- for m in mods:
82
- target = names.get(m.split(".")[-1]) or names.get(m)
83
- tname = target["name"] if target else None
84
- if tname and tname != this and (this, tname) not in allowed:
85
- violations.append(ArchViolation("E_ILLEGAL_DEP",
86
- f"{this} -> {tname} not in SSAT dependency allowlist", mod["path"]))
87
- for inv in ssat.get("invariants", []):
88
- pat = inv["forbid_pattern"]
89
- for mod in ssat.get("modules", []):
90
- p = src_dir/mod["path"]
91
- if p.exists() and re.search(pat, p.read_text()):
92
- violations.append(ArchViolation("E_INVARIANT", f"{inv['name']}: forbidden pattern {pat!r}", mod["path"]))
93
- return violations