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.
- {code_factory_2_forge-0.9.0/code_factory_2_forge.egg-info → code_factory_2_forge-0.9.2}/PKG-INFO +21 -3
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/README.md +20 -2
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2/code_factory_2_forge.egg-info}/PKG-INFO +21 -3
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/__init__.py +1 -1
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/cli.py +7 -1
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/typescript_mutants.py +21 -1
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/orchestrator.py +47 -7
- code_factory_2_forge-0.9.2/forgeline/ssat.py +345 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/pyproject.toml +1 -1
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/tests/test_forgeline.py +151 -1
- code_factory_2_forge-0.9.0/forgeline/ssat.py +0 -93
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/LICENSE-APACHE +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/LICENSE-MIT +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/NOTICE +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/requires.txt +0 -0
- {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
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/adapters.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/adoption.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/attribution.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/demo.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/demo_learning.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/__init__.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/adversary.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/judge.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/qa_audit.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/reverse_classical.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/runtime_smoke.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/skill_check.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/intent_thread.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/learning.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/pr_optimizer.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/refinement.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/run_store.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/skill_memory.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/states.py +0 -0
- {code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/setup.cfg +0 -0
{code_factory_2_forge-0.9.0/code_factory_2_forge.egg-info → code_factory_2_forge-0.9.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-factory-2-forge
|
|
3
|
-
Version: 0.9.
|
|
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 #
|
|
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>
|
|
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 #
|
|
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>
|
|
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
|
{code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2/code_factory_2_forge.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-factory-2-forge
|
|
3
|
-
Version: 0.9.
|
|
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 #
|
|
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>
|
|
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
|
|
@@ -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
|
-
|
|
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))
|
{code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/typescript_mutants.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/code_factory_2_forge.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{code_factory_2_forge-0.9.0 → code_factory_2_forge-0.9.2}/forgeline/gates/reverse_classical.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|