surfaceplate 0.16.0__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 (96) hide show
  1. surfaceplate/MANIFEST.sha256 +230 -0
  2. surfaceplate/VERSION +1 -0
  3. surfaceplate/__init__.py +18 -0
  4. surfaceplate/about.py +48 -0
  5. surfaceplate/adapters/python.md +5 -0
  6. surfaceplate/adapters/r.md +3 -0
  7. surfaceplate/adapters/typescript.md +5 -0
  8. surfaceplate/adopt/__init__.py +15 -0
  9. surfaceplate/adopt/catalogue.py +78 -0
  10. surfaceplate/adopt/defaults.py +278 -0
  11. surfaceplate/adopt/detect.py +141 -0
  12. surfaceplate/adopt/discover.py +614 -0
  13. surfaceplate/adopt/example_answers.py +128 -0
  14. surfaceplate/adopt/explanations.py +551 -0
  15. surfaceplate/adopt/flow.py +559 -0
  16. surfaceplate/adopt/interview.py +237 -0
  17. surfaceplate/adopt/plan.py +1330 -0
  18. surfaceplate/adopt/provenance.py +300 -0
  19. surfaceplate/adopt/render.py +282 -0
  20. surfaceplate/adopt/scaffold.py +343 -0
  21. surfaceplate/adopt/sections.py +287 -0
  22. surfaceplate/adopt/tui/__init__.py +7 -0
  23. surfaceplate/adopt/tui/app.py +211 -0
  24. surfaceplate/adopt/tui/app.tcss +294 -0
  25. surfaceplate/adopt/tui/mark.py +77 -0
  26. surfaceplate/adopt/tui/screens.py +1772 -0
  27. surfaceplate/adopt/validators.py +224 -0
  28. surfaceplate/adopt/wizard.py +932 -0
  29. surfaceplate/check_conformance.py +3664 -0
  30. surfaceplate/cli.py +268 -0
  31. surfaceplate/core/AI_OPERATING_MODEL.md +45 -0
  32. surfaceplate/core/CONFORMANCE_LEVELS.md +299 -0
  33. surfaceplate/core/CONTROL_PRINCIPLES.md +14 -0
  34. surfaceplate/core/PREREQUISITE_GATES.md +322 -0
  35. surfaceplate/core/REVIEW_AND_EVIDENCE.md +55 -0
  36. surfaceplate/core/SECURITY_BASELINE.md +29 -0
  37. surfaceplate/doctor.py +249 -0
  38. surfaceplate/examples/application-profile.essential.example.yaml +126 -0
  39. surfaceplate/examples/application-profile.full.example.yaml +315 -0
  40. surfaceplate/examples/method-registry-entry.example.yaml +78 -0
  41. surfaceplate/examples/method-run-lineage.example.yaml +44 -0
  42. surfaceplate/examples/override-record.approved.example.yaml +39 -0
  43. surfaceplate/install_standard.py +880 -0
  44. surfaceplate/rules.py +148 -0
  45. surfaceplate/schemas/README.md +24 -0
  46. surfaceplate/schemas/application-profile.schema.yaml +320 -0
  47. surfaceplate/schemas/assurance-evidence.schema.yaml +33 -0
  48. surfaceplate/schemas/gate-exception.schema.yaml +47 -0
  49. surfaceplate/schemas/method-registry-entry.schema.yaml +132 -0
  50. surfaceplate/schemas/method-run-lineage.schema.yaml +72 -0
  51. surfaceplate/schemas/override-record.schema.yaml +76 -0
  52. surfaceplate/seeds/CHANGELOG.md +18 -0
  53. surfaceplate/seeds/activity-register.md +47 -0
  54. surfaceplate/seeds/adoption-decision-record.md +30 -0
  55. surfaceplate/seeds/data-source-register.md +18 -0
  56. surfaceplate/seeds/decision-log.md +28 -0
  57. surfaceplate/seeds/dependency-review-log.md +18 -0
  58. surfaceplate/seeds/findings-register.md +26 -0
  59. surfaceplate/seeds/method-registry-readme.md +7 -0
  60. surfaceplate/seeds/options-log.md +18 -0
  61. surfaceplate/seeds/output-validation-log.md +18 -0
  62. surfaceplate/seeds/overrides-readme.md +7 -0
  63. surfaceplate/seeds/release-checklist.md +19 -0
  64. surfaceplate/seeds/risk-classification.md +24 -0
  65. surfaceplate/seeds/run-lineage-readme.md +7 -0
  66. surfaceplate/seeds/source-of-truth-matrix.yaml +25 -0
  67. surfaceplate/seeds/test-conventions.md +20 -0
  68. surfaceplate/standard/.githooks/.gitattributes +1 -0
  69. surfaceplate/standard/.githooks/pre-commit +35 -0
  70. surfaceplate/standard/.github/skills/bug-fix/SKILL.md +57 -0
  71. surfaceplate/standard/.github/skills/change/SKILL.md +62 -0
  72. surfaceplate/standard/.github/skills/dependency-update/SKILL.md +61 -0
  73. surfaceplate/standard/.github/skills/fix-ci/SKILL.md +68 -0
  74. surfaceplate/standard/.github/skills/release/SKILL.md +71 -0
  75. surfaceplate/standard/.github/skills/review/SKILL.md +56 -0
  76. surfaceplate/standard/.github/skills/security-review/SKILL.md +61 -0
  77. surfaceplate/standard/.github/workflows/standards-conformance.yml +38 -0
  78. surfaceplate/standard/agent-instructions/activity.md +78 -0
  79. surfaceplate/standard/agent-instructions/ai-workflow.md +109 -0
  80. surfaceplate/standard/agent-instructions/authority.md +72 -0
  81. surfaceplate/standard/agent-instructions/provenance.md +93 -0
  82. surfaceplate/standard/agent-instructions/security.md +78 -0
  83. surfaceplate/standard/agent-instructions/tests.md +71 -0
  84. surfaceplate/standard/conformance-block.md +31 -0
  85. surfaceplate/templates/application-profile.yaml +113 -0
  86. surfaceplate/templates/decision-record.md +42 -0
  87. surfaceplate/templates/gate-exception.yaml +23 -0
  88. surfaceplate/templates/override-record.yaml +25 -0
  89. surfaceplate/templates/work-packet.md +33 -0
  90. surfaceplate-0.16.0.dist-info/METADATA +19 -0
  91. surfaceplate-0.16.0.dist-info/RECORD +96 -0
  92. surfaceplate-0.16.0.dist-info/WHEEL +4 -0
  93. surfaceplate-0.16.0.dist-info/entry_points.txt +2 -0
  94. surfaceplate-0.16.0.dist-info/licenses/LICENSE +201 -0
  95. surfaceplate-0.16.0.dist-info/licenses/LICENSE-DOCS +121 -0
  96. surfaceplate-0.16.0.dist-info/licenses/NOTICE +24 -0
@@ -0,0 +1,559 @@
1
+ """The run as stages, driven by the screens and by the scripted interview alike.
2
+
3
+ `DR-47` fixes the flow: decisions, level, the gate list, whatever the proposal could not fill,
4
+ the scaffold offer, and an annotated review that shows every line of the profile with its origin
5
+ and lets the human change any of them before anything is written. This module holds that
6
+ sequence as data and transitions, so the Textual app and `ScriptedInterview` drive the same
7
+ object and neither can invent a step the other lacks - the seam `tests/test_adopt_tui.py`'s join
8
+ closes for the screens, closed once more for the sequence.
9
+
10
+ What it owns: the state (raw answers by section, as `sections.build_profile` reads them), the
11
+ origin of every answer (`provenance.Origin` by answer key), the proposals, which stages are done,
12
+ and the bulk decisions. What it does not own: rendering, verification and writing, which stay in
13
+ `render.py` and `wizard.py`; the review is built by asking the wizard to verify, never by
14
+ verifying here.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as _dt
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Callable
23
+
24
+ import yaml
25
+
26
+ from surfaceplate.adopt import defaults, detect, discover, plan, provenance, render, scaffold, sections, validators
27
+ from surfaceplate.adopt.provenance import Origin
28
+
29
+ # The screens, in order. `remainder` and `scaffold` appear only when they have something to show.
30
+ STAGES = ("decisions", "level", "gates", "remainder", "scaffold", "review")
31
+
32
+ # Decisions-form fields whose state target is a boolean; the form asks yes/no so that nothing is
33
+ # pre-selected (`F64`), and the answer becomes the boolean the builders read.
34
+ _YES_NO = ("stack.builds_user_interface", "risk.relied_on_outside_team", "risk.material_quantitative_output")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ReviewLine:
39
+ """One annotated line of the rendered profile."""
40
+
41
+ line: int # 0-based line in `Review.rendered`
42
+ path: str # profile path, e.g. `adoption.review_by`
43
+ origin: str # the label shown
44
+ editable: bool
45
+ note: str = "" # why not editable, where it is not
46
+
47
+
48
+ @dataclass
49
+ class Review:
50
+ rendered: str
51
+ lines: list[ReviewLine]
52
+ error: str = ""
53
+ error_path: str | None = None
54
+
55
+ @property
56
+ def error_line(self) -> int | None:
57
+ for entry in self.lines:
58
+ if entry.path == self.error_path:
59
+ return entry.line
60
+ return None
61
+
62
+ def counts(self) -> dict[str, int]:
63
+ out: dict[str, int] = {}
64
+ for entry in self.lines:
65
+ kind = entry.origin.split(":")[0]
66
+ out[kind] = out.get(kind, 0) + 1
67
+ return out
68
+
69
+
70
+ class Flow:
71
+ """One run, as stages. Construct once per run; resume by passing the draft's state back in."""
72
+
73
+ def __init__(
74
+ self,
75
+ repo: Path,
76
+ record: dict,
77
+ *,
78
+ verify: Callable[[dict, str], None] | None = None,
79
+ state: dict | None = None,
80
+ origins: dict[str, Origin] | None = None,
81
+ done: tuple[str, ...] = (),
82
+ found: discover.Discovered | None = None,
83
+ today: _dt.date | None = None,
84
+ ) -> None:
85
+ self.repo = repo
86
+ self.record = record
87
+ self.verify = verify or (lambda _profile, _rendered: None)
88
+ self.found = found if found is not None else discover.scan(repo)
89
+ self.state: dict[str, dict] = {name: dict(values) for name, values in (state or {}).items()}
90
+ self.origins: dict[str, Origin] = dict(origins or {})
91
+ self.done: list[str] = list(done)
92
+ self.bulk: list[provenance.BulkDecision] = []
93
+ self.adoption_date = (today or _dt.date.today()).isoformat()
94
+ self.accepted_scaffold: list[scaffold.Offer] = []
95
+ self.proposals: dict[str, defaults.Proposal] = {}
96
+ # The register of explanation. `DR-47`'s flow has no screen for it; plain English is the
97
+ # register that assumes nothing, so it is the one used.
98
+ self.state.setdefault("mode", {"mode": "simple"})
99
+ for proposal in defaults.propose_identity(repo) + defaults.propose_stack(repo):
100
+ self.proposals[proposal.field] = proposal
101
+ if "level" in self.state:
102
+ self._propose_after_level()
103
+
104
+ # ----------------------------------------------------------------------------------------
105
+ # where we are
106
+ # ----------------------------------------------------------------------------------------
107
+
108
+ def next_stage(self) -> str:
109
+ for stage in STAGES:
110
+ if stage in self.done:
111
+ continue
112
+ if stage == "remainder" and not self.remainder_plan().fields:
113
+ self.done.append(stage)
114
+ continue
115
+ if stage == "scaffold" and not self.scaffold_offers():
116
+ self.done.append(stage)
117
+ continue
118
+ return stage
119
+ return "review"
120
+
121
+ def _set(self, key: str, value: object, origin: Origin) -> None:
122
+ section, _, field_id = key.partition(".")
123
+ self.state.setdefault(section, {})[field_id] = value
124
+ self.origins[key] = origin
125
+
126
+ def _answered(self, key: str) -> bool:
127
+ section, _, field_id = key.partition(".")
128
+ return field_id in (self.state.get(section) or {})
129
+
130
+ def _record_answer(self, key: str, value: object, spec: plan.FieldSpec | None = None) -> None:
131
+ """A submitted form value: the proposal's origin if it is the proposal, the field's own
132
+ default's origin if it is that, typed otherwise. `DR-47` (3): a proposed value submitted
133
+ unchanged is never recorded as typed - and a field's shown default (a worked example, a
134
+ sentence that follows from an earlier answer) is a proposal too."""
135
+ proposal = self.proposals.get(key)
136
+ if proposal is not None and value == proposal.value:
137
+ self._set(key, value, proposal.as_origin())
138
+ return
139
+ if spec is None:
140
+ spec = self.field_spec(key)
141
+ if spec is not None and spec.default and value == spec.default and spec.kind in ("text", "textarea"):
142
+ self._set(key, value, _default_origin(key, spec))
143
+ return
144
+ self._set(key, value, Origin(provenance.TYPED))
145
+
146
+ # ----------------------------------------------------------------------------------------
147
+ # stage 1: decisions
148
+ # ----------------------------------------------------------------------------------------
149
+
150
+ def decisions_plan(self) -> plan.SectionPlan:
151
+ return plan.decisions_plan(self.repo, found=self.found, proposals=self.proposals)
152
+
153
+ def answer_decisions(self, answers: dict) -> None:
154
+ for key, value in answers.items():
155
+ if key in _YES_NO and isinstance(value, str):
156
+ value = value == "yes"
157
+ self._record_answer(key, value)
158
+ # What the form could show pre-filled but did not ask: the display name, a detected
159
+ # language. Recorded as proposed, shown on the review.
160
+ for key, proposal in self.proposals.items():
161
+ if key.split(".")[0] in ("identity", "stack") and not self._answered(key):
162
+ self._set(key, proposal.value, proposal.as_origin())
163
+ for proposal in defaults.propose_risk(self.state.get("risk") or {}):
164
+ self.proposals[proposal.field] = proposal
165
+ self._set(proposal.field, proposal.value, proposal.as_origin())
166
+ self.done.append("decisions")
167
+
168
+ # ----------------------------------------------------------------------------------------
169
+ # stage 2: level
170
+ # ----------------------------------------------------------------------------------------
171
+
172
+ def level_plan(self) -> plan.SectionPlan:
173
+ return plan.section_plan("level", repo=self.repo, state=self.state, found=self.found)
174
+
175
+ def recommended_level(self) -> str:
176
+ return plan.recommended_level(self.state.get("risk") or {})[0]
177
+
178
+ def answer_level(self, answers: dict) -> None:
179
+ self._set("level.conformance_level", answers["conformance_level"], Origin(provenance.TYPED))
180
+ self._propose_after_level()
181
+ self.done.append("level")
182
+
183
+ def _propose_after_level(self) -> None:
184
+ for proposal in defaults.propose_after_level(
185
+ self.state, found=self.found, adoption_date=self.adoption_date
186
+ ):
187
+ self.proposals[proposal.field] = proposal
188
+ section = proposal.field.split(".")[0]
189
+ # Gate proposals seed the gate list and are recorded when it is answered; the rest
190
+ # are recorded now and shown on the remainder form or the review.
191
+ if section != "gates" and not self._answered(proposal.field):
192
+ self._set(proposal.field, proposal.value, proposal.as_origin())
193
+
194
+ # ----------------------------------------------------------------------------------------
195
+ # stage 3: the gate list
196
+ # ----------------------------------------------------------------------------------------
197
+
198
+ def _level(self) -> str:
199
+ return self.state["level"]["conformance_level"]
200
+
201
+ def _builds_ui(self) -> bool:
202
+ return bool(self.state["stack"]["builds_user_interface"])
203
+
204
+ def gate_specs(self) -> tuple[plan.GateSpec, ...]:
205
+ return plan.gate_plan(
206
+ level=self._level(), builds_ui=self._builds_ui(), mode=self.state["mode"]["mode"], found=self.found
207
+ )
208
+
209
+ def gates_plan(self) -> plan.SectionPlan:
210
+ return plan.gates_plan(
211
+ level=self._level(), builds_ui=self._builds_ui(), mode=self.state["mode"]["mode"], found=self.found
212
+ )
213
+
214
+ def gate_seeds(self) -> dict[str, object]:
215
+ """What the gate list shows pre-filled: every gate proposal, keyed `<gate>.<field>`."""
216
+ seeds = {
217
+ key[len("gates."):]: proposal.value
218
+ for key, proposal in self.proposals.items()
219
+ if key.startswith("gates.")
220
+ }
221
+ # A resumed run carries its own answers, which win over the proposals.
222
+ seeds.update(self.state.get("gates") or {})
223
+ return seeds
224
+
225
+ def answer_gates(self, answers: dict, *, bulk: tuple[str, set[str]] | None = None) -> None:
226
+ """`answers` keyed `<gate>.<field>`; `bulk` is `(status, gate ids)` for one explicit bulk
227
+ command, recorded as a single human act with its count (`DR-47` (4))."""
228
+ bulk_status, bulk_gates = bulk if bulk else ("", set())
229
+ self.state["gates"] = {}
230
+ gate_proposals = {
231
+ key[len("gates."):]: proposal for key, proposal in self.proposals.items() if key.startswith("gates.")
232
+ }
233
+ specs = {
234
+ f"{spec.id}.{f.id}": f for spec in self.gate_specs() for f in spec.fields
235
+ }
236
+ for key, value in answers.items():
237
+ gate_id, _, field_id = key.partition(".")
238
+ if field_id == "status":
239
+ detail = f"bulk: every undecided gate declared {bulk_status}" if gate_id in bulk_gates else ""
240
+ self._set(f"gates.{key}", value, Origin(provenance.TYPED, detail))
241
+ continue
242
+ proposal = gate_proposals.get(key)
243
+ spec = specs.get(key)
244
+ if proposal is not None and value == proposal.value:
245
+ self._set(f"gates.{key}", value, proposal.as_origin())
246
+ elif spec is not None and spec.default and value == spec.default and spec.kind in ("text", "textarea"):
247
+ self._set(f"gates.{key}", value, _default_origin(f"gates.{key}", spec))
248
+ else:
249
+ self._set(f"gates.{key}", value, Origin(provenance.TYPED))
250
+ if bulk_gates:
251
+ self.bulk.append(provenance.BulkDecision(status=bulk_status, count=len(bulk_gates)))
252
+ self.done.append("gates")
253
+
254
+ # ----------------------------------------------------------------------------------------
255
+ # stage 4: what the proposal could not fill
256
+ # ----------------------------------------------------------------------------------------
257
+
258
+ def remainder_plan(self) -> plan.SectionPlan:
259
+ """Every planned field in controls, adoption and wrap that has no answer yet, plus the
260
+ above-the-floor list. Presented as one form; empty when there is nothing to ask."""
261
+ fields: list[plan.FieldSpec] = []
262
+ for name in ("controls", "adoption", "wrap"):
263
+ section = plan.section_plan(name, repo=self.repo, state=self.state, found=self.found)
264
+ answered = self.state.get(name) or {}
265
+ for spec in section.fields:
266
+ key = f"{name}.{spec.id}"
267
+ present = spec.id in answered
268
+ if spec.id == "above_floor":
269
+ # Presented pre-filled with the proposal, because a level is a floor and not
270
+ # a ceiling: declaring more is a real choice the human must be able to make.
271
+ fields.append(_dotted(name, spec, default=list(answered.get("above_floor") or [])))
272
+ continue
273
+ if key == "adoption.decision_record_id" and not present:
274
+ # Asked where a decisions directory exists to name a record in; scaffolded
275
+ # otherwise (`scaffold.DECISION_RECORD`).
276
+ if detect.detect_decisions_folder(self.repo) is None:
277
+ continue
278
+ fields.append(_dotted(name, spec))
279
+ continue
280
+ if key == "adoption.needs_validator":
281
+ continue # proposed False; the review shows independent_validator as computed
282
+ if present:
283
+ continue
284
+ if spec.depends_on is not None:
285
+ other, wanted = spec.depends_on
286
+ if other != "above_floor" and answered.get(other) not in wanted:
287
+ continue
288
+ fields.append(_dotted(name, spec))
289
+ return plan.SectionPlan(
290
+ name="remainder",
291
+ title="A few things this repository did not answer",
292
+ intro=(
293
+ "Nothing in this repository answered these, so they are yours. Everything else "
294
+ "has been proposed and is shown, with where it came from, on the review next."
295
+ ),
296
+ fields=tuple(fields),
297
+ )
298
+
299
+ def answer_remainder(self, answers: dict) -> None:
300
+ for key, value in answers.items():
301
+ self._record_answer(key, value)
302
+ self.done.append("remainder")
303
+
304
+ # ----------------------------------------------------------------------------------------
305
+ # stage 5: scaffold
306
+ # ----------------------------------------------------------------------------------------
307
+
308
+ def scaffold_offers(self) -> list[scaffold.Offer]:
309
+ """Where a gate the profile will declare as required has no artefact, offer one; and the
310
+ adoption decision record where no decisions directory exists (`ACT-033`, `DR-47` (2))."""
311
+ gates = self.state.get("gates") or {}
312
+ needs: list[str] = []
313
+ for spec in self.gate_specs():
314
+ if spec.id not in scaffold.SEEDABLE:
315
+ continue
316
+ status = "required" if spec.mandatory else (spec.auto_status or gates.get(f"{spec.id}.status"))
317
+ if status != "required":
318
+ continue
319
+ answer = str(gates.get(f"{spec.id}.artefact") or "").strip()
320
+ seed_path = scaffold.SEEDABLE[spec.id][0]
321
+ if answer and answer != seed_path:
322
+ continue
323
+ if (self.repo / seed_path).exists():
324
+ continue
325
+ needs.append(spec.id)
326
+ offers = scaffold.offers(self.repo, needs)
327
+ # `DR-54` (3): a control whose reference is its seed's path, chosen on the remainder form.
328
+ controls = self.state.get("controls") or {}
329
+ wanted = [
330
+ control_id for control_id, (path, _s, _w) in scaffold.SEEDABLE_CONTROLS.items()
331
+ if str(controls.get(f"{control_id}.implementation_reference") or "").strip() == path
332
+ and not (self.repo / path).exists()
333
+ ]
334
+ offers += scaffold.offers_for_controls(self.repo, wanted)
335
+ # `F98`: a resumed draft carries the id as scaffolded from the attempt that was cancelled
336
+ # after this stage, and the file was never written; the offer stands until the file exists
337
+ # (`decision_record_offer` returns nothing once it does), as a gate's seed does above.
338
+ record_origin = self.origins.get("adoption.decision_record_id")
339
+ scaffolded_record = record_origin is not None and record_origin.kind == provenance.SCAFFOLDED
340
+ if "decisions" in self.done and (not self._answered("adoption.decision_record_id") or scaffolded_record):
341
+ if detect.detect_decisions_folder(self.repo) is None or scaffolded_record:
342
+ offer = scaffold.decision_record_offer(self.repo)
343
+ if offer is not None:
344
+ offers.append(offer)
345
+ return [o for o in offers if o.gate_id not in {a.gate_id for a in self.accepted_scaffold}]
346
+
347
+ def accept_scaffold(self, accepted: list[scaffold.Offer]) -> None:
348
+ moment = provenance.now_iso()
349
+ for offer in accepted:
350
+ if offer.control_id:
351
+ self._set(
352
+ f"controls.{offer.control_id}.implementation_reference",
353
+ offer.reference or offer.path,
354
+ Origin(provenance.SCAFFOLDED, f"created: {offer.path}"),
355
+ )
356
+ continue
357
+ if offer.gate_id == scaffold.DECISION_RECORD_GATE:
358
+ self._set(
359
+ "adoption.decision_record_id",
360
+ scaffold.DECISION_RECORD_ID,
361
+ Origin(provenance.SCAFFOLDED, f"created: {offer.path}"),
362
+ )
363
+ continue
364
+ self._set(f"gates.{offer.gate_id}.artefact", offer.path, Origin(provenance.SCAFFOLDED, f"created: {offer.path}"))
365
+ # `F47`: the gate binds from the instant the artefact was created, not from midnight.
366
+ self._set(f"gates.{offer.gate_id}.effective_from", moment, Origin(provenance.FACT, "the moment the artefact was created"))
367
+ self.accepted_scaffold.extend(accepted)
368
+ self.done.append("scaffold")
369
+
370
+ # ----------------------------------------------------------------------------------------
371
+ # stage 6: the review
372
+ # ----------------------------------------------------------------------------------------
373
+
374
+ def assemble(self) -> dict:
375
+ return sections.build_profile(
376
+ self.state,
377
+ framework_version=self.record.get("standard_version", ""),
378
+ framework_digest=self.record.get("framework_digest", ""),
379
+ )
380
+
381
+ def field_spec(self, key: str) -> plan.FieldSpec | None:
382
+ """The `FieldSpec` behind an answer key, so an edit knows its kind and choices."""
383
+ section, _, field_id = key.partition(".")
384
+ try:
385
+ section_plan = plan.section_plan(section, repo=self.repo, state=self.state, found=self.found)
386
+ except KeyError:
387
+ return None
388
+ return next((s for s in section_plan.fields if s.id == field_id), None)
389
+
390
+ def _first_problem(self) -> tuple[str, str] | None:
391
+ """`(answer key, message)` for the first answered field its validator refuses, or one the
392
+ profile needs and nothing answered. `F65`: the review names the line, and the line is
393
+ reachable."""
394
+ for name in plan.SECTION_ORDER:
395
+ answered = self.state.get(name) or {}
396
+ section = plan.section_plan(name, repo=self.repo, state=self.state, found=self.found)
397
+ for spec in section.fields:
398
+ if not spec.applies(answered):
399
+ continue
400
+ if spec.id not in answered:
401
+ if spec.validate:
402
+ return f"{name}.{spec.id}", validators.BLANK
403
+ continue
404
+ origin = self.origins.get(f"{name}.{spec.id}")
405
+ if origin is not None and origin.kind == provenance.SCAFFOLDED:
406
+ continue # the file is created when the profile is written, not before
407
+ problem = validators.check(spec.validate, answered.get(spec.id), repo=self.repo)
408
+ if problem:
409
+ return f"{name}.{spec.id}", problem
410
+ return None
411
+
412
+ def review(self) -> Review:
413
+ """The rendered profile, every line annotated, and the first thing stopping a write."""
414
+ problem = self._first_problem()
415
+ state_for_render = {n: dict(v) for n, v in self.state.items()}
416
+ placeholders: dict[str, Origin] = {}
417
+ # Render with every blank in place, so the builders never meet a missing answer and the
418
+ # error has a line to go to. The first problem is the one named.
419
+ for name in plan.SECTION_ORDER:
420
+ answered = state_for_render.get(name) or {}
421
+ section = plan.section_plan(name, repo=self.repo, state=self.state, found=self.found)
422
+ for spec in section.fields:
423
+ if spec.applies(answered) and spec.id not in answered:
424
+ state_for_render.setdefault(name, {})[spec.id] = ""
425
+ placeholders[f"{name}.{spec.id}"] = Origin(provenance.TYPED, "not yet answered")
426
+ profile = sections.build_profile(
427
+ state_for_render,
428
+ framework_version=self.record.get("standard_version", ""),
429
+ framework_digest=self.record.get("framework_digest", ""),
430
+ )
431
+ rendered = render.render_profile(profile, written_on=self.adoption_date)
432
+ traced = provenance.trace(profile, state_for_render, {**self.origins, **placeholders})
433
+ line_of = _line_map(rendered)
434
+ lines: list[ReviewLine] = []
435
+ for path, origin in traced.items():
436
+ line = line_of.get(path)
437
+ if line is None:
438
+ continue
439
+ key, _fixed = provenance.answer_key_for(path, profile)
440
+ bare = provenance._index_free(path)
441
+ note = provenance.NOT_EDITABLE_ON_REVIEW.get(bare, "")
442
+ if key is not None and key.endswith(".status") and key.startswith("gates."):
443
+ note = "decided on the gates screen"
444
+ lines.append(ReviewLine(line=line, path=path, origin=origin.label(), editable=key is not None and not note, note=note))
445
+ lines.sort(key=lambda entry: entry.line)
446
+ error = ""
447
+ error_path = None
448
+ if problem is not None:
449
+ key, message = problem
450
+ error = f"{key}: {message}"
451
+ error_path = next((p for p in traced if provenance.answer_key_for(p, profile)[0] == key), None)
452
+ else:
453
+ try:
454
+ self.verify(profile, rendered)
455
+ except Exception as exc: # WriteRefused, or anything the renderer did not expect
456
+ error = f"This cannot be written yet: {getattr(exc, 'detail', exc)}"
457
+ # `F79`: a refusal that names a profile path points Ctrl+E at its first line -
458
+ # the block's own line where the path is a block (`risk`), not a leaf.
459
+ named = getattr(exc, "path", None)
460
+ if named:
461
+ error_path = next(
462
+ (p for p in traced if p == named or p.startswith(named + ".") or p.startswith(named + "[")),
463
+ None,
464
+ )
465
+ return Review(rendered=rendered, lines=lines, error=error, error_path=error_path)
466
+
467
+ def edit(self, path: str, value: object) -> None:
468
+ """A change made on the review: recorded as typed, with a timestamp (`DR-47` (3))."""
469
+ profile = self.assemble()
470
+ key, _fixed = provenance.answer_key_for(path, profile)
471
+ if key is None:
472
+ raise ValueError(f"{path} is not editable")
473
+ if key in _YES_NO and isinstance(value, str):
474
+ value = value == "yes"
475
+ self._set(key, value, Origin(provenance.TYPED, at=provenance.now_iso()))
476
+
477
+ def approve(self) -> str:
478
+ at = provenance.now_iso()
479
+ self.done.append("review")
480
+ return at
481
+
482
+ # ----------------------------------------------------------------------------------------
483
+ # the draft
484
+ # ----------------------------------------------------------------------------------------
485
+
486
+ def draft(self) -> dict:
487
+ return {
488
+ "sections": self.state,
489
+ "origins": {key: {"kind": o.kind, "detail": o.detail, "at": o.at} for key, o in self.origins.items()},
490
+ "done": [s for s in self.done if s not in ("scaffold", "review")],
491
+ "bulk": [{"status": b.status, "count": b.count, "at": b.at} for b in self.bulk],
492
+ }
493
+
494
+ @staticmethod
495
+ def origins_from(draft: dict) -> dict[str, Origin]:
496
+ raw = draft.get("origins") or {}
497
+ return {
498
+ key: Origin(str(o.get("kind", "")), str(o.get("detail", "")), str(o.get("at", "")))
499
+ for key, o in raw.items()
500
+ if isinstance(o, dict)
501
+ }
502
+
503
+
504
+ def _default_origin(key: str, spec: plan.FieldSpec) -> Origin:
505
+ """The origin of a field's shown default, submitted unchanged: a worked example where the
506
+ framework ships one for that item, otherwise a value that follows from an earlier answer."""
507
+ from surfaceplate.adopt import example_answers
508
+
509
+ item = key.split(".")[1] if key.count(".") >= 2 else ""
510
+ if item and example_answers.rationale_example(item) == spec.default:
511
+ return Origin(provenance.EXAMPLE, "this framework's own worked example")
512
+ return Origin(provenance.COMPUTED, "the field's own default; follows from an answer already given")
513
+
514
+
515
+ def _dotted(section_name: str, spec: plan.FieldSpec, default: object | None = None) -> plan.FieldSpec:
516
+ """A section's field, re-keyed to its state address so one form can carry several sections."""
517
+ depends_on = None
518
+ if spec.depends_on is not None:
519
+ other, wanted = spec.depends_on
520
+ depends_on = (f"{section_name}.{other}", wanted)
521
+ return plan.FieldSpec(
522
+ id=f"{section_name}.{spec.id}",
523
+ label=spec.label,
524
+ kind=spec.kind,
525
+ help=spec.help,
526
+ default=spec.default if default is None else (",".join(default) if isinstance(default, list) else default),
527
+ choices=spec.choices,
528
+ validate=spec.validate,
529
+ depends_on=depends_on,
530
+ suggestions=spec.suggestions,
531
+ decides=spec.decides,
532
+ wrong=spec.wrong,
533
+ context=spec.context,
534
+ choice_help=spec.choice_help,
535
+ seed=spec.seed,
536
+ )
537
+
538
+
539
+ def _line_map(rendered: str) -> dict[str, int]:
540
+ """Profile path -> 0-based line, read from the YAML's own node marks rather than from the
541
+ renderer's layout, so the review cannot drift from what `render.py` actually wrote."""
542
+ node = yaml.compose(rendered)
543
+ out: dict[str, int] = {}
544
+
545
+ def walk(current, path: str) -> None:
546
+ if isinstance(current, yaml.MappingNode):
547
+ for key_node, value_node in current.value:
548
+ walk(value_node, f"{path}.{key_node.value}" if path else str(key_node.value))
549
+ elif isinstance(current, yaml.SequenceNode):
550
+ if not current.value:
551
+ out[path] = current.start_mark.line
552
+ for index, item in enumerate(current.value):
553
+ walk(item, f"{path}[{index}]")
554
+ else:
555
+ out[path] = current.start_mark.line
556
+
557
+ if node is not None:
558
+ walk(node, "")
559
+ return out