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.
Files changed (110) hide show
  1. trueline/__init__.py +1 -0
  2. trueline/__main__.py +9 -0
  3. trueline/adoption/__init__.py +72 -0
  4. trueline/adoption/assemble.py +270 -0
  5. trueline/adoption/derive.py +143 -0
  6. trueline/adoption/facts.py +211 -0
  7. trueline/adoption/fingerprint.py +36 -0
  8. trueline/adoption/parse.py +188 -0
  9. trueline/adoption/records.py +184 -0
  10. trueline/adoption/scan.py +103 -0
  11. trueline/adoption/universe.py +54 -0
  12. trueline/adr/__init__.py +24 -0
  13. trueline/adr/parse.py +48 -0
  14. trueline/adr/project.py +64 -0
  15. trueline/adr/validate.py +211 -0
  16. trueline/amendments.py +47 -0
  17. trueline/assets/ci/trueline-validate.yml +22 -0
  18. trueline/assets/hooks/post-merge +4 -0
  19. trueline/assets/hooks/pre-push +6 -0
  20. trueline/assets/instructions/adr-deriver.md +54 -0
  21. trueline/assets/instructions/architecture-review.md +48 -0
  22. trueline/assets/instructions/architecture-synthesis.md +34 -0
  23. trueline/assets/instructions/assembler.md +52 -0
  24. trueline/assets/instructions/capability-planner.md +44 -0
  25. trueline/assets/instructions/charter-review.md +26 -0
  26. trueline/assets/instructions/ci-semantic.md +28 -0
  27. trueline/assets/instructions/clarify.md +43 -0
  28. trueline/assets/instructions/constitution-check.md +52 -0
  29. trueline/assets/instructions/decompose-judge.md +54 -0
  30. trueline/assets/instructions/describer.md +83 -0
  31. trueline/assets/instructions/judge.md +39 -0
  32. trueline/assets/instructions/recon.md +60 -0
  33. trueline/assets/instructions/scanner.md +31 -0
  34. trueline/assets/instructions/skeptic.md +45 -0
  35. trueline/assets/instructions/synthesizer.md +27 -0
  36. trueline/assets/instructions/validator.md +67 -0
  37. trueline/assets/taxonomy.md +31 -0
  38. trueline/assets/templates/architecture-setup.md +50 -0
  39. trueline/assets/templates/build.md +33 -0
  40. trueline/assets/templates/charter.md +32 -0
  41. trueline/assets/templates/constitution.md +45 -0
  42. trueline/assets/templates/decompose.md +51 -0
  43. trueline/assets/templates/implementation-report.md +14 -0
  44. trueline/assets/templates/plan.md +83 -0
  45. trueline/assets/templates/propose.md +64 -0
  46. trueline/checks/__init__.py +19 -0
  47. trueline/checks/battery.py +1316 -0
  48. trueline/checks/context.py +40 -0
  49. trueline/clarifications.py +56 -0
  50. trueline/cli/__init__.py +93 -0
  51. trueline/cli/adopt_cmds.py +846 -0
  52. trueline/cli/adopt_gate.py +678 -0
  53. trueline/cli/adopt_run.py +143 -0
  54. trueline/cli/adr_cmd.py +55 -0
  55. trueline/cli/assets_util.py +8 -0
  56. trueline/cli/clarify_cmd.py +175 -0
  57. trueline/cli/conversation.py +65 -0
  58. trueline/cli/finalize_cmds.py +275 -0
  59. trueline/cli/governance_cmds.py +389 -0
  60. trueline/cli/init_cmd.py +607 -0
  61. trueline/cli/output.py +39 -0
  62. trueline/cli/phase_cmds.py +603 -0
  63. trueline/cli/project.py +104 -0
  64. trueline/cli/roles.py +513 -0
  65. trueline/cli/runtime_opts.py +32 -0
  66. trueline/cli/status_cmd.py +60 -0
  67. trueline/cli/template_cmd.py +29 -0
  68. trueline/cli/validate_cmd.py +209 -0
  69. trueline/conformance.py +111 -0
  70. trueline/fold/__init__.py +35 -0
  71. trueline/fold/apply.py +117 -0
  72. trueline/fold/parser.py +316 -0
  73. trueline/fold/validate.py +256 -0
  74. trueline/gates/__init__.py +26 -0
  75. trueline/gates/executor.py +40 -0
  76. trueline/gates/facts.py +151 -0
  77. trueline/gates/table.py +83 -0
  78. trueline/gitio.py +239 -0
  79. trueline/governance.py +196 -0
  80. trueline/invoke/__init__.py +56 -0
  81. trueline/invoke/drivers.py +212 -0
  82. trueline/invoke/interactive.py +333 -0
  83. trueline/invoke/policy.py +115 -0
  84. trueline/invoke/request.py +159 -0
  85. trueline/judge/__init__.py +52 -0
  86. trueline/judge/errors.py +13 -0
  87. trueline/judge/loop.py +201 -0
  88. trueline/judge/precondition.py +99 -0
  89. trueline/judge/workspace.py +143 -0
  90. trueline/judge/write.py +135 -0
  91. trueline/py.typed +0 -0
  92. trueline/schemas/__init__.py +62 -0
  93. trueline/schemas/adoption.py +289 -0
  94. trueline/schemas/adr.py +37 -0
  95. trueline/schemas/base.py +43 -0
  96. trueline/schemas/change.py +34 -0
  97. trueline/schemas/clarify.py +17 -0
  98. trueline/schemas/config.py +138 -0
  99. trueline/schemas/control.py +100 -0
  100. trueline/schemas/finding.py +10 -0
  101. trueline/state/__init__.py +63 -0
  102. trueline/state/derive.py +98 -0
  103. trueline/state/init_state.py +86 -0
  104. trueline/state/inventory.py +244 -0
  105. trueline-0.1.0.dev0.dist-info/METADATA +57 -0
  106. trueline-0.1.0.dev0.dist-info/RECORD +110 -0
  107. trueline-0.1.0.dev0.dist-info/WHEEL +4 -0
  108. trueline-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  109. trueline-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  110. 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)