code-factory-2-forge 0.10.2__tar.gz → 0.10.4__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 (40) hide show
  1. {code_factory_2_forge-0.10.2/code_factory_2_forge.egg-info → code_factory_2_forge-0.10.4}/PKG-INFO +21 -4
  2. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/README.md +20 -3
  3. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4/code_factory_2_forge.egg-info}/PKG-INFO +21 -4
  4. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/__init__.py +1 -1
  5. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/qa_audit.py +15 -10
  6. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/orchestrator.py +50 -6
  7. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/provenance.py +41 -4
  8. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/run_store.py +14 -0
  9. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/source_scope.py +58 -11
  10. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/ssat.py +52 -9
  11. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/pyproject.toml +1 -1
  12. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/tests/test_forgeline.py +62 -1
  13. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/LICENSE-APACHE +0 -0
  14. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/LICENSE-MIT +0 -0
  15. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/NOTICE +0 -0
  16. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/code_factory_2_forge.egg-info/SOURCES.txt +0 -0
  17. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/code_factory_2_forge.egg-info/dependency_links.txt +0 -0
  18. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/code_factory_2_forge.egg-info/entry_points.txt +0 -0
  19. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/code_factory_2_forge.egg-info/requires.txt +0 -0
  20. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/code_factory_2_forge.egg-info/top_level.txt +0 -0
  21. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/adapters.py +0 -0
  22. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/adoption.py +0 -0
  23. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/attribution.py +0 -0
  24. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/cli.py +0 -0
  25. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/demo.py +0 -0
  26. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/demo_learning.py +0 -0
  27. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/__init__.py +0 -0
  28. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/adversary.py +0 -0
  29. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/judge.py +0 -0
  30. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/reverse_classical.py +0 -0
  31. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/runtime_smoke.py +0 -0
  32. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/skill_check.py +0 -0
  33. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/gates/typescript_mutants.py +0 -0
  34. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/intent_thread.py +0 -0
  35. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/learning.py +0 -0
  36. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/pr_optimizer.py +0 -0
  37. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/refinement.py +0 -0
  38. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/skill_memory.py +0 -0
  39. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/forgeline/states.py +0 -0
  40. {code_factory_2_forge-0.10.2 → code_factory_2_forge-0.10.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-factory-2-forge
3
- Version: 0.10.2
3
+ Version: 0.10.4
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
@@ -135,9 +135,24 @@ files and includes before/after SHA-256 values. Existing targets are reported as
135
135
  conflicts and leave both the files and feature state unchanged. An intentional
136
136
  replacement requires `--force`; ForgeLine first writes timestamped backups under
137
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.
138
+ modified target if any replacement fails. Python, JavaScript (including ESM
139
+ `.mjs`), TypeScript, and TSX scaffolds use extension-specific generators and
140
+ structure checks; unsupported extensions are rejected rather than receiving
141
+ Python syntax.
142
+
143
+ **Feature-scoped, language-aware QA.** `forge qa <feature> --ssat <contract>`
144
+ only grades files declared by that contract. Dependency, build, cache, VCS, and
145
+ reparse-point trees are pruned. Python is parsed with Python; JavaScript/ESM is
146
+ syntax-checked by Node and inventories exported and local functions; TypeScript
147
+ uses its matching parser. An unavailable parser is reported as
148
+ `PARSER_UNSUPPORTED`, never disguised as a syntax error. A slice without
149
+ discoverable functions is explicitly inventory-only, not behavioral-proof green.
150
+
151
+ **Fresh proof inputs.** `forge verify-tests` hashes its SSAT, declared source,
152
+ smoke manifests, tests, command, and tool version. Any mismatch invalidates the
153
+ prior reverse-classical receipt and reruns the proof. Inspect the active binary
154
+ with `forge version --json`; it reports version, origin, build hash, runtime,
155
+ and whether source identity is complete.
141
156
 
142
157
  **The grumpy adversary.** A review agent that *assumes your code is broken and
143
158
  insecure* and makes the generator prove otherwise. Executable heuristics catch
@@ -182,6 +197,8 @@ forge arch-gate <feature> <ssat> architecture CI gate
182
197
  forge verify-tests <feature> <ssat> prove smoke checks fail on generated stubs
183
198
  forge verify-tests-ts <feature> prove existing TypeScript tests fail on reviewed source mutants
184
199
  forge challenge <feature> <ssat> write a Factory Passport challenge receipt
200
+ forge qa <feature> --ssat <ssat> --strict run scoped language-aware QA
201
+ forge version --json print install and build provenance
185
202
  forge smoke <feature> runtime behavior gate
186
203
  forge ship <feature> seal it
187
204
  forge handoff <feature> <spec> route decision tables to HSF
@@ -117,9 +117,24 @@ files and includes before/after SHA-256 values. Existing targets are reported as
117
117
  conflicts and leave both the files and feature state unchanged. An intentional
118
118
  replacement requires `--force`; ForgeLine first writes timestamped backups under
119
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.
120
+ modified target if any replacement fails. Python, JavaScript (including ESM
121
+ `.mjs`), TypeScript, and TSX scaffolds use extension-specific generators and
122
+ structure checks; unsupported extensions are rejected rather than receiving
123
+ Python syntax.
124
+
125
+ **Feature-scoped, language-aware QA.** `forge qa <feature> --ssat <contract>`
126
+ only grades files declared by that contract. Dependency, build, cache, VCS, and
127
+ reparse-point trees are pruned. Python is parsed with Python; JavaScript/ESM is
128
+ syntax-checked by Node and inventories exported and local functions; TypeScript
129
+ uses its matching parser. An unavailable parser is reported as
130
+ `PARSER_UNSUPPORTED`, never disguised as a syntax error. A slice without
131
+ discoverable functions is explicitly inventory-only, not behavioral-proof green.
132
+
133
+ **Fresh proof inputs.** `forge verify-tests` hashes its SSAT, declared source,
134
+ smoke manifests, tests, command, and tool version. Any mismatch invalidates the
135
+ prior reverse-classical receipt and reruns the proof. Inspect the active binary
136
+ with `forge version --json`; it reports version, origin, build hash, runtime,
137
+ and whether source identity is complete.
123
138
 
124
139
  **The grumpy adversary.** A review agent that *assumes your code is broken and
125
140
  insecure* and makes the generator prove otherwise. Executable heuristics catch
@@ -164,6 +179,8 @@ forge arch-gate <feature> <ssat> architecture CI gate
164
179
  forge verify-tests <feature> <ssat> prove smoke checks fail on generated stubs
165
180
  forge verify-tests-ts <feature> prove existing TypeScript tests fail on reviewed source mutants
166
181
  forge challenge <feature> <ssat> write a Factory Passport challenge receipt
182
+ forge qa <feature> --ssat <ssat> --strict run scoped language-aware QA
183
+ forge version --json print install and build provenance
167
184
  forge smoke <feature> runtime behavior gate
168
185
  forge ship <feature> seal it
169
186
  forge handoff <feature> <spec> route decision tables to HSF
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-factory-2-forge
3
- Version: 0.10.2
3
+ Version: 0.10.4
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
@@ -135,9 +135,24 @@ files and includes before/after SHA-256 values. Existing targets are reported as
135
135
  conflicts and leave both the files and feature state unchanged. An intentional
136
136
  replacement requires `--force`; ForgeLine first writes timestamped backups under
137
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.
138
+ modified target if any replacement fails. Python, JavaScript (including ESM
139
+ `.mjs`), TypeScript, and TSX scaffolds use extension-specific generators and
140
+ structure checks; unsupported extensions are rejected rather than receiving
141
+ Python syntax.
142
+
143
+ **Feature-scoped, language-aware QA.** `forge qa <feature> --ssat <contract>`
144
+ only grades files declared by that contract. Dependency, build, cache, VCS, and
145
+ reparse-point trees are pruned. Python is parsed with Python; JavaScript/ESM is
146
+ syntax-checked by Node and inventories exported and local functions; TypeScript
147
+ uses its matching parser. An unavailable parser is reported as
148
+ `PARSER_UNSUPPORTED`, never disguised as a syntax error. A slice without
149
+ discoverable functions is explicitly inventory-only, not behavioral-proof green.
150
+
151
+ **Fresh proof inputs.** `forge verify-tests` hashes its SSAT, declared source,
152
+ smoke manifests, tests, command, and tool version. Any mismatch invalidates the
153
+ prior reverse-classical receipt and reruns the proof. Inspect the active binary
154
+ with `forge version --json`; it reports version, origin, build hash, runtime,
155
+ and whether source identity is complete.
141
156
 
142
157
  **The grumpy adversary.** A review agent that *assumes your code is broken and
143
158
  insecure* and makes the generator prove otherwise. Executable heuristics catch
@@ -182,6 +197,8 @@ forge arch-gate <feature> <ssat> architecture CI gate
182
197
  forge verify-tests <feature> <ssat> prove smoke checks fail on generated stubs
183
198
  forge verify-tests-ts <feature> prove existing TypeScript tests fail on reviewed source mutants
184
199
  forge challenge <feature> <ssat> write a Factory Passport challenge receipt
200
+ forge qa <feature> --ssat <ssat> --strict run scoped language-aware QA
201
+ forge version --json print install and build provenance
185
202
  forge smoke <feature> runtime behavior gate
186
203
  forge ship <feature> seal it
187
204
  forge handoff <feature> <spec> route decision tables to HSF
@@ -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.10.2"
14
+ __version__ = "0.10.4"
@@ -14,10 +14,10 @@ COMPLEXITY_LIMIT = 10
14
14
 
15
15
  @dataclass
16
16
  class QAReport:
17
- coverage_intent: float = 0.0
17
+ coverage_intent: float | None = 0.0
18
18
  max_complexity: int = 0
19
19
  security_score: int = 100
20
- doc_ratio: float = 0.0
20
+ doc_ratio: float | None = 0.0
21
21
  grade: str = "F"
22
22
  findings: list[str] = field(default_factory=list)
23
23
  metrics: dict = field(default_factory=dict)
@@ -52,9 +52,6 @@ class QAReport:
52
52
  units.append(UnitResult(f"qa_audit:{finding.split()[1]}", "qa_audit", False, finding, FailureClass.PARSER_UNSUPPORTED))
53
53
  elif finding.startswith("QA_SYNTAX"):
54
54
  units.append(UnitResult(f"qa_audit:{finding.split()[1]}", "qa_audit", False, finding, FailureClass.SYNTAX_ERROR))
55
- if not units:
56
- units.append(UnitResult("qa_audit:<no-public-functions>", "qa_audit", False,
57
- "no supported public functions were available to grade", FailureClass.PARSER_UNSUPPORTED))
58
55
  return Attribution("qa_audit", len(units), sum(unit.passed for unit in units), units)
59
56
 
60
57
 
@@ -130,20 +127,26 @@ def qa_audit(src_dir: Path, *, source_paths: Iterable[Path] | None = None) -> QA
130
127
  report.security_score -= deduction
131
128
  report.findings.append(f"QA_SEC[{severity}] {message} in {path.relative_to(root).as_posix()}")
132
129
 
133
- count = len(report.function_metrics) or 1
134
- report.coverage_intent = round(sum(metric["tested"] for metric in report.function_metrics) / count, 2)
135
- report.doc_ratio = round(sum(metric["documented"] for metric in report.function_metrics) / count, 2)
130
+ count = len(report.function_metrics)
131
+ report.coverage_intent = round(sum(metric["tested"] for metric in report.function_metrics) / count, 2) if count else None
132
+ report.doc_ratio = round(sum(metric["documented"] for metric in report.function_metrics) / count, 2) if count else None
136
133
  report.max_complexity = max((metric["complexity"] for metric in report.function_metrics), default=0)
137
134
  report.security_score = max(report.security_score, 0)
138
135
  for metric in report.function_metrics:
139
136
  if metric["complexity"] > COMPLEXITY_LIMIT:
140
137
  report.findings.append(f"QA_COMPLEXITY {metric['function']} complexity {metric['complexity']} > {COMPLEXITY_LIMIT}; policy=hard")
141
138
 
142
- score = 35 * report.coverage_intent
139
+ # A source slice with no declared functions has no coverage *claim*. It is
140
+ # syntax/security inventory only, rather than a synthetic zero-coverage D.
141
+ score = 35 * report.coverage_intent if report.coverage_intent is not None else 35
143
142
  score += 25 * (1 if report.max_complexity <= COMPLEXITY_LIMIT else max(0, 1 - (report.max_complexity - COMPLEXITY_LIMIT) / 10))
144
143
  score += 25 * (report.security_score / 100)
145
- score += 15 * report.doc_ratio
144
+ score += 15 * report.doc_ratio if report.doc_ratio is not None else 15
146
145
  report.grade = "A" if score >= 85 else "B" if score >= 70 else "C" if score >= 55 else "D" if score >= 40 else "F"
146
+ # No executable symbols means no behavioral proof, only a syntax/security
147
+ # inventory. Do not surface that inventory as an aggregate green result.
148
+ if not count and report.grade == "A":
149
+ report.grade = "B"
147
150
  if report.max_complexity > COMPLEXITY_LIMIT and report.grade in {"A", "B"}:
148
151
  report.grade = "C"
149
152
  if any(finding.startswith(("QA_SYNTAX", "QA_PARSER_UNSUPPORTED")) for finding in report.findings):
@@ -152,6 +155,8 @@ def qa_audit(src_dir: Path, *, source_paths: Iterable[Path] | None = None) -> QA
152
155
  "coverage_intent": report.coverage_intent, "max_complexity": report.max_complexity,
153
156
  "security_score": report.security_score, "doc_ratio": report.doc_ratio,
154
157
  "composite": round(score, 1), "complexity_policy": "hard",
158
+ "coverage_assessment": "measured" if count else "not_applicable_no_declared_functions",
159
+ "behavioral_proof_status": "available" if count else "unavailable_no_declared_functions",
155
160
  "scope": report.scope, "skipped_paths": report.skipped_paths,
156
161
  }
157
162
  return report
@@ -3,7 +3,7 @@ governance and HSF for decision compilation when a spec carries a decision
3
3
  table. Everything is a receipt; every gate failure records a skill lesson and
4
4
  routes to the refine loop."""
5
5
  from __future__ import annotations
6
- import shutil, subprocess, sys
6
+ import hashlib, json, 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
@@ -14,6 +14,7 @@ from .source_scope import declared_paths
14
14
  from .skill_memory import record_lesson, inject_lessons_block, lessons_for
15
15
  from .learning import LearningKernel
16
16
  from .attribution import Attribution, FailureClass, UnitResult
17
+ from . import __version__
17
18
 
18
19
  MAX_REFINE = 3
19
20
 
@@ -60,6 +61,29 @@ def _judge_failure_class(findings: list[str]) -> FailureClass:
60
61
  return failure_class
61
62
  return FailureClass.INCONSISTENT_LOGIC
62
63
 
64
+
65
+ def _verify_tests_fingerprint(root: Path, feature: str, ssat_path: Path) -> dict:
66
+ """Hash every verified input so stale reverse-classical receipts cannot pass."""
67
+ from .source_scope import iter_source_files
68
+ root = Path(root).resolve()
69
+ ssat_path = Path(ssat_path).resolve()
70
+ ssat = load_ssat(ssat_path)
71
+ components: dict[str, str] = {"ssat": hashlib.sha256(Path(ssat_path).read_bytes()).hexdigest()}
72
+ for path in declared_paths(root, ssat):
73
+ key = f"source:{path.relative_to(root).as_posix()}"
74
+ components[key] = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else "MISSING"
75
+ smoke_paths = [root / "smoke" / f"{feature}.json", root / "smoke" / "smoke.json"]
76
+ for path in smoke_paths:
77
+ if path.exists():
78
+ components[f"smoke:{path.relative_to(root).as_posix()}"] = hashlib.sha256(path.read_bytes()).hexdigest()
79
+ for path in iter_source_files(root):
80
+ if path.name.startswith("test_") or path.parent.name in {"tests", "test", "__tests__"} or ".test." in path.name or ".spec." in path.name:
81
+ components[f"test:{path.relative_to(root).as_posix()}"] = hashlib.sha256(path.read_bytes()).hexdigest()
82
+ components["command"] = "forge verify-tests"
83
+ components["tool_version"] = __version__
84
+ canonical = json.dumps(components, sort_keys=True, separators=(",", ":")).encode("utf-8")
85
+ return {"sha256": hashlib.sha256(canonical).hexdigest(), "components": components}
86
+
63
87
  class Orchestrator:
64
88
  def __init__(self, root: Path, feature: str):
65
89
  self.root = Path(root); self.feature = feature
@@ -274,17 +298,35 @@ class Orchestrator:
274
298
  def verify_tests(self, ssat_path: Path) -> dict:
275
299
  """Verify smoke checks can fail before trusting smoke results."""
276
300
  from .gates.reverse_classical import verify_tests
301
+ fingerprint = _verify_tests_fingerprint(self.root, self.feature, Path(ssat_path))
277
302
  if self.store.state == State.SHIPPED:
278
- return {"verified": True, "note": "already shipped"}
303
+ previous = self.store.latest_receipt("verify_tests")
304
+ if previous and previous.get("input_fingerprint") == fingerprint["sha256"]:
305
+ return {"verified": True, "note": "already shipped", "input_fingerprint": fingerprint["sha256"]}
306
+ return {
307
+ "verified": False,
308
+ "reason": "shipped artifact has stale verify-tests evidence; open a new feature run",
309
+ "input_fingerprint": fingerprint["sha256"],
310
+ "previous_fingerprint": previous.get("input_fingerprint") if previous else None,
311
+ }
279
312
  if self.store.state in {State.TESTS_VERIFIED, State.SMOKED}:
280
- return {"verified": True, "note": "already verified"}
313
+ previous = self.store.latest_receipt("verify_tests")
314
+ if previous and previous.get("input_fingerprint") == fingerprint["sha256"]:
315
+ return {"verified": True, "note": "already verified", "input_fingerprint": fingerprint["sha256"]}
316
+ # Source, tests, SSAT, command, or tool version changed. Invalidate
317
+ # downstream proof before rerunning rather than returning stale green.
318
+ self.store.set_state(State.ARCH_GATED, "verify-tests inputs changed; stale receipt invalidated")
319
+ self.store.receipt(phase="verify_tests_cache", verified=False,
320
+ reason="input fingerprint changed", input_fingerprint=fingerprint["sha256"],
321
+ previous_fingerprint=previous.get("input_fingerprint") if previous else None)
281
322
  if self.store.state not in {State.ARCH_GATED, State.BLOCKED}:
282
323
  return {"verified": False,
283
324
  "reason": "architecture gate not passed - run `forge arch-gate` first"}
284
325
  gate = verify_tests(self.root, self.feature, Path(ssat_path))
285
326
  attr = gate.attribution.to_dict()
286
327
  if not gate.passed:
287
- self.store.receipt(phase="verify_tests", verified=False, attribution=attr)
328
+ self.store.receipt(phase="verify_tests", verified=False, attribution=attr,
329
+ input_fingerprint=fingerprint["sha256"], input_components=fingerprint["components"])
288
330
  if self.store.state != State.BLOCKED:
289
331
  self._advance(State.BLOCKED, "reverse-classical test verification failed")
290
332
  return {"verified": False,
@@ -292,8 +334,10 @@ class Orchestrator:
292
334
  "attribution": attr}
293
335
  self._advance(State.TESTS_VERIFIED, "smoke checks proven non-hollow",
294
336
  attribution=attr)
295
- self.store.receipt(phase="verify_tests", verified=True, attribution=attr)
296
- return {"verified": True, "checks": gate.attribution.n_checked, "attribution": attr}
337
+ self.store.receipt(phase="verify_tests", verified=True, attribution=attr,
338
+ input_fingerprint=fingerprint["sha256"], input_components=fingerprint["components"])
339
+ return {"verified": True, "checks": gate.attribution.n_checked, "attribution": attr,
340
+ "input_fingerprint": fingerprint["sha256"]}
297
341
 
298
342
  def refine(self, evaluate, propose, apply, revert, max_iters: int = 6) -> dict:
299
343
  """Run deterministic localized refinement.
@@ -3,14 +3,45 @@ from __future__ import annotations
3
3
 
4
4
  import importlib.metadata
5
5
  import json
6
+ import hashlib
7
+ import os
8
+ import subprocess
6
9
  import sys
7
10
  from pathlib import Path
8
11
 
9
12
  from . import __version__
10
13
 
11
14
 
15
+ def _source_commit(module_dir: Path) -> str | None:
16
+ """Return a checked-out source revision when one is actually available."""
17
+ source_root = module_dir.parent
18
+ manifest = source_root / "pyproject.toml"
19
+ if not (source_root / ".git").exists() or not manifest.exists():
20
+ return None
21
+ if 'name = "code-factory-2-forge"' not in manifest.read_text(encoding="utf-8"):
22
+ return None
23
+ try:
24
+ result = subprocess.run(
25
+ ["git", "rev-parse", "HEAD"], cwd=source_root, capture_output=True,
26
+ text=True, timeout=3, check=False,
27
+ )
28
+ except OSError:
29
+ return None
30
+ return result.stdout.strip() if result.returncode == 0 else None
31
+
32
+
33
+ def _build_hash(module_dir: Path) -> str:
34
+ """Stable hash of the installed Python package payload, not a claimed commit."""
35
+ digest = hashlib.sha256()
36
+ for path in sorted(module_dir.rglob("*.py")):
37
+ digest.update(path.relative_to(module_dir).as_posix().encode("utf-8"))
38
+ digest.update(path.read_bytes())
39
+ return digest.hexdigest()
40
+
41
+
12
42
  def provenance() -> dict:
13
43
  """Return only facts available from the active installed distribution."""
44
+ module_dir = Path(__file__).resolve().parent
14
45
  install_origin = "unknown"
15
46
  direct_url: dict | None = None
16
47
  try:
@@ -19,17 +50,19 @@ def provenance() -> dict:
19
50
  if metadata_path.name.endswith(".egg-info"):
20
51
  install_origin = "source-tree"
21
52
  installed_module = Path(distribution.locate_file("forgeline")).resolve()
22
- if installed_module != Path(__file__).resolve().parent:
53
+ if installed_module != module_dir:
23
54
  return {
24
55
  "schema": "forgeline.provenance.v1",
25
56
  "package": "code-factory-2-forge",
26
57
  "version": __version__,
27
58
  "source_commit": None,
28
- "build_hash": None,
59
+ "build_hash": _build_hash(module_dir),
29
60
  "install_origin": "source-tree",
30
61
  "direct_url": None,
31
62
  "python": sys.version.split()[0],
63
+ "runtime": {"python": sys.version.split()[0], "implementation": sys.implementation.name},
32
64
  "receipt_schema": "forge.receipt.v1",
65
+ "identity_complete": False,
33
66
  }
34
67
  direct_url_text = distribution.read_text("direct_url.json")
35
68
  if direct_url_text:
@@ -39,14 +72,18 @@ def provenance() -> dict:
39
72
  install_origin = "site-packages"
40
73
  except importlib.metadata.PackageNotFoundError:
41
74
  pass
75
+ source_commit = _source_commit(module_dir)
76
+ build_hash = _build_hash(module_dir)
42
77
  return {
43
78
  "schema": "forgeline.provenance.v1",
44
79
  "package": "code-factory-2-forge",
45
80
  "version": __version__,
46
- "source_commit": None,
47
- "build_hash": None,
81
+ "source_commit": source_commit,
82
+ "build_hash": build_hash,
48
83
  "install_origin": install_origin,
49
84
  "direct_url": direct_url.get("url") if direct_url else None,
50
85
  "python": sys.version.split()[0],
86
+ "runtime": {"python": sys.version.split()[0], "implementation": sys.implementation.name},
51
87
  "receipt_schema": "forge.receipt.v1",
88
+ "identity_complete": bool(source_commit and build_hash),
52
89
  }
@@ -41,3 +41,17 @@ class RunStore:
41
41
  fields = {"h": hashlib.sha256(line.encode()).hexdigest()[:12], **fields}
42
42
  with self.receipts.open("a") as f:
43
43
  f.write(json.dumps(fields, sort_keys=True) + "\n")
44
+
45
+ def latest_receipt(self, phase: str) -> dict | None:
46
+ """Return the most recent receipt for one phase without trusting state."""
47
+ if not self.receipts.exists():
48
+ return None
49
+ latest = None
50
+ for line in self.receipts.read_text(encoding="utf-8").splitlines():
51
+ try:
52
+ item = json.loads(line)
53
+ except json.JSONDecodeError:
54
+ continue
55
+ if item.get("phase") == phase:
56
+ latest = item
57
+ return latest
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import ast
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import shutil
8
9
  import subprocess
9
10
  from pathlib import Path
@@ -167,6 +168,20 @@ def analyze_source(path: Path, root: Path) -> dict:
167
168
  node = shutil.which("node")
168
169
  if node is None:
169
170
  return {"status": "parser_unsupported", "language": language, "text": text, "reason": "node is unavailable"}
171
+ # Node's parser is authoritative for JavaScript/ESM and does not need the
172
+ # optional TypeScript package. That makes .mjs support deterministic in a
173
+ # normal Node installation instead of silently producing zero symbols.
174
+ if language == "javascript":
175
+ try:
176
+ checked = subprocess.run(
177
+ [node, "--check", str(path)], cwd=Path(root), capture_output=True,
178
+ text=True, timeout=10, check=False,
179
+ )
180
+ except (OSError, subprocess.TimeoutExpired) as error:
181
+ return {"status": "parser_unsupported", "language": language, "text": text, "reason": type(error).__name__}
182
+ if checked.returncode != 0:
183
+ return {"status": "syntax_error", "language": language, "text": text, "error": checked.stderr.strip()}
184
+ return {"status": "ok", "language": language, "text": text, "functions": _javascript_functions(text), "parser": "node-check"}
170
185
  try:
171
186
  completed = subprocess.run(
172
187
  [node, "-e", _NODE_TYPESCRIPT_AST, str(path), language], cwd=Path(root),
@@ -183,15 +198,47 @@ def analyze_source(path: Path, root: Path) -> dict:
183
198
  if payload["errors"]:
184
199
  return {"status": "syntax_error", "language": language, "text": text, "error": "; ".join(payload["errors"])}
185
200
  return {"status": "ok", "language": language, "text": text, "functions": payload["functions"]}
186
- if language == "javascript":
187
- try:
188
- checked = subprocess.run(
189
- [node, "--check", str(path)], cwd=Path(root), capture_output=True,
190
- text=True, timeout=10, check=False,
191
- )
192
- except (OSError, subprocess.TimeoutExpired) as error:
193
- return {"status": "parser_unsupported", "language": language, "text": text, "reason": type(error).__name__}
194
- if checked.returncode == 0:
195
- return {"status": "ok", "language": language, "text": text, "functions": [], "parser": "node-check"}
196
- return {"status": "syntax_error", "language": language, "text": text, "error": checked.stderr.strip()}
197
201
  return {"status": "parser_unsupported", "language": language, "text": text, "reason": completed.stderr.strip() or "typescript parser is unavailable"}
202
+
203
+
204
+ def _javascript_functions(source: str) -> list[dict]:
205
+ """Extract named ESM/local functions after Node has validated the source.
206
+
207
+ This is intentionally an inventory, not a second syntax parser. Node owns
208
+ syntax validity; the conservative scanner supplies stable symbols and
209
+ branch counts for feature-scoped QA without requiring an npm dependency.
210
+ """
211
+ patterns = (
212
+ re.compile(r"(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)[^{]*\{", re.MULTILINE),
213
+ re.compile(r"(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>", re.MULTILINE),
214
+ )
215
+ functions: list[dict] = []
216
+ seen: set[str] = set()
217
+ for pattern in patterns:
218
+ for match in pattern.finditer(source):
219
+ name = match.group(1)
220
+ if name in seen:
221
+ continue
222
+ seen.add(name)
223
+ start = match.start()
224
+ brace = source.find("{", match.end() - 1)
225
+ end = len(source)
226
+ if brace >= 0:
227
+ depth = 0
228
+ for index in range(brace, len(source)):
229
+ if source[index] == "{":
230
+ depth += 1
231
+ elif source[index] == "}":
232
+ depth -= 1
233
+ if depth == 0:
234
+ end = index + 1
235
+ break
236
+ body = source[start:end]
237
+ complexity = 1 + len(re.findall(r"\b(?:if|for|while|case|catch)\b|&&|\|\||\?", body))
238
+ prefix = source[max(0, start - 400):start]
239
+ functions.append({
240
+ "name": name,
241
+ "complexity": complexity,
242
+ "documented": bool(re.search(r"/\*\*[^*]*(?:\*(?!/)[^*]*)*\*/\s*$", prefix, re.DOTALL)),
243
+ })
244
+ return functions
@@ -80,7 +80,15 @@ class ScaffoldReport:
80
80
  }
81
81
 
82
82
 
83
- _LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "typescript"}
83
+ _LANGUAGE_BY_SUFFIX = {
84
+ ".py": "python",
85
+ ".js": "javascript",
86
+ ".jsx": "javascript",
87
+ ".mjs": "javascript",
88
+ ".cjs": "javascript",
89
+ ".ts": "typescript",
90
+ ".tsx": "typescript",
91
+ }
84
92
 
85
93
 
86
94
  def load_ssat(path: Path) -> dict:
@@ -147,11 +155,39 @@ def _render_typescript(module: dict) -> str:
147
155
  return "\n".join(lines)
148
156
 
149
157
 
158
+ def _javascript_args(args: list[str]) -> str:
159
+ """Remove TypeScript-only annotations while preserving ordinary JS syntax."""
160
+ cleaned: list[str] = []
161
+ for arg in args:
162
+ value = arg.split(":", 1)[0].strip().rstrip("?")
163
+ if not value:
164
+ raise ValueError("JavaScript SSAT arguments must include a parameter name")
165
+ cleaned.append(value)
166
+ return ", ".join(cleaned)
167
+
168
+
169
+ def _render_javascript(module: dict) -> str:
170
+ lines = ["/** AUTO-SCAFFOLD from SSAT. Fill bodies only; do not change signatures. */"]
171
+ lines.extend(_typescript_import(imp) for imp in module.get("imports", []))
172
+ lines.append("")
173
+ for function in module.get("functions", []):
174
+ lines.extend([
175
+ f"/** {function.get('doc', 'TODO')} */",
176
+ f"export function {function['name']}({_javascript_args(function.get('args', []))}) {{",
177
+ ' throw new Error("NotImplementedError: FILL");',
178
+ "}",
179
+ "",
180
+ ])
181
+ return "\n".join(lines)
182
+
183
+
150
184
  def _render_module(module: dict, target: Path) -> str:
151
185
  language = _language_for(target)
152
186
  if language == "python":
153
187
  return _render_python(module)
154
- return _render_typescript(module)
188
+ if language == "typescript":
189
+ return _render_typescript(module)
190
+ return _render_javascript(module)
155
191
 
156
192
 
157
193
  def _validate_generated_source(source: str, target: Path) -> None:
@@ -160,10 +196,17 @@ def _validate_generated_source(source: str, target: Path) -> None:
160
196
  ast.parse(source)
161
197
  return
162
198
  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}")
199
+ raise ValueError(f"generated invalid {language} for {target}")
164
200
  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}")
201
+ if "export function " not in line:
202
+ continue
203
+ signature = (
204
+ r"export function \w+\(.*\):\s*[^\s]+\s*\{"
205
+ if language == "typescript"
206
+ else r"export function \w+\(.*\)\s*\{"
207
+ )
208
+ if not re.search(signature, line):
209
+ raise ValueError(f"generated invalid {language} signature for {target}")
167
210
 
168
211
 
169
212
  def scaffold_from_ssat(
@@ -268,8 +311,8 @@ def _module_of(path: str, ssat: dict) -> str | None:
268
311
  return None
269
312
 
270
313
 
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."""
314
+ def _javascript_erosion(module: dict, path: Path, names: dict, allowed: set[tuple[str, str]]) -> list[ArchViolation]:
315
+ """A JavaScript/TypeScript structure check. Python's AST is never used here."""
273
316
  source = path.read_text(encoding="utf-8")
274
317
  violations: list[ArchViolation] = []
275
318
  if re.search(r"^\s*def\s+", source, flags=re.MULTILINE) or source.count("{") != source.count("}"):
@@ -401,8 +444,8 @@ def check_erosion(ssat: dict, src_dir: Path) -> list[ArchViolation]:
401
444
  except ValueError as exc:
402
445
  violations.append(ArchViolation("E_UNSUPPORTED_LANGUAGE", str(exc), module["path"]))
403
446
  continue
404
- if language == "typescript":
405
- violations.extend(_typescript_erosion(module, path, names, allowed))
447
+ if language in {"typescript", "javascript"}:
448
+ violations.extend(_javascript_erosion(module, path, names, allowed))
406
449
  continue
407
450
  try:
408
451
  tree = ast.parse(path.read_text(encoding="utf-8"))
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "code-factory-2-forge"
7
- version = "0.10.2"
7
+ version = "0.10.4"
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"
@@ -105,6 +105,19 @@ def test_typescript_ssat_generates_typescript_and_compiles_when_tsc_is_available
105
105
  assert completed.returncode == 0, completed.stdout + completed.stderr
106
106
 
107
107
 
108
+ def test_mjs_ssat_generates_valid_esm_without_python_syntax(proj):
109
+ report = scaffold_from_ssat(_typescript_ssat(["src/memory.mjs"]), proj)
110
+ target = proj / "src" / "memory.mjs"
111
+ source = target.read_text(encoding="utf-8")
112
+ assert len(report) == 1
113
+ assert "export function render(name)" in source
114
+ assert "def render" not in source
115
+ node = shutil.which("node")
116
+ if node is not None:
117
+ completed = subprocess.run([node, "--check", str(target)], capture_output=True, text=True)
118
+ assert completed.returncode == 0, completed.stdout + completed.stderr
119
+
120
+
108
121
  def test_mixed_language_ssat_generates_each_language_and_rejects_unknown_extensions(proj):
109
122
  report = scaffold_from_ssat(_typescript_ssat(["src/memory.ts", "src/worker.py"]), proj)
110
123
  assert len(report.created) == 2
@@ -317,6 +330,39 @@ def test_qa_feature_slice_ignores_unrelated_python_and_never_python_parses_mjs(p
317
330
  assert not any("bad.py" in finding for finding in report.findings)
318
331
 
319
332
 
333
+ def test_mjs_qa_extracts_esm_and_local_symbols_with_measured_coverage(proj):
334
+ from forgeline.gates.qa_audit import qa_audit
335
+
336
+ feature = proj / "services" / "memory.mjs"
337
+ feature.parent.mkdir()
338
+ feature.write_text(
339
+ "/** Recall a saved value. */\n"
340
+ "export function recall(id) { if (!id) return null; return id; }\n"
341
+ "const normalize = (value) => value.trim();\n",
342
+ encoding="utf-8",
343
+ )
344
+ tests = proj / "services" / "memory.test.mjs"
345
+ tests.write_text("import { recall } from './memory.mjs';\nrecall('x'); normalize?.(' x ');\n", encoding="utf-8")
346
+
347
+ report = qa_audit(proj, source_paths=[feature])
348
+ names = {metric["function"].split(":")[-1] for metric in report.function_metrics}
349
+ assert {"recall", "normalize"} <= names
350
+ assert report.coverage_intent is not None and report.coverage_intent > 0
351
+ assert report.metrics["coverage_assessment"] == "measured"
352
+ assert not any("PARSER_UNSUPPORTED" in finding or "QA_SYNTAX" in finding for finding in report.findings)
353
+
354
+
355
+ def test_mjs_invalid_syntax_is_not_misattributed_as_python_error(proj):
356
+ from forgeline.source_scope import analyze_source
357
+
358
+ feature = proj / "services" / "broken.mjs"
359
+ feature.parent.mkdir()
360
+ feature.write_text("export function broken( {\n", encoding="utf-8")
361
+ parsed = analyze_source(feature, proj)
362
+ assert parsed["language"] == "javascript"
363
+ assert parsed["status"] == "syntax_error"
364
+
365
+
320
366
  def test_qa_complexity_threshold_is_hard_and_cannot_pass_with_grade_b(proj):
321
367
  from forgeline.gates.qa_audit import qa_audit
322
368
 
@@ -390,7 +436,7 @@ def test_cli_requires_feature_scope_and_reports_machine_provenance(proj, capsys)
390
436
  main(["version", "--json"])
391
437
  provenance = json.loads(capsys.readouterr().out)
392
438
  assert provenance["package"] == "code-factory-2-forge"
393
- assert provenance["version"] == "0.10.2"
439
+ assert provenance["version"] == "0.10.4"
394
440
  assert {"source_commit", "build_hash", "install_origin", "python"} <= provenance.keys()
395
441
 
396
442
  def test_learning_kernel_promotes_recurring_lessons(proj):
@@ -590,6 +636,21 @@ def test_verify_tests_passes_real_behavioral_check(proj):
590
636
  assert r["attribution"]["rate"] == 1.0
591
637
 
592
638
 
639
+ def test_verify_tests_invalidates_receipt_when_source_changes(proj):
640
+ o = _to_arch_gated(proj)
641
+ write_smoke_manifest(proj, passing=True)
642
+ first = o.verify_tests(proj / "notifier.ssat.yaml")
643
+ assert first["verified"] is True
644
+ target = proj / "slices" / "notifier" / "formatter.py"
645
+ target.write_text(target.read_text(encoding="utf-8") + "\n# source changed\n", encoding="utf-8")
646
+
647
+ second = o.verify_tests(proj / "notifier.ssat.yaml")
648
+ assert second["verified"] is True
649
+ assert second["input_fingerprint"] != first["input_fingerprint"]
650
+ cache = o.store.latest_receipt("verify_tests_cache")
651
+ assert cache is not None and cache["reason"] == "input fingerprint changed"
652
+
653
+
593
654
  def test_verify_tests_catches_assert_true_hollow_check(proj):
594
655
  from forgeline.states import State
595
656
  o = _to_arch_gated(proj)