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,237 @@
|
|
|
1
|
+
"""How a whole run gets asked, kept separate from what is asked and from what it becomes.
|
|
2
|
+
|
|
3
|
+
`DR-36` split asking from the shape of the answers; `DR-47` fixes the sequence of asking as the
|
|
4
|
+
stages `flow.Flow` holds: decisions, level, the gate list, whatever the proposal could not fill,
|
|
5
|
+
the scaffold offer, and an annotated review. An `Interview` drives a `Flow` through those stages.
|
|
6
|
+
There is one real implementation (`tui/`) and one scripted one (here), and `Cancelled` is raised
|
|
7
|
+
at the seam so an abandoned run has exactly one exit path.
|
|
8
|
+
|
|
9
|
+
**What the scripted implementation proves, and what it does not.** `ScriptedInterview` answers
|
|
10
|
+
every field a stage presents, raises on a presented field it has no answer for, and
|
|
11
|
+
`assert_no_unused_keys()` raises on an answer nothing asked for and nothing proposed. It cannot
|
|
12
|
+
prove the *screens* present what the flow presents - `tests/test_adopt_tui.py` closes that with a
|
|
13
|
+
field-id join per screen - and `tests/test_provenance.py` closes the other side: every value in
|
|
14
|
+
the written profile traces to an answer, a proposal or the allow-list, with its origin recorded.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Callable, Protocol
|
|
21
|
+
|
|
22
|
+
from surfaceplate.adopt import flow as _flow
|
|
23
|
+
from surfaceplate.adopt import plan, scaffold
|
|
24
|
+
|
|
25
|
+
# Bumped when the shape of a draft changes incompatibly. Format 2 held raw answers by section;
|
|
26
|
+
# format 3 adds the origin of every answer and which stages are done (`DR-47`). A format-2 draft
|
|
27
|
+
# is offered a fresh start rather than resumed into a flow that would record its values with no
|
|
28
|
+
# origin.
|
|
29
|
+
DRAFT_FORMAT = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Cancelled(Exception):
|
|
33
|
+
"""Raised when the human backs out - a quit key, or declining the final write.
|
|
34
|
+
|
|
35
|
+
Caught once, at the top level (`wizard.run`), which is what makes "an interrupt leaves the
|
|
36
|
+
repository untouched" true by construction rather than by every screen remembering to check.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DraftInfo:
|
|
42
|
+
"""What a human is told before being asked whether to resume an unfinished run.
|
|
43
|
+
|
|
44
|
+
`matches` is false when the draft was written against a different installed framework version
|
|
45
|
+
or digest. `DR-35` decided that case is *flagged, not silently trusted and not refused* - the
|
|
46
|
+
human still decides, with the mismatch stated.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
sections: tuple[str, ...]
|
|
50
|
+
framework_version: str
|
|
51
|
+
framework_digest: str
|
|
52
|
+
matches: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Welcome:
|
|
57
|
+
"""What the opening screen shows before the first question (`F81`, `DR-51` (2)): the tool,
|
|
58
|
+
the install it is about to write against, where it will write, and the draft if one exists.
|
|
59
|
+
The version comparison has already passed by the time this is built (`wizard.InstallMismatch`)."""
|
|
60
|
+
|
|
61
|
+
repo: str
|
|
62
|
+
tool_name: str
|
|
63
|
+
tool_version: str
|
|
64
|
+
tool_anchor: str
|
|
65
|
+
licence: str
|
|
66
|
+
publisher: str
|
|
67
|
+
homepage: str
|
|
68
|
+
tagline: str
|
|
69
|
+
installed_version: str
|
|
70
|
+
installed_anchor: str
|
|
71
|
+
installed_at: str
|
|
72
|
+
profile_path: str
|
|
73
|
+
provenance_path: str
|
|
74
|
+
draft: DraftInfo | None = None
|
|
75
|
+
# Why a draft found on disk is not offered (`F77`): left in place, and said out loud.
|
|
76
|
+
draft_note: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Interview(Protocol):
|
|
80
|
+
def open(self, welcome: Welcome) -> bool | None:
|
|
81
|
+
"""Show the opening screen and, where `welcome.draft` is set, the resume prompt. `True`
|
|
82
|
+
begins (resuming the draft if there is one); `False` discards the draft and begins fresh;
|
|
83
|
+
`None` means the human quit: the run is cancelled and any draft is kept (`F68`). A draft
|
|
84
|
+
is offered, never silently reloaded."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
def collect(self, flow: _flow.Flow, *, on_progress: Callable[[], None]) -> str:
|
|
88
|
+
"""Drive `flow` from wherever it stands to an approved review, and return the approval
|
|
89
|
+
timestamp. `on_progress()` is called as each stage completes, so a draft is saved and a
|
|
90
|
+
late failure loses at most the stage in progress. Raises `Cancelled` if the human
|
|
91
|
+
abandons the run or declines the final write."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ScriptedInterview:
|
|
97
|
+
"""An `Interview` driven by a keyed script, for tests and for nothing else.
|
|
98
|
+
|
|
99
|
+
`answers` is keyed `"<section>.<field id>"` - `"identity.owner"`, `"gates.work_registration.artefact"`.
|
|
100
|
+
A key for a field a stage presents is used as the answer; a key for a field the flow proposed
|
|
101
|
+
but did not present overrides the proposal (and is recorded as typed, as a review edit would
|
|
102
|
+
be). Gate statuses are presented as decisions and must be scripted, one per undecided gate,
|
|
103
|
+
unless `bulk_not_applicable` is set - the scripted form of the one explicit bulk command.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
answers: dict[str, object]
|
|
107
|
+
cancel_before: str = "" # stage name to abandon at, for interrupt tests
|
|
108
|
+
confirm_write: bool = True
|
|
109
|
+
resume: bool | None = True # what to answer if a draft is offered
|
|
110
|
+
bulk_not_applicable: bool = False
|
|
111
|
+
accept_scaffold: bool = True
|
|
112
|
+
edits: dict[str, object] = field(default_factory=dict) # review edits, by profile path
|
|
113
|
+
asked: list[str] = field(default_factory=list)
|
|
114
|
+
resume_offers: list[DraftInfo] = field(default_factory=list)
|
|
115
|
+
welcomes: list[Welcome] = field(default_factory=list)
|
|
116
|
+
review: _flow.Review | None = None
|
|
117
|
+
stages: list[str] = field(default_factory=list)
|
|
118
|
+
flow: _flow.Flow | None = None
|
|
119
|
+
|
|
120
|
+
def open(self, welcome: Welcome) -> bool | None:
|
|
121
|
+
self.welcomes.append(welcome)
|
|
122
|
+
if welcome.draft is None:
|
|
123
|
+
return True
|
|
124
|
+
self.resume_offers.append(welcome.draft)
|
|
125
|
+
return self.resume
|
|
126
|
+
|
|
127
|
+
def collect(self, flow: _flow.Flow, *, on_progress: Callable[[], None]) -> str:
|
|
128
|
+
self.flow = flow # kept so a test can read what the run proposed and recorded
|
|
129
|
+
while True:
|
|
130
|
+
stage = flow.next_stage()
|
|
131
|
+
if stage == self.cancel_before:
|
|
132
|
+
raise Cancelled()
|
|
133
|
+
self.stages.append(stage)
|
|
134
|
+
if stage == "decisions":
|
|
135
|
+
flow.answer_decisions(self._answer(flow.decisions_plan()))
|
|
136
|
+
elif stage == "level":
|
|
137
|
+
flow.answer_level(self._answer(flow.level_plan(), prefix="level."))
|
|
138
|
+
elif stage == "gates":
|
|
139
|
+
gate_answers, bulk = self._answer_gates(flow)
|
|
140
|
+
flow.answer_gates(gate_answers, bulk=bulk)
|
|
141
|
+
elif stage == "remainder":
|
|
142
|
+
flow.answer_remainder(self._answer(flow.remainder_plan()))
|
|
143
|
+
elif stage == "scaffold":
|
|
144
|
+
offers = flow.scaffold_offers()
|
|
145
|
+
flow.accept_scaffold(offers if self.accept_scaffold else [])
|
|
146
|
+
else:
|
|
147
|
+
break
|
|
148
|
+
on_progress()
|
|
149
|
+
# Overrides for proposed values nothing presented: recorded as typed, like a review edit.
|
|
150
|
+
for key, value in self.answers.items():
|
|
151
|
+
if key not in self.asked and key in flow.proposals:
|
|
152
|
+
flow._record_answer(key, value)
|
|
153
|
+
self.asked.append(key)
|
|
154
|
+
for path, value in self.edits.items():
|
|
155
|
+
flow.edit(path, value)
|
|
156
|
+
self.review = flow.review()
|
|
157
|
+
if not self.confirm_write:
|
|
158
|
+
raise Cancelled()
|
|
159
|
+
if self.review.error:
|
|
160
|
+
raise AssertionError(f"ScriptedInterview: the review refuses to write: {self.review.error}")
|
|
161
|
+
return flow.approve()
|
|
162
|
+
|
|
163
|
+
def _answer(self, section: plan.SectionPlan, prefix: str = "") -> dict:
|
|
164
|
+
answers: dict = {}
|
|
165
|
+
for spec in section.fields:
|
|
166
|
+
if not spec.applies(answers):
|
|
167
|
+
continue
|
|
168
|
+
key = f"{prefix}{spec.id}"
|
|
169
|
+
self.asked.append(key)
|
|
170
|
+
if key in self.answers:
|
|
171
|
+
answers[spec.id] = self.answers[key]
|
|
172
|
+
elif spec.kind == "multiselect":
|
|
173
|
+
answers[spec.id] = [c.strip() for c in str(spec.default).split(",") if c.strip()]
|
|
174
|
+
elif spec.default and spec.kind not in ("choice",):
|
|
175
|
+
answers[spec.id] = spec.default
|
|
176
|
+
elif not spec.validate and spec.kind in ("text", "textarea"):
|
|
177
|
+
answers[spec.id] = ""
|
|
178
|
+
else:
|
|
179
|
+
raise AssertionError(
|
|
180
|
+
f"ScriptedInterview: the flow asks for {key!r} and the script has no answer "
|
|
181
|
+
f"for it. Fields asked so far in this section: {sorted(answers)}"
|
|
182
|
+
)
|
|
183
|
+
return answers
|
|
184
|
+
|
|
185
|
+
def _answer_gates(self, flow: _flow.Flow) -> tuple[dict, tuple[str, set[str]] | None]:
|
|
186
|
+
seeds = flow.gate_seeds()
|
|
187
|
+
answers: dict = {}
|
|
188
|
+
bulk: set[str] = set()
|
|
189
|
+
for spec in flow.gate_specs():
|
|
190
|
+
local: dict = {}
|
|
191
|
+
for gate_field in spec.fields:
|
|
192
|
+
key = f"gates.{spec.id}.{gate_field.id}"
|
|
193
|
+
short = f"{spec.id}.{gate_field.id}"
|
|
194
|
+
if not gate_field.applies(local):
|
|
195
|
+
continue
|
|
196
|
+
self.asked.append(key)
|
|
197
|
+
if (
|
|
198
|
+
gate_field.id == "artefact"
|
|
199
|
+
and spec.id in scaffold.SEEDABLE
|
|
200
|
+
and self.answers.get(key, seeds.get(short)) == scaffold.SEEDABLE[spec.id][0]
|
|
201
|
+
and not (flow.repo / scaffold.SEEDABLE[spec.id][0]).exists()
|
|
202
|
+
):
|
|
203
|
+
# The seed path named before the file exists: as on the screen, left blank so
|
|
204
|
+
# the scaffold offer supplies it (or the review refuses if declined).
|
|
205
|
+
continue
|
|
206
|
+
if key in self.answers:
|
|
207
|
+
local[gate_field.id] = self.answers[key]
|
|
208
|
+
elif short in seeds:
|
|
209
|
+
local[gate_field.id] = seeds[short]
|
|
210
|
+
elif gate_field.id == "status" and self.bulk_not_applicable:
|
|
211
|
+
local[gate_field.id] = "not_applicable"
|
|
212
|
+
bulk.add(spec.id)
|
|
213
|
+
elif gate_field.default and gate_field.kind != "choice":
|
|
214
|
+
local[gate_field.id] = gate_field.default
|
|
215
|
+
elif (
|
|
216
|
+
gate_field.id == "artefact"
|
|
217
|
+
and spec.id in scaffold.SEEDABLE
|
|
218
|
+
and not (flow.repo / scaffold.SEEDABLE[spec.id][0]).exists()
|
|
219
|
+
):
|
|
220
|
+
# As on the screen: a seedable gate's artefact may stay blank, and the
|
|
221
|
+
# scaffold offer that follows this stage supplies it or the review refuses.
|
|
222
|
+
continue
|
|
223
|
+
else:
|
|
224
|
+
raise AssertionError(
|
|
225
|
+
f"ScriptedInterview: the gate list asks for {key!r} and the script has no "
|
|
226
|
+
f"answer for it (and nothing was proposed)."
|
|
227
|
+
)
|
|
228
|
+
for field_id, value in local.items():
|
|
229
|
+
answers[f"{spec.id}.{field_id}"] = value
|
|
230
|
+
return answers, (("not_applicable", bulk) if bulk else None)
|
|
231
|
+
|
|
232
|
+
def assert_no_unused_keys(self) -> None:
|
|
233
|
+
"""The other half of the guarantee: an answer nothing asked for and nothing proposed is a
|
|
234
|
+
test failure too, because it usually means a field was renamed or dropped and the script
|
|
235
|
+
kept feeding it."""
|
|
236
|
+
unused = sorted(set(self.answers) - set(self.asked))
|
|
237
|
+
assert not unused, f"{len(unused)} scripted answer(s) were never asked for: {unused}"
|