factoryline-code-factory 0.5.1__py3-none-any.whl

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.
@@ -0,0 +1,263 @@
1
+ """factoryline.assembly — line the Lego pieces up.
2
+
3
+ factoryline drives whichever modules are installed by shelling out to their CLIs.
4
+ It hard-depends on none of them. A missing module is simply a stud with nothing
5
+ plugged in — the chain reports it and continues with what's present. This is what
6
+ makes the factory portable: any IDE/agent/OS that can run a subprocess can drive it.
7
+ """
8
+ from __future__ import annotations
9
+ import shutil
10
+ import subprocess
11
+ import json
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .contract import MODULES, STAGES, ensure_layout, Receipt
16
+ from .meter import MeterLog, StageTiming, stopwatch
17
+ from .attribution import Attribution, FailureClass
18
+
19
+
20
+ @dataclass
21
+ class ModuleStatus:
22
+ name: str
23
+ cli: str
24
+ installed: bool
25
+ role: str
26
+
27
+
28
+ def detect() -> list[ModuleStatus]:
29
+ """Which Lego pieces are plugged in on this machine."""
30
+ out = []
31
+ for name, meta in MODULES.items():
32
+ out.append(ModuleStatus(
33
+ name=name, cli=meta["cli"],
34
+ installed=shutil.which(meta["cli"]) is not None,
35
+ role=meta["role"]))
36
+ return out
37
+
38
+
39
+ def _run_cli(cli: str, args: list[str], cwd: Path) -> tuple[bool, str]:
40
+ try:
41
+ proc = subprocess.run([cli, *args], cwd=str(cwd),
42
+ capture_output=True, text=True, timeout=300)
43
+ # Parse structured evidence before truncating anything for the receipt.
44
+ return proc.returncode == 0, proc.stdout + proc.stderr
45
+ except FileNotFoundError:
46
+ return False, f"{cli} not installed"
47
+ except subprocess.TimeoutExpired:
48
+ return False, f"{cli} timed out"
49
+
50
+
51
+ def _attribution_from_output(output: str) -> dict | None:
52
+ """Find a structured attribution block in a CLI's JSON output."""
53
+ decoder = __import__("json").JSONDecoder()
54
+ for offset, char in enumerate(output):
55
+ if char != "{":
56
+ continue
57
+ try:
58
+ payload, _ = decoder.raw_decode(output[offset:])
59
+ except ValueError:
60
+ continue
61
+ if isinstance(payload, dict):
62
+ raw = payload.get("attribution")
63
+ if isinstance(raw, dict):
64
+ Attribution.from_dict(raw)
65
+ return raw
66
+ return None
67
+
68
+
69
+ # The default pipeline: (module, cli-args-template). {f} = feature.
70
+ # Only stages whose module is installed run; UI stage runs only if smoke/<f>.ui exists.
71
+ DEFAULT_CHAIN = [
72
+ ("specline", ["strict", "{f}", "--json"]),
73
+ ("specline", ["verify-validators", "{f}", "--json"]),
74
+ ("specline", ["gate", "spec", "{f}"]),
75
+ ("specline", ["tasks", "{f}"]),
76
+ ("specline", ["gate", "plan", "{f}"]),
77
+ ("forgeline", ["architect", "{f}", "{f}.ssat.yaml"]),
78
+ ("forgeline", ["review", "{f}", "{f}.ssat.yaml"]),
79
+ ("forgeline", ["arch-gate", "{f}", "{f}.ssat.yaml"]),
80
+ ("forgeline", ["verify-tests", "{f}", "{f}.ssat.yaml"]),
81
+ ("forgeline", ["smoke", "{f}"]),
82
+ ("hsf", ["compile", "specs/{f}.yaml"]),
83
+ ("forgeline", ["ship", "{f}"]),
84
+ ]
85
+
86
+
87
+ def _stage_order(module: str, stage: str) -> tuple[int, str, str]:
88
+ """Return canonical pipeline order for a module stage.
89
+
90
+ Receipt rollups can arrive in display order, filesystem timestamp order, or
91
+ mixed legacy spellings. Failure diagnosis still follows pipeline order:
92
+ instrument verification precedes trusting runtime smoke output.
93
+ """
94
+ normalized = stage.replace("_", "-")
95
+ order = {
96
+ (mod, args[0].replace("_", "-")): index
97
+ for index, (mod, args) in enumerate(DEFAULT_CHAIN)
98
+ }
99
+ return (order.get((module, normalized), len(DEFAULT_CHAIN)), module, normalized)
100
+
101
+
102
+ def assemble(root: Path, feature: str, chain=None, dry_run: bool = False) -> dict:
103
+ """Run the assembly line for a feature. Returns a per-stage report.
104
+ Missing modules are skipped with a clear note (Lego stud left open)."""
105
+ root = Path(root); ensure_layout(root)
106
+ chain = chain or DEFAULT_CHAIN
107
+ installed = {m.name: m for m in detect()}
108
+ meterlog = MeterLog(root)
109
+ report = {"feature": feature, "root": str(root), "stages": [], "dry_run": dry_run}
110
+
111
+ spec_path = root / "specs" / f"{feature}.md"
112
+ if not dry_run and not spec_path.exists() and installed["specline"].installed:
113
+ with stopwatch() as sw:
114
+ ok, out = _run_cli(MODULES["specline"]["cli"], ["new", feature], root)
115
+ Receipt(module="specline", stage="new", feature=feature, ok=ok,
116
+ outputs={"log_tail": out[-2000:]}).write(root)
117
+ report["stages"].append({"module": "specline", "stage": "new",
118
+ "status": "ok" if ok else "failed", "wall_ms": sw.wall_ms})
119
+ if not ok:
120
+ report["halted_at"] = "specline:new"
121
+ else:
122
+ report["paused_at"] = "author_spec"
123
+ report["next_command"] = f"edit specs/{feature}.md and plans/{feature}.md, then rerun factory assemble {feature}"
124
+ report["rollup"] = rollup_attributions(report["stages"])
125
+ return report
126
+
127
+ for module, args_tmpl in chain:
128
+ cli = MODULES[module]["cli"]
129
+ present = installed[module].installed
130
+ args = [a.replace("{f}", feature) for a in args_tmpl]
131
+ stage_name = args[0]
132
+ if not present:
133
+ report["stages"].append({"module": module, "stage": stage_name,
134
+ "status": "skipped", "reason": f"{cli} not installed"})
135
+ continue
136
+ if not dry_run and module == "forgeline" and stage_name == "architect":
137
+ ssat = root / f"{feature}.ssat.yaml"
138
+ state_path = root / ".forge" / feature / "state.json"
139
+ state = None
140
+ if state_path.exists():
141
+ try:
142
+ state = json.loads(state_path.read_text(encoding="utf-8")).get("state")
143
+ except (OSError, ValueError):
144
+ state = None
145
+ if not ssat.exists():
146
+ report["paused_at"] = "architecture_contract"
147
+ report["next_command"] = f"write {feature}.ssat.yaml, then run forge expand {feature}"
148
+ break
149
+ if state in {None, "intent"}:
150
+ ok, out = _run_cli(cli, ["expand", feature], root)
151
+ Receipt(module=module, stage="expand", feature=feature, ok=ok,
152
+ outputs={"log_tail": out[-2000:]}).write(root)
153
+ report["stages"].append({"module": module, "stage": "expand", "status": "ok" if ok else "failed"})
154
+ report["paused_at"] = "architecture_approval"
155
+ report["next_command"] = f"forge gate architected {feature}"
156
+ break
157
+ if state == "expanded":
158
+ report["paused_at"] = "architecture_approval"
159
+ report["next_command"] = f"forge gate architected {feature}"
160
+ break
161
+ if not dry_run and module == "forgeline" and stage_name == "review":
162
+ state_path = root / ".forge" / feature / "state.json"
163
+ if state_path.exists():
164
+ state = json.loads(state_path.read_text(encoding="utf-8")).get("state")
165
+ if state == "scaffolded":
166
+ report["paused_at"] = "implementation_fill"
167
+ report["next_command"] = f"implement the scaffold, then run forge fill {feature} {feature}.ssat.yaml"
168
+ break
169
+ if not dry_run and module == "hsf" and stage_name == "compile" and not (root / f"specs/{feature}.yaml").exists():
170
+ report["stages"].append({"module": module, "stage": stage_name,
171
+ "status": "skipped", "reason": "no deterministic decision spec"})
172
+ continue
173
+ if dry_run:
174
+ report["stages"].append({"module": module, "stage": stage_name,
175
+ "status": "would-run", "cmd": f"{cli} {' '.join(args)}"})
176
+ continue
177
+ with stopwatch() as sw:
178
+ ok, out = _run_cli(cli, args, root)
179
+ attribution_block = _attribution_from_output(out)
180
+ meterlog.record(StageTiming(module, stage_name, sw.wall_ms, 0, 0, 0, ok))
181
+ Receipt(module=module, stage=stage_name, feature=feature, ok=ok,
182
+ outputs={"log_tail": out[-2000:]},
183
+ attribution=attribution_block).write(root)
184
+ report["stages"].append({"module": module, "stage": stage_name,
185
+ "status": "ok" if ok else "failed",
186
+ "wall_ms": sw.wall_ms,
187
+ "attribution": attribution_block})
188
+ if not ok:
189
+ report["halted_at"] = f"{module}:{stage_name}"
190
+ break
191
+ report["rollup"] = rollup_attributions(report["stages"])
192
+ return report
193
+
194
+
195
+ def rollup_receipts(root: Path, feature: str) -> dict:
196
+ """Load compatible factory receipts and roll up the latest stage records."""
197
+ receipt_dir = Path(root) / "receipts"
198
+ latest: dict[tuple[str, str], tuple[float, dict]] = {}
199
+ for path in receipt_dir.glob(f"*-{feature}-*.json"):
200
+ try:
201
+ payload = __import__("json").loads(path.read_text(encoding="utf-8"))
202
+ receipt = Receipt.from_dict(payload)
203
+ except (ValueError, TypeError, OSError):
204
+ continue
205
+ latest[(receipt.module, receipt.stage)] = (
206
+ path.stat().st_mtime,
207
+ {
208
+ "module": receipt.module,
209
+ "stage": receipt.stage,
210
+ "status": "ok" if receipt.ok else "failed",
211
+ "attribution": receipt.attribution,
212
+ },
213
+ )
214
+ stages = [item[1] for item in sorted(latest.values(), key=lambda item: item[0])]
215
+ return rollup_attributions(stages)
216
+
217
+
218
+ def rollup_attributions(stages: list[dict]) -> dict:
219
+ """Aggregate module attribution with canonical pipeline failure priority.
220
+
221
+ Older receipts without attribution remain visible but do not crash the line.
222
+ The recommendation is always the earliest failing stage, never the worst rate.
223
+ """
224
+ rows = []
225
+ for stage in stages:
226
+ raw = stage.get("attribution")
227
+ if not raw:
228
+ rows.append({
229
+ **stage,
230
+ "order": _stage_order(stage["module"], stage["stage"])[0],
231
+ "rate": None,
232
+ "dominant_failure_class": None,
233
+ })
234
+ continue
235
+ attr = Attribution.from_dict(raw)
236
+ dominant = attr.dominant_failure_class()
237
+ rows.append({
238
+ **stage,
239
+ "order": _stage_order(stage["module"], stage["stage"])[0],
240
+ "rate": attr.rate,
241
+ "n_checked": attr.n_checked,
242
+ "n_passed": attr.n_passed,
243
+ "dominant_failure_class": dominant.value if dominant else None,
244
+ })
245
+ failures = [
246
+ row for row in rows
247
+ if row.get("status") == "failed" or (row["rate"] is not None and row["rate"] < 1.0)
248
+ ]
249
+ first = min(
250
+ failures,
251
+ key=lambda row: _stage_order(row["module"], row["stage"]),
252
+ default=None,
253
+ )
254
+ return {
255
+ "stages": rows,
256
+ "earliest_failing_stage": (
257
+ f"{first['module']}:{first['stage']}" if first else None
258
+ ),
259
+ "recommended_edit_class": (
260
+ "structural" if first and first["dominant_failure_class"] else
261
+ "inspect_stage_output" if first else None
262
+ ),
263
+ }
@@ -0,0 +1,113 @@
1
+ """Deterministic, build-time failure attribution shared by factory modules."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass
5
+ from enum import Enum
6
+ from typing import Iterable
7
+
8
+
9
+ class FailureClass(str, Enum):
10
+ AMBIGUOUS_REQUIREMENT = "ambiguous_requirement"
11
+ UNTYPED_INPUT = "untyped_input"
12
+ SCOPE_ESCAPE = "scope_escape"
13
+ INVENTED_PARAM = "invented_param"
14
+ SIGNATURE_DRIFT = "signature_drift"
15
+ STUB_UNFILLED = "stub_unfilled"
16
+ COMPLEXITY_EXCEEDED = "complexity_exceeded"
17
+ INCONSISTENT_LOGIC = "inconsistent_logic"
18
+ RUNTIME_CRASH = "runtime_crash"
19
+ RUNTIME_TIMEOUT = "runtime_timeout"
20
+ WRONG_OUTPUT = "wrong_output"
21
+ HOLLOW_TEST = "hollow_test"
22
+ HOLLOW_MANIFEST = "hollow_manifest"
23
+ HOLLOW_VALIDATOR = "hollow_validator"
24
+ HOLLOW_COVERAGE = "hollow_coverage"
25
+ ACCURACY_REGRESSION = "accuracy_regression"
26
+ NONDETERMINISM = "nondeterminism"
27
+ SECURITY_FINDING = "security_finding"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class UnitResult:
32
+ unit: str
33
+ stage: str
34
+ passed: bool
35
+ evidence: str
36
+ failure_class: FailureClass | None = None
37
+
38
+ def __post_init__(self) -> None:
39
+ if not self.unit.strip() or not self.stage.strip():
40
+ raise ValueError("unit and stage are required")
41
+ if not self.passed and self.failure_class is None:
42
+ raise ValueError("failed units require a failure_class")
43
+ if not self.passed and not self.evidence.strip():
44
+ raise ValueError("failed units require concrete evidence")
45
+ if self.passed and self.failure_class is not None:
46
+ raise ValueError("passed units cannot carry a failure_class")
47
+
48
+
49
+ @dataclass
50
+ class Attribution:
51
+ stage: str
52
+ n_checked: int
53
+ n_passed: int
54
+ units: list[UnitResult]
55
+
56
+ def __post_init__(self) -> None:
57
+ if self.n_checked != len(self.units):
58
+ raise ValueError("n_checked must equal the number of units")
59
+ actual_passed = sum(unit.passed for unit in self.units)
60
+ if self.n_passed != actual_passed:
61
+ raise ValueError("n_passed must equal the number of passing units")
62
+
63
+ @property
64
+ def rate(self) -> float:
65
+ return self.n_passed / self.n_checked if self.n_checked else 0.0
66
+
67
+ @property
68
+ def failures(self) -> list[UnitResult]:
69
+ return [unit for unit in self.units if not unit.passed]
70
+
71
+ def dominant_failure_class(self) -> FailureClass | None:
72
+ counts = {failure_class: 0 for failure_class in FailureClass}
73
+ for unit in self.failures:
74
+ counts[unit.failure_class] += 1
75
+ maximum = max(counts.values(), default=0)
76
+ if maximum == 0:
77
+ return None
78
+ return next(kind for kind in FailureClass if counts[kind] == maximum)
79
+
80
+ def to_dict(self) -> dict:
81
+ payload = asdict(self)
82
+ payload["rate"] = self.rate
83
+ dominant = self.dominant_failure_class()
84
+ payload["dominant_failure_class"] = dominant.value if dominant else None
85
+ return payload
86
+
87
+ @classmethod
88
+ def from_dict(cls, payload: dict) -> "Attribution":
89
+ units = [
90
+ UnitResult(
91
+ unit=item["unit"],
92
+ stage=item.get("stage", payload["stage"]),
93
+ passed=item["passed"],
94
+ evidence=item.get("evidence", ""),
95
+ failure_class=(
96
+ FailureClass(item["failure_class"])
97
+ if item.get("failure_class")
98
+ else None
99
+ ),
100
+ )
101
+ for item in payload.get("units", [])
102
+ ]
103
+ return cls(
104
+ stage=payload["stage"],
105
+ n_checked=payload.get("n_checked", len(units)),
106
+ n_passed=payload.get("n_passed", sum(unit.passed for unit in units)),
107
+ units=units,
108
+ )
109
+
110
+
111
+ def attribution(stage: str, units: Iterable[UnitResult]) -> Attribution:
112
+ materialized = list(units)
113
+ return Attribution(stage, len(materialized), sum(unit.passed for unit in materialized), materialized)
@@ -0,0 +1,23 @@
1
+ """Guards that keep build-time learning state out of deterministic artifacts."""
2
+ from pathlib import Path
3
+
4
+ BUILD_TIME_ONLY = {"attribution", "refine", "rejection_ledger", "edit_selection"}
5
+
6
+
7
+ def assert_no_attribution_in_artifact(artifact_path: Path) -> None:
8
+ source = Path(artifact_path).read_text(encoding="utf-8")
9
+ leaked = sorted(symbol for symbol in BUILD_TIME_ONLY if symbol in source)
10
+ if leaked:
11
+ raise ValueError(f"build-time symbols leaked into artifact: {', '.join(leaked)}")
12
+
13
+
14
+ def assert_build_metadata_locations(root: Path) -> None:
15
+ registry = Path(root) / "registry"
16
+ if not registry.exists():
17
+ return
18
+ offenders = [
19
+ path for path in registry.rglob("*")
20
+ if path.is_file() and any(token in path.name for token in BUILD_TIME_ONLY)
21
+ ]
22
+ if offenders:
23
+ raise ValueError(f"build metadata found in registry: {offenders}")
@@ -0,0 +1,53 @@
1
+ """Counterfactual integrity challenge for Factoryline proof verification."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+ import json
7
+ import tempfile
8
+
9
+ from .proof import load_trace, verify_trace
10
+
11
+
12
+ def challenge_trace(trace_path: Path, root: Path | None = None) -> dict:
13
+ trace_path = Path(trace_path)
14
+ trace = load_trace(trace_path)
15
+ baseline = verify_trace(trace_path, root=root)
16
+ mutations = []
17
+ variants = []
18
+
19
+ empty = deepcopy(trace)
20
+ empty["nodes"] = []
21
+ variants.append(("empty_trace", empty))
22
+
23
+ bad_hash = deepcopy(trace)
24
+ if bad_hash.get("nodes"):
25
+ bad_hash["nodes"][0]["receipt_sha256"] = "0" * 64
26
+ variants.append(("receipt_hash_tamper", bad_hash))
27
+
28
+ if len(trace.get("nodes", [])) > 1:
29
+ reordered = deepcopy(trace)
30
+ reordered["nodes"] = list(reversed(reordered["nodes"]))
31
+ variants.append(("stage_reorder", reordered))
32
+
33
+ with tempfile.TemporaryDirectory(prefix="factory-challenge-") as temp:
34
+ for name, variant in variants:
35
+ path = Path(temp) / f"{name}.json"
36
+ path.write_text(json.dumps(variant, indent=2), encoding="utf-8")
37
+ result = verify_trace(path, root=root)
38
+ mutations.append({
39
+ "unit": name,
40
+ "killed": not result["valid"],
41
+ "evidence": "; ".join(result["errors"]) if result["errors"] else "mutant incorrectly verified",
42
+ })
43
+ killed = sum(bool(item["killed"]) for item in mutations)
44
+ return {
45
+ "schema": "factory.challenge.v1",
46
+ "brick": "factoryline",
47
+ "feature": trace.get("feature"),
48
+ "stage": "proof_integrity_counterfactual",
49
+ "passed": baseline["valid"] and killed == len(mutations),
50
+ "mutants_total": len(mutations),
51
+ "mutants_killed": killed,
52
+ "mutations": mutations,
53
+ }