trueline 0.1.0.dev0__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.
- trueline/__init__.py +1 -0
- trueline/__main__.py +9 -0
- trueline/adoption/__init__.py +72 -0
- trueline/adoption/assemble.py +270 -0
- trueline/adoption/derive.py +143 -0
- trueline/adoption/facts.py +211 -0
- trueline/adoption/fingerprint.py +36 -0
- trueline/adoption/parse.py +188 -0
- trueline/adoption/records.py +184 -0
- trueline/adoption/scan.py +103 -0
- trueline/adoption/universe.py +54 -0
- trueline/adr/__init__.py +24 -0
- trueline/adr/parse.py +48 -0
- trueline/adr/project.py +64 -0
- trueline/adr/validate.py +211 -0
- trueline/amendments.py +47 -0
- trueline/assets/ci/trueline-validate.yml +22 -0
- trueline/assets/hooks/post-merge +4 -0
- trueline/assets/hooks/pre-push +6 -0
- trueline/assets/instructions/adr-deriver.md +54 -0
- trueline/assets/instructions/architecture-review.md +48 -0
- trueline/assets/instructions/architecture-synthesis.md +34 -0
- trueline/assets/instructions/assembler.md +52 -0
- trueline/assets/instructions/capability-planner.md +44 -0
- trueline/assets/instructions/charter-review.md +26 -0
- trueline/assets/instructions/ci-semantic.md +28 -0
- trueline/assets/instructions/clarify.md +43 -0
- trueline/assets/instructions/constitution-check.md +52 -0
- trueline/assets/instructions/decompose-judge.md +54 -0
- trueline/assets/instructions/describer.md +83 -0
- trueline/assets/instructions/judge.md +39 -0
- trueline/assets/instructions/recon.md +60 -0
- trueline/assets/instructions/scanner.md +31 -0
- trueline/assets/instructions/skeptic.md +45 -0
- trueline/assets/instructions/synthesizer.md +27 -0
- trueline/assets/instructions/validator.md +67 -0
- trueline/assets/taxonomy.md +31 -0
- trueline/assets/templates/architecture-setup.md +50 -0
- trueline/assets/templates/build.md +33 -0
- trueline/assets/templates/charter.md +32 -0
- trueline/assets/templates/constitution.md +45 -0
- trueline/assets/templates/decompose.md +51 -0
- trueline/assets/templates/implementation-report.md +14 -0
- trueline/assets/templates/plan.md +83 -0
- trueline/assets/templates/propose.md +64 -0
- trueline/checks/__init__.py +19 -0
- trueline/checks/battery.py +1316 -0
- trueline/checks/context.py +40 -0
- trueline/clarifications.py +56 -0
- trueline/cli/__init__.py +93 -0
- trueline/cli/adopt_cmds.py +846 -0
- trueline/cli/adopt_gate.py +678 -0
- trueline/cli/adopt_run.py +143 -0
- trueline/cli/adr_cmd.py +55 -0
- trueline/cli/assets_util.py +8 -0
- trueline/cli/clarify_cmd.py +175 -0
- trueline/cli/conversation.py +65 -0
- trueline/cli/finalize_cmds.py +275 -0
- trueline/cli/governance_cmds.py +389 -0
- trueline/cli/init_cmd.py +607 -0
- trueline/cli/output.py +39 -0
- trueline/cli/phase_cmds.py +603 -0
- trueline/cli/project.py +104 -0
- trueline/cli/roles.py +513 -0
- trueline/cli/runtime_opts.py +32 -0
- trueline/cli/status_cmd.py +60 -0
- trueline/cli/template_cmd.py +29 -0
- trueline/cli/validate_cmd.py +209 -0
- trueline/conformance.py +111 -0
- trueline/fold/__init__.py +35 -0
- trueline/fold/apply.py +117 -0
- trueline/fold/parser.py +316 -0
- trueline/fold/validate.py +256 -0
- trueline/gates/__init__.py +26 -0
- trueline/gates/executor.py +40 -0
- trueline/gates/facts.py +151 -0
- trueline/gates/table.py +83 -0
- trueline/gitio.py +239 -0
- trueline/governance.py +196 -0
- trueline/invoke/__init__.py +56 -0
- trueline/invoke/drivers.py +212 -0
- trueline/invoke/interactive.py +333 -0
- trueline/invoke/policy.py +115 -0
- trueline/invoke/request.py +159 -0
- trueline/judge/__init__.py +52 -0
- trueline/judge/errors.py +13 -0
- trueline/judge/loop.py +201 -0
- trueline/judge/precondition.py +99 -0
- trueline/judge/workspace.py +143 -0
- trueline/judge/write.py +135 -0
- trueline/py.typed +0 -0
- trueline/schemas/__init__.py +62 -0
- trueline/schemas/adoption.py +289 -0
- trueline/schemas/adr.py +37 -0
- trueline/schemas/base.py +43 -0
- trueline/schemas/change.py +34 -0
- trueline/schemas/clarify.py +17 -0
- trueline/schemas/config.py +138 -0
- trueline/schemas/control.py +100 -0
- trueline/schemas/finding.py +10 -0
- trueline/state/__init__.py +63 -0
- trueline/state/derive.py +98 -0
- trueline/state/init_state.py +86 -0
- trueline/state/inventory.py +244 -0
- trueline-0.1.0.dev0.dist-info/METADATA +57 -0
- trueline-0.1.0.dev0.dist-info/RECORD +110 -0
- trueline-0.1.0.dev0.dist-info/WHEEL +4 -0
- trueline-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- trueline-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- trueline-0.1.0.dev0.dist-info/licenses/THIRD-PARTY-NOTICES.md +38 -0
trueline/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""trueline — Spec-Driven Development framework for Claude Code users."""
|
trueline/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Enable ``python -m trueline`` — the entry the adopt-run orchestrator spawns
|
|
2
|
+
(``[sys.executable, "-m", "trueline", …]``; see ``cli/adopt_run.py``'s ``_spawn``,
|
|
3
|
+
designed PATH-robust/venv-correct in ``M4-IMPL-PLAN.md``). It delegates to the same
|
|
4
|
+
``run()`` console-script entry as ``[project.scripts]`` so the ``-m`` invocation and the
|
|
5
|
+
installed ``trueline`` binary share one framework-error boundary and one exit-code contract."""
|
|
6
|
+
|
|
7
|
+
from trueline.cli import run
|
|
8
|
+
|
|
9
|
+
run()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Adoption (brownfield RE) home: records I/O, facts, parsers, derived state (M4 §2)."""
|
|
2
|
+
|
|
3
|
+
from trueline.adoption.facts import AdoptionFacts, build_adoption_facts
|
|
4
|
+
from trueline.adoption.records import (
|
|
5
|
+
ADOPTION_DIR,
|
|
6
|
+
AdoptionRecordError,
|
|
7
|
+
adoption_home,
|
|
8
|
+
approval_path,
|
|
9
|
+
assembly_dir,
|
|
10
|
+
audits_dir,
|
|
11
|
+
base_branch_path,
|
|
12
|
+
capability_plan_path,
|
|
13
|
+
constraints_dir,
|
|
14
|
+
describe_marker_path,
|
|
15
|
+
description_path,
|
|
16
|
+
dispositions_dir,
|
|
17
|
+
flip_path,
|
|
18
|
+
map_path,
|
|
19
|
+
module_dir,
|
|
20
|
+
provenance_path,
|
|
21
|
+
read_approval,
|
|
22
|
+
read_audits,
|
|
23
|
+
read_base_branch,
|
|
24
|
+
read_capability_plan,
|
|
25
|
+
read_constraints,
|
|
26
|
+
read_describe_marker,
|
|
27
|
+
read_dispositions,
|
|
28
|
+
read_flip,
|
|
29
|
+
read_map,
|
|
30
|
+
read_provenance,
|
|
31
|
+
read_suite_run,
|
|
32
|
+
read_validation,
|
|
33
|
+
suite_log_path,
|
|
34
|
+
suite_run_path,
|
|
35
|
+
validation_path,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ADOPTION_DIR",
|
|
40
|
+
"AdoptionFacts",
|
|
41
|
+
"AdoptionRecordError",
|
|
42
|
+
"adoption_home",
|
|
43
|
+
"approval_path",
|
|
44
|
+
"assembly_dir",
|
|
45
|
+
"audits_dir",
|
|
46
|
+
"base_branch_path",
|
|
47
|
+
"build_adoption_facts",
|
|
48
|
+
"capability_plan_path",
|
|
49
|
+
"constraints_dir",
|
|
50
|
+
"describe_marker_path",
|
|
51
|
+
"description_path",
|
|
52
|
+
"dispositions_dir",
|
|
53
|
+
"flip_path",
|
|
54
|
+
"map_path",
|
|
55
|
+
"module_dir",
|
|
56
|
+
"provenance_path",
|
|
57
|
+
"read_approval",
|
|
58
|
+
"read_audits",
|
|
59
|
+
"read_base_branch",
|
|
60
|
+
"read_capability_plan",
|
|
61
|
+
"read_constraints",
|
|
62
|
+
"read_describe_marker",
|
|
63
|
+
"read_dispositions",
|
|
64
|
+
"read_flip",
|
|
65
|
+
"read_map",
|
|
66
|
+
"read_provenance",
|
|
67
|
+
"read_suite_run",
|
|
68
|
+
"read_validation",
|
|
69
|
+
"suite_log_path",
|
|
70
|
+
"suite_run_path",
|
|
71
|
+
"validation_path",
|
|
72
|
+
]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Assembly substrate (M4 §2.5): the digest, the plan/provenance recomputations
|
|
2
|
+
and the invalidation rule — pure over preloaded facts (the command is the shell).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, ValidationError
|
|
8
|
+
|
|
9
|
+
from trueline.adoption.parse import DescriptionFacts, parse_description
|
|
10
|
+
from trueline.adoption.records import description_path
|
|
11
|
+
from trueline.fold import parse_delta
|
|
12
|
+
from trueline.schemas import StrictModel
|
|
13
|
+
from trueline.schemas.adoption import (
|
|
14
|
+
AdoptionMap,
|
|
15
|
+
CapabilityPlan,
|
|
16
|
+
PlanAssignment,
|
|
17
|
+
ProvenanceEntry,
|
|
18
|
+
ProvenanceMap,
|
|
19
|
+
ProvenanceSource,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DigestEntry(StrictModel):
|
|
24
|
+
part: str
|
|
25
|
+
module: str
|
|
26
|
+
requirement: str
|
|
27
|
+
summary: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlanParseError(RuntimeError):
|
|
31
|
+
"""The planner output failed the schema — the corrective payload carries the why."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_descriptions(home: Path, map_: AdoptionMap) -> dict[str, DescriptionFacts]:
|
|
35
|
+
"""part-qualified module id → parsed description facts (the I/O read, done once)."""
|
|
36
|
+
facts: dict[str, DescriptionFacts] = {}
|
|
37
|
+
for part_id, part_map in map_.parts.items():
|
|
38
|
+
for module_id in part_map.modules:
|
|
39
|
+
path = description_path(home, part_id, module_id)
|
|
40
|
+
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
41
|
+
facts[f"{part_id}/{module_id}"] = parse_description(text)
|
|
42
|
+
return facts
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def requirement_digest(descriptions: dict[str, DescriptionFacts]) -> list[DigestEntry]:
|
|
46
|
+
"""One entry per requirement — part, module, header, SUMMARY (bodies out BY
|
|
47
|
+
CONSTRUCTION: the digest is a mechanical extraction, never an instruction)."""
|
|
48
|
+
entries: list[DigestEntry] = []
|
|
49
|
+
for qualified in sorted(descriptions):
|
|
50
|
+
part, module = qualified.split("/", 1)
|
|
51
|
+
for requirement in descriptions[qualified].requirements:
|
|
52
|
+
entries.append(
|
|
53
|
+
DigestEntry(
|
|
54
|
+
part=part,
|
|
55
|
+
module=module,
|
|
56
|
+
requirement=requirement.name,
|
|
57
|
+
summary=requirement.summary or "",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return entries
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_digest(entries: list[DigestEntry]) -> str:
|
|
64
|
+
if not entries:
|
|
65
|
+
return "(no requirements — every described module is empty)"
|
|
66
|
+
return "\n".join(
|
|
67
|
+
f"- {e.part} / {e.module} / {e.requirement} — SUMMARY: {e.summary}" for e in entries
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PlannerOutput(StrictModel):
|
|
72
|
+
assignments: list[PlanAssignment] = Field(default_factory=lambda: list[PlanAssignment]())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def convert_planner_output(raw: object) -> CapabilityPlan:
|
|
76
|
+
try:
|
|
77
|
+
output = PlannerOutput.model_validate(raw)
|
|
78
|
+
except ValidationError as exc:
|
|
79
|
+
raise PlanParseError(f"the plan does not parse: {exc.errors()[0]['msg']}") from exc
|
|
80
|
+
return CapabilityPlan(assignments=output.assignments)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _key(part: str, module: str, requirement: str) -> tuple[str, str, str]:
|
|
84
|
+
return (part, module, requirement)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plan_completeness_defects(plan: CapabilityPlan, digest: list[DigestEntry]) -> list[str]:
|
|
88
|
+
"""Every digest entry assigned EXACTLY once; nothing outside the digest."""
|
|
89
|
+
defects: list[str] = []
|
|
90
|
+
digest_keys = {_key(e.part, e.module, e.requirement) for e in digest}
|
|
91
|
+
counts: dict[tuple[str, str, str], int] = {}
|
|
92
|
+
for assignment in plan.assignments:
|
|
93
|
+
key = _key(assignment.part, assignment.module, assignment.requirement)
|
|
94
|
+
counts[key] = counts.get(key, 0) + 1
|
|
95
|
+
if key not in digest_keys:
|
|
96
|
+
defects.append(
|
|
97
|
+
f"unknown entry: {assignment.part}/{assignment.module} "
|
|
98
|
+
f"{assignment.requirement!r} is not in the digest"
|
|
99
|
+
)
|
|
100
|
+
for key, count in sorted(counts.items()):
|
|
101
|
+
if count > 1 and key in digest_keys:
|
|
102
|
+
defects.append(f"duplicated entry: {key[0]}/{key[1]} {key[2]!r} assigned {count} times")
|
|
103
|
+
for key in sorted(digest_keys - set(counts)):
|
|
104
|
+
defects.append(f"unassigned entry: {key[0]}/{key[1]} {key[2]!r}")
|
|
105
|
+
return defects
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def stale_plan_entries(plan: CapabilityPlan, digest: list[DigestEntry]) -> list[str]:
|
|
109
|
+
"""Plan entries resolving to NO current requirement (§2.5: a stale entry is a
|
|
110
|
+
pointed refusal, never a silent drop or a phantom in the provenance)."""
|
|
111
|
+
digest_keys = {_key(e.part, e.module, e.requirement) for e in digest}
|
|
112
|
+
return [
|
|
113
|
+
f"{a.part}/{a.module} {a.requirement!r} → {a.capability}"
|
|
114
|
+
for a in plan.assignments
|
|
115
|
+
if _key(a.part, a.module, a.requirement) not in digest_keys
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AssemblerProvenanceSource(StrictModel):
|
|
120
|
+
part: str
|
|
121
|
+
module: str
|
|
122
|
+
requirement: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AssemblerProvenanceEntry(StrictModel):
|
|
126
|
+
requirement: str # the emitted (capability) header, verbatim
|
|
127
|
+
sources: list[AssemblerProvenanceSource]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AssemblerOutput(StrictModel):
|
|
131
|
+
provenance: list[AssemblerProvenanceEntry]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class AssemblerParseError(RuntimeError):
|
|
135
|
+
"""The assembler response failed the schema."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def convert_assembler_output(raw: object) -> list[AssemblerProvenanceEntry]:
|
|
139
|
+
try:
|
|
140
|
+
return AssemblerOutput.model_validate(raw).provenance
|
|
141
|
+
except ValidationError as exc:
|
|
142
|
+
raise AssemblerParseError(
|
|
143
|
+
f"the provenance does not parse: {exc.errors()[0]['msg']}"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def assembly_coverage_defects(
|
|
148
|
+
assigned: list[PlanAssignment],
|
|
149
|
+
entries: list[AssemblerProvenanceEntry],
|
|
150
|
+
delta_text: str,
|
|
151
|
+
) -> list[str]:
|
|
152
|
+
"""The per-capability postcondition: every assigned requirement accounted for in
|
|
153
|
+
the provenance AND every emitted header exists in the delta (all-ADDED); no
|
|
154
|
+
source outside the assigned set."""
|
|
155
|
+
defects: list[str] = []
|
|
156
|
+
assigned_keys = {_key(a.part, a.module, a.requirement) for a in assigned}
|
|
157
|
+
covered: set[tuple[str, str, str]] = set()
|
|
158
|
+
delta_names = {block.name for block in parse_delta(delta_text).added}
|
|
159
|
+
for entry in entries:
|
|
160
|
+
if entry.requirement not in delta_names:
|
|
161
|
+
defects.append(
|
|
162
|
+
f"provenance names {entry.requirement!r}, which is not an ADDED "
|
|
163
|
+
"requirement of the delta"
|
|
164
|
+
)
|
|
165
|
+
for source in entry.sources:
|
|
166
|
+
key = _key(source.part, source.module, source.requirement)
|
|
167
|
+
if key not in assigned_keys:
|
|
168
|
+
defects.append(
|
|
169
|
+
f"unassigned source: {source.part}/{source.module} {source.requirement!r}"
|
|
170
|
+
)
|
|
171
|
+
covered.add(key)
|
|
172
|
+
for key in sorted(assigned_keys - covered):
|
|
173
|
+
defects.append(f"assigned requirement unaccounted for: {key[0]}/{key[1]} {key[2]!r}")
|
|
174
|
+
for name in sorted(delta_names - {entry.requirement for entry in entries}):
|
|
175
|
+
defects.append(f"delta requirement {name!r} has no provenance entry")
|
|
176
|
+
return defects
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def join_evidence(
|
|
180
|
+
capability: str,
|
|
181
|
+
entries: list[AssemblerProvenanceEntry],
|
|
182
|
+
descriptions: dict[str, DescriptionFacts],
|
|
183
|
+
blobs: dict[str, str | None],
|
|
184
|
+
) -> list[ProvenanceEntry]:
|
|
185
|
+
"""The evidence lines are joined MECHANICALLY by the framework (from the parsed
|
|
186
|
+
descriptions) — the model reports keys only. Each source is stamped with its
|
|
187
|
+
module's committed description blob (`blobs`) so the next `assemble` can rebuild
|
|
188
|
+
on a same-header content correction (M4 §2.5)."""
|
|
189
|
+
joined: list[ProvenanceEntry] = []
|
|
190
|
+
for entry in entries:
|
|
191
|
+
sources: list[ProvenanceSource] = []
|
|
192
|
+
for source in entry.sources:
|
|
193
|
+
facts = descriptions.get(f"{source.part}/{source.module}")
|
|
194
|
+
evidence: list[str] = []
|
|
195
|
+
if facts is not None:
|
|
196
|
+
for requirement in facts.requirements:
|
|
197
|
+
if requirement.name == source.requirement:
|
|
198
|
+
evidence = [
|
|
199
|
+
*(f"EVIDENCE: {value}" for value in requirement.evidence),
|
|
200
|
+
*(f"EVIDENCE-TEST: {value}" for value in requirement.evidence_tests),
|
|
201
|
+
]
|
|
202
|
+
break
|
|
203
|
+
sources.append(
|
|
204
|
+
ProvenanceSource(
|
|
205
|
+
part=source.part,
|
|
206
|
+
module=source.module,
|
|
207
|
+
requirement=source.requirement,
|
|
208
|
+
evidence=evidence,
|
|
209
|
+
blob=blobs.get(f"{source.part}/{source.module}"),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
joined.append(
|
|
213
|
+
ProvenanceEntry(capability=capability, requirement=entry.requirement, sources=sources)
|
|
214
|
+
)
|
|
215
|
+
return joined
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def capabilities_to_build(
|
|
219
|
+
plan: CapabilityPlan,
|
|
220
|
+
previous: ProvenanceMap | None,
|
|
221
|
+
digest: list[DigestEntry],
|
|
222
|
+
specs_dir: Path,
|
|
223
|
+
current_blobs: dict[str, str | None],
|
|
224
|
+
) -> list[str]:
|
|
225
|
+
"""Per-capability resume + wholesale invalidation (§2.5): rebuild a capability
|
|
226
|
+
iff its baseline is missing, it was never assembled (no provenance entry), a
|
|
227
|
+
provenance source no longer resolves against the CURRENT descriptions (a header
|
|
228
|
+
removed/renamed), **the plan re-partitioned its assigned source set**, or **a
|
|
229
|
+
source module's committed description blob moved** (a same-header body/evidence
|
|
230
|
+
correction). The last two close the canonical reopen → re-describe → re-assemble
|
|
231
|
+
loop: name-resolution alone missed a corrected description that kept its headers
|
|
232
|
+
and a requirement re-assigned between two existing capabilities."""
|
|
233
|
+
digest_keys = {_key(e.part, e.module, e.requirement) for e in digest}
|
|
234
|
+
previous_by_capability: dict[str, list[ProvenanceEntry]] = {}
|
|
235
|
+
if previous is not None:
|
|
236
|
+
for entry in previous.entries:
|
|
237
|
+
previous_by_capability.setdefault(entry.capability, []).append(entry)
|
|
238
|
+
to_build: list[str] = []
|
|
239
|
+
for capability in sorted({a.capability for a in plan.assignments}):
|
|
240
|
+
if not (specs_dir / capability / "spec.md").is_file():
|
|
241
|
+
to_build.append(capability)
|
|
242
|
+
continue
|
|
243
|
+
entries = previous_by_capability.get(capability)
|
|
244
|
+
if not entries:
|
|
245
|
+
to_build.append(capability)
|
|
246
|
+
continue
|
|
247
|
+
sources = [source for entry in entries for source in entry.sources]
|
|
248
|
+
sources_resolve = all(
|
|
249
|
+
_key(source.part, source.module, source.requirement) in digest_keys
|
|
250
|
+
for source in sources
|
|
251
|
+
)
|
|
252
|
+
if not sources_resolve:
|
|
253
|
+
to_build.append(capability)
|
|
254
|
+
continue
|
|
255
|
+
# re-partition: the plan's current assigned set differs from what was built
|
|
256
|
+
current_assigned = {
|
|
257
|
+
_key(a.part, a.module, a.requirement)
|
|
258
|
+
for a in plan.assignments
|
|
259
|
+
if a.capability == capability
|
|
260
|
+
}
|
|
261
|
+
built_from = {_key(s.part, s.module, s.requirement) for s in sources}
|
|
262
|
+
if current_assigned != built_from:
|
|
263
|
+
to_build.append(capability)
|
|
264
|
+
continue
|
|
265
|
+
# content correction: a source module's committed description blob moved
|
|
266
|
+
if any(
|
|
267
|
+
source.blob != current_blobs.get(f"{source.part}/{source.module}") for source in sources
|
|
268
|
+
):
|
|
269
|
+
to_build.append(capability)
|
|
270
|
+
return to_build
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Derived adoption state (M4 §2.2/§3/§6.3) — pure over preloaded facts, with the
|
|
2
|
+
paired `*_at` I/O shells (the `derive_state`/`derive_state_at` idiom).
|
|
3
|
+
|
|
4
|
+
Nothing is stored: a describe marker COUNTS only while the map's current file set
|
|
5
|
+
matches the recorded one (surgery), no mapped file changed since the read-SHA
|
|
6
|
+
(staleness) and no constraint was recorded after it (reopen — at SHA equality the
|
|
7
|
+
constraint counts as recorded-after, erring conservative). A validation record
|
|
8
|
+
counts only while the description it validated is current. Late writers are inert
|
|
9
|
+
by comparison — the M1 orphan-approval idiom at module grain.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from trueline.adoption.records import (
|
|
15
|
+
read_describe_marker,
|
|
16
|
+
read_suite_run,
|
|
17
|
+
read_validation,
|
|
18
|
+
)
|
|
19
|
+
from trueline.adoption.scan import path_excluded
|
|
20
|
+
from trueline.gitio import diff_names, is_ancestor
|
|
21
|
+
from trueline.schemas import StrictModel
|
|
22
|
+
from trueline.schemas.adoption import (
|
|
23
|
+
Constraint,
|
|
24
|
+
DescribeMarker,
|
|
25
|
+
ModuleEntry,
|
|
26
|
+
PartMap,
|
|
27
|
+
SuiteRun,
|
|
28
|
+
ValidationRecord,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ModuleFacts(StrictModel):
|
|
33
|
+
"""One module's preloaded derivation facts (the shell computes the git reads)."""
|
|
34
|
+
|
|
35
|
+
mapped_files: list[str] # the CURRENT map's sorted file set (files + tests)
|
|
36
|
+
marker: DescribeMarker | None
|
|
37
|
+
validation: ValidationRecord | None
|
|
38
|
+
changed_since_read: list[str] # diff(read_sha..HEAD) names ([] without a marker)
|
|
39
|
+
constraint_recorded_after: bool # any module constraint not a PROPER ancestor of read-SHA
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def module_described(facts: ModuleFacts) -> bool:
|
|
43
|
+
"""Describe-green (§2.2/§6.3): marker present, file set matches the current map
|
|
44
|
+
(surgery), no mapped file changed since the read (staleness), no constraint
|
|
45
|
+
recorded after the read (reopen)."""
|
|
46
|
+
marker = facts.marker
|
|
47
|
+
if marker is None:
|
|
48
|
+
return False
|
|
49
|
+
if sorted(marker.files) != sorted(facts.mapped_files):
|
|
50
|
+
return False # map surgery invalidates by derivation — no file is touched
|
|
51
|
+
recorded = set(marker.files)
|
|
52
|
+
if any(changed in recorded for changed in facts.changed_since_read):
|
|
53
|
+
return False # stale: the corpus always describes HEAD
|
|
54
|
+
return not facts.constraint_recorded_after
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def module_validated(facts: ModuleFacts) -> bool:
|
|
58
|
+
"""Validated = described AND the validation record binds the CURRENT description
|
|
59
|
+
(same recorded describe-SHA)."""
|
|
60
|
+
if not module_described(facts):
|
|
61
|
+
return False
|
|
62
|
+
marker = facts.marker
|
|
63
|
+
validation = facts.validation
|
|
64
|
+
if marker is None or validation is None:
|
|
65
|
+
return False
|
|
66
|
+
return validation.describe_sha == marker.read_sha
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def suite_run_valid(
|
|
70
|
+
record: SuiteRun | None,
|
|
71
|
+
changed_since_run: list[str],
|
|
72
|
+
part_map: PartMap,
|
|
73
|
+
any_module_invalidated: bool,
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""The §3 round predicate: the record is valid iff the SHA-diff touches nothing
|
|
76
|
+
of the part's mapped file set (assigned AND excluded — conservative; framework
|
|
77
|
+
paths are outside by construction) AND no module of the part is currently
|
|
78
|
+
stale/reopened/surgery-invalidated."""
|
|
79
|
+
if record is None or any_module_invalidated:
|
|
80
|
+
return False
|
|
81
|
+
assigned = {file for entry in part_map.modules.values() for file in entry.all_files()}
|
|
82
|
+
for changed in changed_since_run:
|
|
83
|
+
if changed in assigned or path_excluded(changed, part_map.exclusions):
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---- the I/O shells ----------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def module_constraints(constraints: list[Constraint], part: str, module: str) -> list[Constraint]:
|
|
92
|
+
return [c for c in constraints if c.part == part and c.module == module]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_module_facts(
|
|
96
|
+
repo_root: Path,
|
|
97
|
+
home: Path,
|
|
98
|
+
part: str,
|
|
99
|
+
module: str,
|
|
100
|
+
entry: ModuleEntry,
|
|
101
|
+
constraints: list[Constraint],
|
|
102
|
+
) -> ModuleFacts:
|
|
103
|
+
marker = read_describe_marker(home, part, module)
|
|
104
|
+
changed = diff_names(repo_root, marker.read_sha) if marker is not None else []
|
|
105
|
+
recorded_after = False
|
|
106
|
+
if marker is not None:
|
|
107
|
+
for constraint in module_constraints(constraints, part, module):
|
|
108
|
+
proper_ancestor = constraint.recorded_sha != marker.read_sha and is_ancestor(
|
|
109
|
+
repo_root, constraint.recorded_sha, marker.read_sha
|
|
110
|
+
)
|
|
111
|
+
if not proper_ancestor:
|
|
112
|
+
recorded_after = True # SHA equality counts as recorded-after (conservative)
|
|
113
|
+
break
|
|
114
|
+
return ModuleFacts(
|
|
115
|
+
mapped_files=sorted(entry.all_files()),
|
|
116
|
+
marker=marker,
|
|
117
|
+
validation=read_validation(home, part, module),
|
|
118
|
+
changed_since_read=changed,
|
|
119
|
+
constraint_recorded_after=recorded_after,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def part_has_invalidated_module(
|
|
124
|
+
repo_root: Path, home: Path, part: str, part_map: PartMap, constraints: list[Constraint]
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""A module WITH a marker that is no longer describe-current (stale, reopened
|
|
127
|
+
or surgery-invalidated) — an un-described module never invalidates the suite."""
|
|
128
|
+
for module, entry in part_map.modules.items():
|
|
129
|
+
facts = build_module_facts(repo_root, home, part, module, entry, constraints)
|
|
130
|
+
if facts.marker is not None and not module_described(facts):
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def suite_run_valid_at(
|
|
136
|
+
repo_root: Path, home: Path, part: str, part_map: PartMap, constraints: list[Constraint]
|
|
137
|
+
) -> bool:
|
|
138
|
+
record = read_suite_run(home, part)
|
|
139
|
+
if record is None:
|
|
140
|
+
return False
|
|
141
|
+
changed = diff_names(repo_root, record.sha)
|
|
142
|
+
invalidated = part_has_invalidated_module(repo_root, home, part, part_map, constraints)
|
|
143
|
+
return suite_run_valid(record, changed, part_map, invalidated)
|