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,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}"