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.
- surfaceplate/MANIFEST.sha256 +230 -0
- surfaceplate/VERSION +1 -0
- surfaceplate/__init__.py +18 -0
- surfaceplate/about.py +48 -0
- surfaceplate/adapters/python.md +5 -0
- surfaceplate/adapters/r.md +3 -0
- surfaceplate/adapters/typescript.md +5 -0
- surfaceplate/adopt/__init__.py +15 -0
- surfaceplate/adopt/catalogue.py +78 -0
- surfaceplate/adopt/defaults.py +278 -0
- surfaceplate/adopt/detect.py +141 -0
- surfaceplate/adopt/discover.py +614 -0
- surfaceplate/adopt/example_answers.py +128 -0
- surfaceplate/adopt/explanations.py +551 -0
- surfaceplate/adopt/flow.py +559 -0
- surfaceplate/adopt/interview.py +237 -0
- surfaceplate/adopt/plan.py +1330 -0
- surfaceplate/adopt/provenance.py +300 -0
- surfaceplate/adopt/render.py +282 -0
- surfaceplate/adopt/scaffold.py +343 -0
- surfaceplate/adopt/sections.py +287 -0
- surfaceplate/adopt/tui/__init__.py +7 -0
- surfaceplate/adopt/tui/app.py +211 -0
- surfaceplate/adopt/tui/app.tcss +294 -0
- surfaceplate/adopt/tui/mark.py +77 -0
- surfaceplate/adopt/tui/screens.py +1772 -0
- surfaceplate/adopt/validators.py +224 -0
- surfaceplate/adopt/wizard.py +932 -0
- surfaceplate/check_conformance.py +3664 -0
- surfaceplate/cli.py +268 -0
- surfaceplate/core/AI_OPERATING_MODEL.md +45 -0
- surfaceplate/core/CONFORMANCE_LEVELS.md +299 -0
- surfaceplate/core/CONTROL_PRINCIPLES.md +14 -0
- surfaceplate/core/PREREQUISITE_GATES.md +322 -0
- surfaceplate/core/REVIEW_AND_EVIDENCE.md +55 -0
- surfaceplate/core/SECURITY_BASELINE.md +29 -0
- surfaceplate/doctor.py +249 -0
- surfaceplate/examples/application-profile.essential.example.yaml +126 -0
- surfaceplate/examples/application-profile.full.example.yaml +315 -0
- surfaceplate/examples/method-registry-entry.example.yaml +78 -0
- surfaceplate/examples/method-run-lineage.example.yaml +44 -0
- surfaceplate/examples/override-record.approved.example.yaml +39 -0
- surfaceplate/install_standard.py +880 -0
- surfaceplate/rules.py +148 -0
- surfaceplate/schemas/README.md +24 -0
- surfaceplate/schemas/application-profile.schema.yaml +320 -0
- surfaceplate/schemas/assurance-evidence.schema.yaml +33 -0
- surfaceplate/schemas/gate-exception.schema.yaml +47 -0
- surfaceplate/schemas/method-registry-entry.schema.yaml +132 -0
- surfaceplate/schemas/method-run-lineage.schema.yaml +72 -0
- surfaceplate/schemas/override-record.schema.yaml +76 -0
- surfaceplate/seeds/CHANGELOG.md +18 -0
- surfaceplate/seeds/activity-register.md +47 -0
- surfaceplate/seeds/adoption-decision-record.md +30 -0
- surfaceplate/seeds/data-source-register.md +18 -0
- surfaceplate/seeds/decision-log.md +28 -0
- surfaceplate/seeds/dependency-review-log.md +18 -0
- surfaceplate/seeds/findings-register.md +26 -0
- surfaceplate/seeds/method-registry-readme.md +7 -0
- surfaceplate/seeds/options-log.md +18 -0
- surfaceplate/seeds/output-validation-log.md +18 -0
- surfaceplate/seeds/overrides-readme.md +7 -0
- surfaceplate/seeds/release-checklist.md +19 -0
- surfaceplate/seeds/risk-classification.md +24 -0
- surfaceplate/seeds/run-lineage-readme.md +7 -0
- surfaceplate/seeds/source-of-truth-matrix.yaml +25 -0
- surfaceplate/seeds/test-conventions.md +20 -0
- surfaceplate/standard/.githooks/.gitattributes +1 -0
- surfaceplate/standard/.githooks/pre-commit +35 -0
- surfaceplate/standard/.github/skills/bug-fix/SKILL.md +57 -0
- surfaceplate/standard/.github/skills/change/SKILL.md +62 -0
- surfaceplate/standard/.github/skills/dependency-update/SKILL.md +61 -0
- surfaceplate/standard/.github/skills/fix-ci/SKILL.md +68 -0
- surfaceplate/standard/.github/skills/release/SKILL.md +71 -0
- surfaceplate/standard/.github/skills/review/SKILL.md +56 -0
- surfaceplate/standard/.github/skills/security-review/SKILL.md +61 -0
- surfaceplate/standard/.github/workflows/standards-conformance.yml +38 -0
- surfaceplate/standard/agent-instructions/activity.md +78 -0
- surfaceplate/standard/agent-instructions/ai-workflow.md +109 -0
- surfaceplate/standard/agent-instructions/authority.md +72 -0
- surfaceplate/standard/agent-instructions/provenance.md +93 -0
- surfaceplate/standard/agent-instructions/security.md +78 -0
- surfaceplate/standard/agent-instructions/tests.md +71 -0
- surfaceplate/standard/conformance-block.md +31 -0
- surfaceplate/templates/application-profile.yaml +113 -0
- surfaceplate/templates/decision-record.md +42 -0
- surfaceplate/templates/gate-exception.yaml +23 -0
- surfaceplate/templates/override-record.yaml +25 -0
- surfaceplate/templates/work-packet.md +33 -0
- surfaceplate-0.16.0.dist-info/METADATA +19 -0
- surfaceplate-0.16.0.dist-info/RECORD +96 -0
- surfaceplate-0.16.0.dist-info/WHEEL +4 -0
- surfaceplate-0.16.0.dist-info/entry_points.txt +2 -0
- surfaceplate-0.16.0.dist-info/licenses/LICENSE +201 -0
- surfaceplate-0.16.0.dist-info/licenses/LICENSE-DOCS +121 -0
- 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
|