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,1330 @@
|
|
|
1
|
+
"""What the wizard will ask, described as data rather than as control flow.
|
|
2
|
+
|
|
3
|
+
`DR-36` records why this module exists. Before it, "which questions does an `essential`-level run
|
|
4
|
+
with no user interface actually ask?" was answerable only by *running* `sections.ask_gates` and
|
|
5
|
+
watching what it did - the applicability rules (level floors, `DESIGN_GATES` auto-masking, the
|
|
6
|
+
`essential` short-circuit) lived inside the same functions that did the asking. That was survivable
|
|
7
|
+
when asking was a sequence of `Prompt` calls. It is not survivable once a screen has to render
|
|
8
|
+
several gates at once, because the screen needs to know the shape of the whole section *before* it
|
|
9
|
+
draws anything.
|
|
10
|
+
|
|
11
|
+
So the shape moves here, and three separate consumers now join against one description:
|
|
12
|
+
|
|
13
|
+
- `tui/` renders a `SectionPlan` into widgets;
|
|
14
|
+
- `ScriptedInterview` answers a `SectionPlan` field by field, and objects to any field it was given
|
|
15
|
+
no answer for (and any answer no field asked for);
|
|
16
|
+
- `tests/test_adopt_tui.py` asserts each screen's field ids equal its plan's field ids.
|
|
17
|
+
|
|
18
|
+
**That third one is what makes the other two load-bearing.** A script that answers the plan proves
|
|
19
|
+
the plan was answered, not that the screens ask the plan; the join test is what closes the gap. See
|
|
20
|
+
`DR-36`'s own note on why `ScriptedPrompt` never proved what it appeared to.
|
|
21
|
+
|
|
22
|
+
Nothing here decides anything on a human's behalf. A `FieldSpec` carries a `default` where this
|
|
23
|
+
framework has a real example to offer (`example_answers.py`) or a suggestion the old flow already
|
|
24
|
+
made (today's date, `history_audit, review`), and a default is still submitted by a human before it
|
|
25
|
+
becomes an answer - the same "shown, must submit" contract `DR-32` established and `DR-35` extended.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime as _dt
|
|
31
|
+
from dataclasses import dataclass, field as _field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from surfaceplate.adopt import catalogue, detect, discover, example_answers, explanations, validators
|
|
35
|
+
|
|
36
|
+
# The three controls required at every level. Fixed order, matching the profile's own layout and
|
|
37
|
+
# `core/CONFORMANCE_LEVELS.md`'s "The three baseline controls" section.
|
|
38
|
+
BASELINE_CONTROL_IDS = ("agent_work_packets", "actual_diff_review", "secret_hygiene")
|
|
39
|
+
|
|
40
|
+
# `DR-51` (3): what the checker does with each kind of implementation reference, and the cost of
|
|
41
|
+
# naming the wrong thing. One sentence each, reused wherever the same rule applies.
|
|
42
|
+
ARTEFACT_DECIDES = (
|
|
43
|
+
"the file the checker requires to exist, be non-empty, carry no placeholder and be tracked "
|
|
44
|
+
"by git (SP032), and to precede every change under the gated paths in history (SP035)"
|
|
45
|
+
)
|
|
46
|
+
ARTEFACT_WRONG = "a file that is not the artefact satisfies the check and guards nothing"
|
|
47
|
+
PATTERN_A_DECIDES = "the file the checker requires to exist, be non-empty, carry no placeholder and be tracked (SP051)"
|
|
48
|
+
PATTERN_B_DECIDES = "the workflow step the checker looks up by name and requires to be able to fail the job (SP053)"
|
|
49
|
+
PATTERN_C_DECIDES = "the directory of records the checker validates against this control's schema (SP055, SP056)"
|
|
50
|
+
CONTROL_WRONG = "the control fails on the next run, or something that is not the implementation passes for it"
|
|
51
|
+
RATIONALE_DECIDES = "why the profile says this applies here; read by reviewers and auditors, never by the checker"
|
|
52
|
+
RATIONALE_WRONG = "a reason nobody wrote is the framework's example standing under your name"
|
|
53
|
+
SCANNER_WIRED_DECIDES = (
|
|
54
|
+
"the file the checker opens to confirm the named scanner runs there and can fail the build "
|
|
55
|
+
"(SP046, SP047) - the one baseline control that is actually checked"
|
|
56
|
+
)
|
|
57
|
+
SCANNER_WIRED_WRONG = "the checker reports the scanner absent on its next run"
|
|
58
|
+
LOCK_DECIDES = PATTERN_A_DECIDES
|
|
59
|
+
NOT_CHECKED_WRONG = "a misleading profile; the checker never reads this"
|
|
60
|
+
RELEASE_ROUTE_DECIDES = "what the profile records as the human and platform controls between a merge and a release; not verified"
|
|
61
|
+
RELEASE_ROUTE_WRONG = "a route nobody follows is a promise the profile makes on your behalf"
|
|
62
|
+
|
|
63
|
+
# Words that mark a file as a findings register, for ranking the offer and for proposing: a
|
|
64
|
+
# proposal comes only from a match (`F40`'s rule, applied to controls at `DR-54` (2)).
|
|
65
|
+
FINDINGS_WORDS = ("finding", "assurance")
|
|
66
|
+
|
|
67
|
+
# `F97` / `DR-59`: controls the checker verifies through a gate, and the gate. Above the floor, such
|
|
68
|
+
# a control is offered only at a level that declares its gate; `SP052` otherwise fails the profile
|
|
69
|
+
# on its first check, and the wizard would have written a profile it knew the checker faults.
|
|
70
|
+
WITHHELD_ABOVE_FLOOR = {"documentation_authority": "authority_map"}
|
|
71
|
+
|
|
72
|
+
TEMPLATE_PLACEHOLDER_HELP = (
|
|
73
|
+
'Type an actual value. "TBD", "TODO" and similar are template placeholders this framework\'s '
|
|
74
|
+
"own checker rejects (SP020) - writing one here would fail the profile you're about to produce."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class FieldSpec:
|
|
80
|
+
"""One thing a human is asked for.
|
|
81
|
+
|
|
82
|
+
`id` is unique within its section; the addressable key a script uses is `f"{section}.{id}"`.
|
|
83
|
+
`depends_on` is the single mechanism for conditionality anywhere in this wizard - a gate's
|
|
84
|
+
follow-ups, `status_rationale`, `independent_validator` all use it, so a screen has exactly one
|
|
85
|
+
rule to implement and the join test has exactly one rule to check.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
id: str
|
|
89
|
+
label: str
|
|
90
|
+
kind: str = "text" # text | textarea | choice | bool | select | multiselect
|
|
91
|
+
help: str = ""
|
|
92
|
+
default: str = ""
|
|
93
|
+
choices: tuple[tuple[str, str], ...] = ()
|
|
94
|
+
validate: str = "nonempty" # a name in validators.py; "" means anything, including blank
|
|
95
|
+
depends_on: tuple[str, tuple[object, ...]] | None = None
|
|
96
|
+
# Candidates read out of the adopting repository (`discover.py`). On a `select` field these
|
|
97
|
+
# ARE the options; on a `text` field they are offered as inline completions and constrain
|
|
98
|
+
# nothing. Empty means discovery found none - the field then behaves exactly as it did before
|
|
99
|
+
# discovery existed, which is the honest fallback for a repository git cannot read.
|
|
100
|
+
suggestions: tuple[str, ...] = ()
|
|
101
|
+
# `DR-51` (3): the stated minimum for a reader meeting the framework for the first time -
|
|
102
|
+
# what the answer decides, and what a wrong answer costs. Shown beside the focused field with
|
|
103
|
+
# `help`; `tests/test_adopt.py` fails on any presented field lacking either.
|
|
104
|
+
decides: str = ""
|
|
105
|
+
wrong: str = ""
|
|
106
|
+
# `DR-51` (4): how to describe a chosen value - `gate:<id>`, `scanner:<name>`, `lock`,
|
|
107
|
+
# `register`, `step` - so the help beside a dropdown can say what the chosen file is.
|
|
108
|
+
context: str = ""
|
|
109
|
+
# `F67`: a list whose labels must fit a row keeps each choice's full explanation here, shown
|
|
110
|
+
# beside the list for the highlighted row, keyed by the choice's value.
|
|
111
|
+
choice_help: tuple[tuple[str, str], ...] = ()
|
|
112
|
+
# `DR-54` (1): the path of the seed this field's first row offers to create, where one is
|
|
113
|
+
# free; the value of that row is the path itself, and choosing it leads to the offer.
|
|
114
|
+
seed: str = ""
|
|
115
|
+
|
|
116
|
+
def applies(self, answers: dict) -> bool:
|
|
117
|
+
"""Whether this field is asked at all, given what has been answered so far in its section."""
|
|
118
|
+
if self.depends_on is None:
|
|
119
|
+
return True
|
|
120
|
+
other_id, wanted = self.depends_on
|
|
121
|
+
value = answers.get(other_id)
|
|
122
|
+
# A `multiselect` answers with a list, so "depends on that field" means "is among what was
|
|
123
|
+
# ticked". This is the same rule generalised, not a second one: for every scalar answer the
|
|
124
|
+
# behaviour is unchanged, and a screen still has one question to ask of a field.
|
|
125
|
+
if isinstance(value, (list, tuple, set)):
|
|
126
|
+
return any(item in value for item in wanted)
|
|
127
|
+
return value in wanted
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class SectionPlan:
|
|
132
|
+
name: str # the state key: "identity", "gates", ...
|
|
133
|
+
title: str # what the frame header shows
|
|
134
|
+
fields: tuple[FieldSpec, ...] = ()
|
|
135
|
+
intro: str = "" # explanatory text above the fields
|
|
136
|
+
recap: tuple[str, ...] = () # "You told us:" lines, from earlier sections
|
|
137
|
+
notes: tuple[str, ...] = () # detected signals and similar, shown but never acted on
|
|
138
|
+
|
|
139
|
+
def field_ids(self) -> list[str]:
|
|
140
|
+
return [f.id for f in self.fields]
|
|
141
|
+
|
|
142
|
+
def applicable_fields(self, answers: dict) -> list[FieldSpec]:
|
|
143
|
+
return [f for f in self.fields if f.applies(answers)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class GateSpec:
|
|
148
|
+
"""One gate in the catalogue screen, as a mini-section.
|
|
149
|
+
|
|
150
|
+
A gate is the only place this wizard shows several answerable things at once, so it carries its
|
|
151
|
+
own fields rather than flattening into the section: the screen draws three `GateSpec`s side by
|
|
152
|
+
side and each one's follow-ups appear inside its own block.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
id: str
|
|
156
|
+
section: str # the `core/PREREQUISITE_GATES.md` group heading it belongs to
|
|
157
|
+
explanation: str # already resolved for the active mode
|
|
158
|
+
mandatory: bool # the level forces `required`; status is stated, not chosen
|
|
159
|
+
auto_status: str = "" # non-empty when the status is settled by an earlier answer
|
|
160
|
+
fields: tuple[FieldSpec, ...] = _field(default_factory=tuple)
|
|
161
|
+
|
|
162
|
+
def field_ids(self) -> list[str]:
|
|
163
|
+
return [f"{self.id}.{f.id}" for f in self.fields]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------------------------
|
|
167
|
+
# Sections 0-3 — mode, identity, stack, risk
|
|
168
|
+
# ---------------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_YES_NO_CHOICES = (("yes", "yes"), ("no", "no"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def decisions_plan(repo: Path, *, found: discover.Discovered, proposals: dict) -> SectionPlan:
|
|
175
|
+
"""The one form before the level: what only the adopter can tell us (`DR-47`, report R1).
|
|
176
|
+
|
|
177
|
+
Eight fields, plus one for each thing discovery could not find that every level needs. Nothing
|
|
178
|
+
here is a question the tool could answer on the adopter's behalf: who owns this, whether it
|
|
179
|
+
builds an interface, who relies on it, how its data is classified, how it is released. The
|
|
180
|
+
three yes/no questions are radio choices with nothing pre-selected (`F64`), so each is a key
|
|
181
|
+
the human pressed and is recorded as typed. `application_id` is pre-filled from the directory
|
|
182
|
+
name and recorded as computed unless changed.
|
|
183
|
+
"""
|
|
184
|
+
fields: list[FieldSpec] = [
|
|
185
|
+
FieldSpec(
|
|
186
|
+
id="identity.application_id",
|
|
187
|
+
label="application_id",
|
|
188
|
+
help="short, stable, used in file paths and IDs - lowercase, digits, hyphen or underscore",
|
|
189
|
+
default=str(proposals["identity.application_id"].value) if "identity.application_id" in proposals else "",
|
|
190
|
+
validate="application_id",
|
|
191
|
+
decides="the id every record about this repository is filed under, and the name the checker reports",
|
|
192
|
+
wrong="renaming later means changing every reference; pick the stable one now",
|
|
193
|
+
),
|
|
194
|
+
FieldSpec(
|
|
195
|
+
id="identity.owner",
|
|
196
|
+
label="owner",
|
|
197
|
+
help="who is accountable for this application, not this adoption - a named human",
|
|
198
|
+
decides="who the framework holds accountable for this profile, and who reviewers look to",
|
|
199
|
+
wrong="accountability lands on nobody, and the checker cannot tell",
|
|
200
|
+
),
|
|
201
|
+
]
|
|
202
|
+
if "stack.language" not in proposals:
|
|
203
|
+
fields.append(
|
|
204
|
+
FieldSpec(
|
|
205
|
+
id="stack.language",
|
|
206
|
+
label="Language(s) / framework",
|
|
207
|
+
help="nothing recognisable was detected, so say what this is built in",
|
|
208
|
+
decides="what the profile records for its reader; the checker never reads it",
|
|
209
|
+
wrong=NOT_CHECKED_WRONG,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
fields += [
|
|
213
|
+
FieldSpec(
|
|
214
|
+
id="stack.builds_user_interface",
|
|
215
|
+
label="Does this repository build a user interface?",
|
|
216
|
+
kind="choice",
|
|
217
|
+
choices=_YES_NO_CHOICES,
|
|
218
|
+
help=(
|
|
219
|
+
"Decides whether the four interface gates are a floor at standard and full. "
|
|
220
|
+
"Answer for what this repository actually does, not what it might do later."
|
|
221
|
+
),
|
|
222
|
+
decides="whether the four interface gates (component library, design authority, options, screen state) are required at standard and full",
|
|
223
|
+
wrong="yes on a repository with no interface adds four gates it can never satisfy; no on one with screens leaves them unguarded",
|
|
224
|
+
),
|
|
225
|
+
# `ACT-032`: the two questions the level recommendation reads, in the framework's own
|
|
226
|
+
# words (`catalogue.LEVEL_BLURBS`), so the level is answerable before it is explained.
|
|
227
|
+
FieldSpec(
|
|
228
|
+
id="risk.relied_on_outside_team",
|
|
229
|
+
label="Does anyone outside your team rely on what this produces?",
|
|
230
|
+
kind="choice",
|
|
231
|
+
choices=_YES_NO_CHOICES,
|
|
232
|
+
help=(
|
|
233
|
+
"A colleague in another team, a customer, or another system reading your "
|
|
234
|
+
"output. If it is a proof of concept or internal tooling nobody else depends "
|
|
235
|
+
"on, the answer is no."
|
|
236
|
+
),
|
|
237
|
+
decides="the level this tool recommends: standard exists for output someone outside the team depends on",
|
|
238
|
+
wrong="a recommendation pointing at the wrong level; the level itself is still yours to choose on the next screen",
|
|
239
|
+
),
|
|
240
|
+
FieldSpec(
|
|
241
|
+
id="risk.material_quantitative_output",
|
|
242
|
+
label="Does it produce numbers or AI output that others treat as fact?",
|
|
243
|
+
kind="choice",
|
|
244
|
+
choices=_YES_NO_CHOICES,
|
|
245
|
+
help=(
|
|
246
|
+
"Figures, model outputs or AI-generated results that another system or another "
|
|
247
|
+
"team consumes without re-deriving them. Not: logs, dashboards of your own "
|
|
248
|
+
"activity, or output a human always checks before it is used."
|
|
249
|
+
),
|
|
250
|
+
decides="the level this tool recommends: full exists for numbers or AI output that others treat as fact",
|
|
251
|
+
wrong="a recommendation pointing at the wrong level; the level itself is still yours to choose on the next screen",
|
|
252
|
+
),
|
|
253
|
+
FieldSpec(
|
|
254
|
+
id="risk.data_classification",
|
|
255
|
+
label="Data classification",
|
|
256
|
+
kind="choice",
|
|
257
|
+
choices=(
|
|
258
|
+
("public", "public - no restriction"),
|
|
259
|
+
("internal", "internal - not for external release"),
|
|
260
|
+
("confidential", "confidential - within the organisation"),
|
|
261
|
+
("restricted", "restricted - the strictest tier"),
|
|
262
|
+
),
|
|
263
|
+
help="the most sensitive data this repository handles, in the organisation's own tiers",
|
|
264
|
+
decides="what the profile states about the data here; the repository classification is proposed from it",
|
|
265
|
+
wrong="a profile that understates or overstates the data's sensitivity to whoever relies on it",
|
|
266
|
+
),
|
|
267
|
+
FieldSpec(
|
|
268
|
+
id="wrap.release_route",
|
|
269
|
+
label="Release route",
|
|
270
|
+
kind="textarea",
|
|
271
|
+
help="human and platform release controls, in your own words",
|
|
272
|
+
decides=RELEASE_ROUTE_DECIDES,
|
|
273
|
+
wrong=RELEASE_ROUTE_WRONG,
|
|
274
|
+
),
|
|
275
|
+
FieldSpec(
|
|
276
|
+
id="risk.risk_profile",
|
|
277
|
+
label="Risk, in your own words",
|
|
278
|
+
kind="textarea",
|
|
279
|
+
help="optional - intended use, uncertainty, materiality. Left blank, the profile says so",
|
|
280
|
+
validate="",
|
|
281
|
+
decides="the profile's own statement of what could go wrong and for whom",
|
|
282
|
+
wrong="left blank, the profile says \"Not stated at adoption\", which is honest; a vague sentence is worse than that",
|
|
283
|
+
),
|
|
284
|
+
]
|
|
285
|
+
# One question per thing discovery could not find that every level needs. A field with no
|
|
286
|
+
# honest source is asked (`DR-40`); discovery's job is to make this rare, not to invent.
|
|
287
|
+
# `F83` / `DR-51` (5): asked when no workflow RUNS the scanner, not when none exists.
|
|
288
|
+
if not found.scanner_workflows:
|
|
289
|
+
fields.append(
|
|
290
|
+
FieldSpec(
|
|
291
|
+
id="controls.scanner.wired_in",
|
|
292
|
+
label="Scanner workflow file",
|
|
293
|
+
help=f"no workflow with a step that runs {discover.DEFAULT_SCANNER} was found; name the file where the secret scanner runs",
|
|
294
|
+
validate=f"scanner_workflow:{discover.DEFAULT_SCANNER}",
|
|
295
|
+
context=f"scanner:{discover.DEFAULT_SCANNER}",
|
|
296
|
+
decides=SCANNER_WIRED_DECIDES,
|
|
297
|
+
wrong=SCANNER_WIRED_WRONG,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
if not found.lock_files:
|
|
301
|
+
fields.append(
|
|
302
|
+
FieldSpec(
|
|
303
|
+
id="controls.dependency_lock.implementation_reference",
|
|
304
|
+
label="Dependency lock file",
|
|
305
|
+
help="no lock file was found; name the file that pins this repository's dependencies",
|
|
306
|
+
validate="tracked_path",
|
|
307
|
+
context="lock",
|
|
308
|
+
decides=LOCK_DECIDES,
|
|
309
|
+
wrong=CONTROL_WRONG,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
return SectionPlan(
|
|
313
|
+
name="decisions",
|
|
314
|
+
title="What only you can tell us",
|
|
315
|
+
intro=(
|
|
316
|
+
"Everything else is proposed from this repository and this framework's own examples, "
|
|
317
|
+
"and shown to you with where it came from before anything is written."
|
|
318
|
+
),
|
|
319
|
+
fields=tuple(fields),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def identity_plan() -> SectionPlan:
|
|
324
|
+
return SectionPlan(
|
|
325
|
+
name="identity",
|
|
326
|
+
title="Identity",
|
|
327
|
+
fields=(
|
|
328
|
+
FieldSpec(
|
|
329
|
+
id="application_id",
|
|
330
|
+
label="application_id",
|
|
331
|
+
help="short, stable, used in file paths and IDs - lowercase, digits, hyphen or underscore",
|
|
332
|
+
validate="application_id",
|
|
333
|
+
decides="the id every record about this repository is filed under, and the name the checker reports",
|
|
334
|
+
wrong="renaming later means changing every reference; pick the stable one now",
|
|
335
|
+
),
|
|
336
|
+
FieldSpec(
|
|
337
|
+
id="display_name", label="display_name", help="what humans call it",
|
|
338
|
+
decides="what the profile shows a reader as this application's name; not checked",
|
|
339
|
+
wrong=NOT_CHECKED_WRONG,
|
|
340
|
+
),
|
|
341
|
+
FieldSpec(
|
|
342
|
+
id="owner",
|
|
343
|
+
label="owner",
|
|
344
|
+
help="who is accountable for this application, not this adoption",
|
|
345
|
+
decides="who the framework holds accountable for this profile, and who reviewers look to",
|
|
346
|
+
wrong="accountability lands on nobody, and the checker cannot tell",
|
|
347
|
+
),
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def stack_plan(repo: Path) -> SectionPlan:
|
|
353
|
+
"""`builds_user_interface` is asked outright and never set from detection.
|
|
354
|
+
`core/CONFORMANCE_LEVELS.md` is explicit about why: "a reviewer can falsify a wrong answer in
|
|
355
|
+
seconds" - which is only a meaningful check if a human, not a heuristic, gave the answer being
|
|
356
|
+
checked. Detection fills the language box and nothing else."""
|
|
357
|
+
languages = detect.detect_languages(repo)
|
|
358
|
+
ui_hint = detect.detect_ui_hint(repo)
|
|
359
|
+
|
|
360
|
+
notes = []
|
|
361
|
+
if languages:
|
|
362
|
+
notes.append(f"Detected: {', '.join(languages)}")
|
|
363
|
+
if ui_hint:
|
|
364
|
+
notes.append(f"Detected a UI-framework dependency: {ui_hint} - confirm it below yourself")
|
|
365
|
+
|
|
366
|
+
return SectionPlan(
|
|
367
|
+
name="stack",
|
|
368
|
+
title="Stack",
|
|
369
|
+
notes=tuple(notes),
|
|
370
|
+
fields=(
|
|
371
|
+
FieldSpec(
|
|
372
|
+
id="language",
|
|
373
|
+
label="Language(s) / framework",
|
|
374
|
+
default=", ".join(languages) if languages else "",
|
|
375
|
+
help="shown above if detected - confirm or correct it",
|
|
376
|
+
decides="what the profile records for its reader; the checker never reads it",
|
|
377
|
+
wrong=NOT_CHECKED_WRONG,
|
|
378
|
+
),
|
|
379
|
+
FieldSpec(
|
|
380
|
+
id="builds_user_interface",
|
|
381
|
+
label="Does this repository build a user interface?",
|
|
382
|
+
kind="bool",
|
|
383
|
+
help=(
|
|
384
|
+
"Decides whether the four interface gates are a floor at standard and full. "
|
|
385
|
+
"Not descriptive - answer for what this repository actually does, not what it "
|
|
386
|
+
"might do later."
|
|
387
|
+
),
|
|
388
|
+
validate="",
|
|
389
|
+
decides="whether the four interface gates are required at standard and full",
|
|
390
|
+
wrong="yes on a repository with no interface adds four gates it can never satisfy; no on one with screens leaves them unguarded",
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def risk_plan() -> SectionPlan:
|
|
397
|
+
return SectionPlan(
|
|
398
|
+
name="risk",
|
|
399
|
+
title="Risk & materiality",
|
|
400
|
+
fields=(
|
|
401
|
+
FieldSpec(
|
|
402
|
+
id="risk_profile",
|
|
403
|
+
label="Risk profile",
|
|
404
|
+
kind="textarea",
|
|
405
|
+
help="intended use, uncertainty, materiality - a sentence or two, in your own words",
|
|
406
|
+
decides="the profile's own statement of what could go wrong and for whom",
|
|
407
|
+
wrong="a vague sentence reads as a considered one; \"Not stated at adoption\" is the honest blank",
|
|
408
|
+
),
|
|
409
|
+
FieldSpec(
|
|
410
|
+
id="materiality_definition",
|
|
411
|
+
label="Materiality definition",
|
|
412
|
+
kind="textarea",
|
|
413
|
+
help="which outputs or decisions are material - what would make a wrong one matter",
|
|
414
|
+
decides="what the profile counts as material, which is what every material-output rule in the standard refers back to",
|
|
415
|
+
wrong="too narrow and material outputs escape the controls; too wide and everything is material and nothing is",
|
|
416
|
+
),
|
|
417
|
+
# `ACT-032`. Two plain questions about the adopter's world, asked BEFORE the level, so
|
|
418
|
+
# the level becomes answerable by someone who has not yet learned what a "conformance
|
|
419
|
+
# level" is. They are not new criteria: `catalogue.LEVEL_BLURBS` already defines each
|
|
420
|
+
# level exactly this way - *"anything whose output nobody outside the team relies on"*,
|
|
421
|
+
# *"anything a colleague or a customer depends on"*, *"material quantitative or AI
|
|
422
|
+
# output that other systems consume as fact"*. This asks the framework's own
|
|
423
|
+
# definitions instead of asking the adopter to map themselves onto its vocabulary.
|
|
424
|
+
FieldSpec(
|
|
425
|
+
id="relied_on_outside_team",
|
|
426
|
+
label="Does anyone outside your team rely on what this produces?",
|
|
427
|
+
kind="bool",
|
|
428
|
+
help=(
|
|
429
|
+
"A colleague in another team, a customer, or another system reading your "
|
|
430
|
+
"output. If it is a proof of concept or internal tooling nobody else depends "
|
|
431
|
+
"on, the answer is no."
|
|
432
|
+
),
|
|
433
|
+
validate="",
|
|
434
|
+
decides="the level this tool recommends: standard exists for output someone outside the team depends on",
|
|
435
|
+
wrong="a recommendation pointing at the wrong level; the level itself is still yours to choose",
|
|
436
|
+
),
|
|
437
|
+
FieldSpec(
|
|
438
|
+
id="material_quantitative_output",
|
|
439
|
+
label="Does it produce numbers or AI output that others treat as fact?",
|
|
440
|
+
kind="bool",
|
|
441
|
+
help=(
|
|
442
|
+
"Figures, model outputs or AI-generated results that another system or another "
|
|
443
|
+
"team consumes without re-deriving them. Not: logs, dashboards of your own "
|
|
444
|
+
"activity, or output a human always checks before it is used."
|
|
445
|
+
),
|
|
446
|
+
validate="",
|
|
447
|
+
decides="the level this tool recommends: full exists for numbers or AI output that others treat as fact",
|
|
448
|
+
wrong="a recommendation pointing at the wrong level; the level itself is still yours to choose",
|
|
449
|
+
),
|
|
450
|
+
FieldSpec(
|
|
451
|
+
id="data_classification",
|
|
452
|
+
label="Data classification",
|
|
453
|
+
kind="choice",
|
|
454
|
+
# Short enough to read beside the label column at 80 columns (`F67`).
|
|
455
|
+
choices=(
|
|
456
|
+
("public", "public - no restriction"),
|
|
457
|
+
("internal", "internal - not for external release"),
|
|
458
|
+
("confidential", "confidential - within the organisation"),
|
|
459
|
+
("restricted", "restricted - the strictest tier"),
|
|
460
|
+
),
|
|
461
|
+
help="the most sensitive data this repository handles, in the organisation's own tiers",
|
|
462
|
+
decides="what the profile states about the data here; the repository classification is proposed from it",
|
|
463
|
+
wrong="a profile that understates or overstates the data's sensitivity to whoever relies on it",
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ---------------------------------------------------------------------------------------------
|
|
470
|
+
# Section 4 — conformance level
|
|
471
|
+
# ---------------------------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def detected_signals(repo: Path) -> tuple[list[str], list[str]]:
|
|
475
|
+
"""(present, absent) - shown on the level screen so the honest cost of a level is visible
|
|
476
|
+
before it is chosen, never to choose one. `DR-35`: the maintainer's own instruction was that
|
|
477
|
+
the three levels stay neutral options because they are repository-dependent."""
|
|
478
|
+
present: list[str] = []
|
|
479
|
+
absent: list[str] = []
|
|
480
|
+
|
|
481
|
+
ci_workflows = detect.detect_ci_workflows(repo)
|
|
482
|
+
if ci_workflows:
|
|
483
|
+
# Named, but not all of them: a repository with a dozen workflows would push the level
|
|
484
|
+
# options off the screen, and the point of this line is the fact that CI exists, not an
|
|
485
|
+
# inventory of it.
|
|
486
|
+
shown = ci_workflows[0]
|
|
487
|
+
extra = len(ci_workflows) - 1
|
|
488
|
+
present.append(
|
|
489
|
+
f"a CI workflow ({shown}" + (f" and {extra} more)" if extra else ")")
|
|
490
|
+
)
|
|
491
|
+
else:
|
|
492
|
+
absent.append("a CI workflow")
|
|
493
|
+
|
|
494
|
+
decisions = detect.detect_decisions_folder(repo)
|
|
495
|
+
if decisions:
|
|
496
|
+
present.append(f"a decisions/ADR folder ({decisions})")
|
|
497
|
+
else:
|
|
498
|
+
absent.append("a decisions/ADR folder")
|
|
499
|
+
|
|
500
|
+
changelog = detect.detect_changelog(repo)
|
|
501
|
+
if changelog:
|
|
502
|
+
present.append(f"a CHANGELOG ({changelog})")
|
|
503
|
+
else:
|
|
504
|
+
absent.append("a CHANGELOG")
|
|
505
|
+
|
|
506
|
+
return present, absent
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def level_choices(builds_ui: bool) -> tuple[tuple[str, str], ...]:
|
|
510
|
+
"""(value, label) per level, with counts computed from the catalogue rather than written down
|
|
511
|
+
- the same derivation `catalogue.level_summary` already does for the old flow."""
|
|
512
|
+
out = []
|
|
513
|
+
for level in ("essential", "standard", "full"):
|
|
514
|
+
summary = catalogue.level_summary(level, builds_ui)
|
|
515
|
+
out.append(
|
|
516
|
+
(
|
|
517
|
+
level,
|
|
518
|
+
f"{level} - {summary['gate_count']} gate(s), {summary['control_count']} control(s). "
|
|
519
|
+
f"{summary['blurb']}",
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
return tuple(out)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def level_controls(level: str) -> tuple[str, ...]:
|
|
526
|
+
"""The concrete controls a level names - the mockup's amber meta line on the highlighted
|
|
527
|
+
option, which is the thing that makes the choice legible rather than a number."""
|
|
528
|
+
return tuple(sorted(catalogue.CONFORMANCE_LEVELS[level]))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def recommended_level(risk: dict) -> tuple[str, str]:
|
|
532
|
+
"""The level the adopter's own answers point at, and the sentence explaining why.
|
|
533
|
+
|
|
534
|
+
**This is a recommendation and never an answer.** `core/CONFORMANCE_LEVELS.md:214-216` is
|
|
535
|
+
explicit: *"The level is a human decision recorded in the adoption decision record. It is not
|
|
536
|
+
derived automatically from the stack, the repository size, or the data classification, because
|
|
537
|
+
materiality depends on intended use and reliance, which only the application owner can judge."*
|
|
538
|
+
So this is shown beside the choice with its reasoning, the choice is still made, and nothing is
|
|
539
|
+
pre-selected on the adopter's behalf.
|
|
540
|
+
|
|
541
|
+
The mapping is not a heuristic of ours - it is `catalogue.LEVEL_BLURBS` read back as questions.
|
|
542
|
+
"""
|
|
543
|
+
outside = bool(risk.get("relied_on_outside_team"))
|
|
544
|
+
material = bool(risk.get("material_quantitative_output"))
|
|
545
|
+
if material:
|
|
546
|
+
return "full", (
|
|
547
|
+
"you said this produces numbers or AI output that others treat as fact, which is what "
|
|
548
|
+
"full exists for"
|
|
549
|
+
)
|
|
550
|
+
if outside:
|
|
551
|
+
return "standard", "you said someone outside your team relies on what this produces"
|
|
552
|
+
return "essential", (
|
|
553
|
+
"you said nobody outside your team relies on this output, and that it produces nothing "
|
|
554
|
+
"others treat as fact"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def level_plan(
|
|
559
|
+
repo: Path,
|
|
560
|
+
*,
|
|
561
|
+
builds_ui: bool,
|
|
562
|
+
mode: str,
|
|
563
|
+
recap: tuple[str, ...] = (),
|
|
564
|
+
risk: dict | None = None,
|
|
565
|
+
) -> SectionPlan:
|
|
566
|
+
present, absent = detected_signals(repo)
|
|
567
|
+
notes = []
|
|
568
|
+
if risk:
|
|
569
|
+
level, because = recommended_level(risk)
|
|
570
|
+
# Two rows at 80 columns, not four: `F67` found the recap and this note filling the
|
|
571
|
+
# frame with the three options below the fold.
|
|
572
|
+
notes.append(
|
|
573
|
+
f"From your answers, {level} looks right: {because}. "
|
|
574
|
+
"A recommendation, not a decision; the choice is yours."
|
|
575
|
+
)
|
|
576
|
+
if present:
|
|
577
|
+
notes.append(f"You appear to have: {'; '.join(present)}.")
|
|
578
|
+
if absent:
|
|
579
|
+
notes.append(f"You don't yet have: {'; '.join(absent)}.")
|
|
580
|
+
|
|
581
|
+
return SectionPlan(
|
|
582
|
+
name="level",
|
|
583
|
+
title="Conformance level",
|
|
584
|
+
intro=explanations.LEVEL_CHOICE[mode],
|
|
585
|
+
recap=recap,
|
|
586
|
+
notes=tuple(notes),
|
|
587
|
+
fields=(
|
|
588
|
+
FieldSpec(
|
|
589
|
+
id="conformance_level",
|
|
590
|
+
label="Conformance level - a floor, not a ceiling; you may require more than the level asks",
|
|
591
|
+
kind="choice",
|
|
592
|
+
choices=level_choices(builds_ui),
|
|
593
|
+
help="how strict this repository's own rules are; the counts beside each level are computed from the catalogue",
|
|
594
|
+
decides="the floor: which gates and controls the checker requires of this repository from now on",
|
|
595
|
+
wrong="too low leaves what others rely on unguarded; too high fails on gates the repository cannot yet meet",
|
|
596
|
+
),
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ---------------------------------------------------------------------------------------------
|
|
602
|
+
# Section 5 — controls
|
|
603
|
+
# ---------------------------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _first_line(text: str, limit: int = 72) -> str:
|
|
607
|
+
"""One line of an explanation, for a tick-box label that has to fit on a row.
|
|
608
|
+
|
|
609
|
+
The full explanation stays available as the field's help; this is the label beside the box, and
|
|
610
|
+
a label that wraps to four lines makes a list of nine unreadable.
|
|
611
|
+
"""
|
|
612
|
+
line = text.strip().split("\n", 1)[0].strip()
|
|
613
|
+
if len(line) <= limit:
|
|
614
|
+
return line
|
|
615
|
+
return line[: limit - 1].rsplit(" ", 1)[0] + "…"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _implementation_reference_field(
|
|
619
|
+
control_id: str, *, at_floor: bool, found: discover.Discovered
|
|
620
|
+
) -> FieldSpec | None:
|
|
621
|
+
"""Pattern A/B/C controls each name where they are implemented, and the three patterns want
|
|
622
|
+
different things named - a file, a CI step, a register directory (`DR-25`, `DR-26`). Pattern D
|
|
623
|
+
controls name nothing."""
|
|
624
|
+
labels = {
|
|
625
|
+
**{c: ("File that implements", "a lock file, a findings register - whatever this control is actually checked against") for c in catalogue.PATTERN_A_CONTROLS},
|
|
626
|
+
**{c: ("CI step name that implements", "the exact step name in your workflow file - matched by name, not by job") for c in catalogue.PATTERN_B_CONTROLS},
|
|
627
|
+
**{c: ("Register directory for", "a directory of records validating against this control's schema - empty is a valid, honest start") for c in catalogue.PATTERN_C_CONTROLS},
|
|
628
|
+
}
|
|
629
|
+
if control_id not in labels:
|
|
630
|
+
return None
|
|
631
|
+
prefix, help_text = labels[control_id]
|
|
632
|
+
decides = {
|
|
633
|
+
**{c: PATTERN_A_DECIDES for c in catalogue.PATTERN_A_CONTROLS},
|
|
634
|
+
**{c: PATTERN_B_DECIDES for c in catalogue.PATTERN_B_CONTROLS},
|
|
635
|
+
**{c: PATTERN_C_DECIDES for c in catalogue.PATTERN_C_CONTROLS},
|
|
636
|
+
}[control_id]
|
|
637
|
+
# Each pattern names a different kind of thing, and each is discoverable. The CI step in
|
|
638
|
+
# particular is why this packet exists: "no example for CI step name... not sure how to proceed
|
|
639
|
+
# on that one" is unanswerable from a canned example and trivial from the adopter's own
|
|
640
|
+
# workflow files.
|
|
641
|
+
# `DR-48`: what the checker will ask of the value is asked at the field - a tracked path for
|
|
642
|
+
# patterns A and C (`SP051`, `SP055`), a step name that exists for pattern B (`SP053`).
|
|
643
|
+
if control_id in catalogue.PATTERN_A_CONTROLS:
|
|
644
|
+
# A lock file for `dependency_lock`; for `assurance_findings`, a document whose name says
|
|
645
|
+
# so. `lock_files or artefacts` offered `requirements.txt` as a findings register and, on
|
|
646
|
+
# a repository with nothing else, whatever file it held first - the `F40` shape.
|
|
647
|
+
if control_id == "dependency_lock":
|
|
648
|
+
candidates = found.lock_files
|
|
649
|
+
context = "lock"
|
|
650
|
+
else:
|
|
651
|
+
# `F88` / `DR-54` (2): every artefact, the name matches first - as the gates do -
|
|
652
|
+
# rather than only the matches, which left a register named otherwise unofferable.
|
|
653
|
+
hit = [a for a in found.artefacts if any(w in a.lower() for w in FINDINGS_WORDS)]
|
|
654
|
+
candidates = tuple(hit + [a for a in found.artefacts if a not in hit])
|
|
655
|
+
context = "artefact"
|
|
656
|
+
validate = "tracked_path"
|
|
657
|
+
elif control_id in catalogue.PATTERN_B_CONTROLS:
|
|
658
|
+
candidates = found.ci_steps
|
|
659
|
+
validate = "ci_step"
|
|
660
|
+
context = "step"
|
|
661
|
+
else:
|
|
662
|
+
# `F93`: the directories that fit this control first, then every other register directory.
|
|
663
|
+
fit = list(found.register_fit.get(control_id, ()))
|
|
664
|
+
candidates = tuple(fit + [d for d in found.register_dirs if d not in fit])
|
|
665
|
+
validate = "tracked_path"
|
|
666
|
+
context = "register"
|
|
667
|
+
return _from_candidates(
|
|
668
|
+
id=f"{control_id}.implementation_reference",
|
|
669
|
+
label=f"{prefix} {control_id}",
|
|
670
|
+
help=help_text,
|
|
671
|
+
candidates=candidates,
|
|
672
|
+
depends_on=None if at_floor else ("above_floor", (control_id,)),
|
|
673
|
+
validate=validate,
|
|
674
|
+
context=context,
|
|
675
|
+
decides=decides,
|
|
676
|
+
wrong=CONTROL_WRONG,
|
|
677
|
+
seed=found.free_control_seeds.get(control_id, ""),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def controls_plan(
|
|
682
|
+
*, level: str, mode: str, found: discover.Discovered | None = None, scanner: str = discover.DEFAULT_SCANNER
|
|
683
|
+
) -> SectionPlan:
|
|
684
|
+
"""All nine controls are shown, not just the level's floor.
|
|
685
|
+
|
|
686
|
+
The old flow asked the floor's controls and then offered a "declare another?" loop, which is a
|
|
687
|
+
poor fit for a screen and a worse fit for a plan - an unbounded loop has no field ids to join
|
|
688
|
+
against. Showing all nine, with the floor's own pre-marked and locked, keeps the capability a
|
|
689
|
+
level being "a floor, not a ceiling" requires while making the section a fixed, describable
|
|
690
|
+
shape. Nothing above the floor is declared unless a human turns it on.
|
|
691
|
+
"""
|
|
692
|
+
found = found or discover.Discovered()
|
|
693
|
+
required = catalogue.CONFORMANCE_LEVELS[level]
|
|
694
|
+
fields: list[FieldSpec] = []
|
|
695
|
+
|
|
696
|
+
for control_id in BASELINE_CONTROL_IDS:
|
|
697
|
+
fields.append(
|
|
698
|
+
FieldSpec(
|
|
699
|
+
id=f"{control_id}.rationale",
|
|
700
|
+
label=f"Why does {control_id} apply here?",
|
|
701
|
+
kind="textarea",
|
|
702
|
+
help=f"{explanations.explain(control_id, mode)}\n\n{TEMPLATE_PLACEHOLDER_HELP}",
|
|
703
|
+
default=example_answers.rationale_example(control_id),
|
|
704
|
+
decides=RATIONALE_DECIDES,
|
|
705
|
+
wrong=RATIONALE_WRONG,
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
fields.append(
|
|
710
|
+
FieldSpec(
|
|
711
|
+
id="scanner.name",
|
|
712
|
+
label="Secret scanner",
|
|
713
|
+
default=scanner,
|
|
714
|
+
help="the tool that scans for secrets before they're committed",
|
|
715
|
+
decides="the word the checker looks for in the workflow file, and requires a step to run (SP046)",
|
|
716
|
+
wrong="the checker looks for the wrong word and reports the scanner absent",
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
# `F83` / `DR-51` (5): the candidates are the workflows where a step RUNS the scanner, and the
|
|
720
|
+
# field refuses any other file with the checker's own words.
|
|
721
|
+
fields.append(
|
|
722
|
+
_from_candidates(
|
|
723
|
+
id="scanner.wired_in",
|
|
724
|
+
label="Scanner workflow file",
|
|
725
|
+
help=f"the workflow where a step runs {scanner}; that step must be able to fail the build",
|
|
726
|
+
candidates=found.scanner_workflows,
|
|
727
|
+
validate=f"scanner_workflow:{scanner}",
|
|
728
|
+
context=f"scanner:{scanner}",
|
|
729
|
+
decides=SCANNER_WIRED_DECIDES,
|
|
730
|
+
wrong=SCANNER_WIRED_WRONG,
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# `ACT-032`: ONE opt-in, not one tick box per control. A level is a floor, and choosing it has
|
|
735
|
+
# already declined everything above it - so asking the adopter to decline each one again, in
|
|
736
|
+
# turn, is asking them to restate an answer they have given. For a solo maintainer at
|
|
737
|
+
# `essential` that was eight separate questions out of fifteen in this section, and
|
|
738
|
+
# `defaults.propose_controls` computed `False` for every one of them without asking at all.
|
|
739
|
+
#
|
|
740
|
+
# Above the floor stays a real choice, because a level is a floor and not a ceiling; it is now
|
|
741
|
+
# a single list to tick through rather than a sequence of screens to say no to.
|
|
742
|
+
# `F97` / `DR-59`: a control the checker verifies through a gate this level does not declare is
|
|
743
|
+
# withheld from the list, and the help says why. `documentation_authority` at `essential` was
|
|
744
|
+
# offered, and a profile declaring it failed `SP052` on its first check: the level declares no
|
|
745
|
+
# `authority_map` gate, so the control would be checked against nothing.
|
|
746
|
+
withheld = {
|
|
747
|
+
control_id: gate_id
|
|
748
|
+
for control_id, gate_id in WITHHELD_ABOVE_FLOOR.items()
|
|
749
|
+
if control_id not in required and gate_id not in catalogue.LEVEL_REQUIRED_GATES[level]
|
|
750
|
+
}
|
|
751
|
+
above_floor = [c for c in sorted(catalogue.CONFORMANCE_LEVELS["full"]) if c not in required and c not in withheld]
|
|
752
|
+
if above_floor:
|
|
753
|
+
withheld_note = "".join(
|
|
754
|
+
f" {control_id} is not offered at {level}: the checker verifies it through the {gate_id} gate, "
|
|
755
|
+
f"which {level} does not declare (SP052); choose a level that requires both."
|
|
756
|
+
for control_id, gate_id in sorted(withheld.items())
|
|
757
|
+
)
|
|
758
|
+
fields.append(
|
|
759
|
+
FieldSpec(
|
|
760
|
+
id="above_floor",
|
|
761
|
+
label=f"Declare any control beyond the {level} floor? ({len(required)} already required)",
|
|
762
|
+
kind="multiselect",
|
|
763
|
+
help=(
|
|
764
|
+
f"{level} does not require these. Tick any you want this repository held to "
|
|
765
|
+
"anyway - a level is a floor, not a ceiling. Leaving them all unticked is a "
|
|
766
|
+
"complete answer." + withheld_note
|
|
767
|
+
),
|
|
768
|
+
# `F67`: a row is the id and a short cue that fits 60 columns; the full explanation
|
|
769
|
+
# of the highlighted row is shown beside the list (`choice_help`).
|
|
770
|
+
choices=tuple((c, f"{c} - {explanations.CUES[c]}") for c in above_floor),
|
|
771
|
+
choice_help=tuple((c, explanations.explain(c, mode)) for c in above_floor),
|
|
772
|
+
default="",
|
|
773
|
+
validate="",
|
|
774
|
+
decides="which controls beyond the floor this repository is held to; each ticked one asks for its rationale and reference and is then checked",
|
|
775
|
+
wrong="a ticked control with nothing behind it fails the checker; an unticked one is simply not claimed",
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
for control_id in sorted(catalogue.CONFORMANCE_LEVELS["full"]):
|
|
780
|
+
at_floor = control_id in required
|
|
781
|
+
fields.append(
|
|
782
|
+
FieldSpec(
|
|
783
|
+
id=f"{control_id}.rationale",
|
|
784
|
+
label=f"Why does {control_id} apply here?",
|
|
785
|
+
kind="textarea",
|
|
786
|
+
help=(
|
|
787
|
+
f"{explanations.explain(control_id, mode)}\n\n{TEMPLATE_PLACEHOLDER_HELP}"
|
|
788
|
+
if at_floor
|
|
789
|
+
else TEMPLATE_PLACEHOLDER_HELP
|
|
790
|
+
),
|
|
791
|
+
default=example_answers.rationale_example(control_id),
|
|
792
|
+
depends_on=None if at_floor else ("above_floor", (control_id,)),
|
|
793
|
+
decides=RATIONALE_DECIDES,
|
|
794
|
+
wrong=RATIONALE_WRONG,
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
reference = _implementation_reference_field(control_id, at_floor=at_floor, found=found)
|
|
798
|
+
if reference is not None:
|
|
799
|
+
fields.append(reference)
|
|
800
|
+
|
|
801
|
+
return SectionPlan(
|
|
802
|
+
name="controls",
|
|
803
|
+
title=f"Controls, floor: {level}",
|
|
804
|
+
intro=(
|
|
805
|
+
"Three baseline controls apply at every level and cannot be excluded, deferred or "
|
|
806
|
+
"omitted - but why each applies here is yours to state, not ours to assume. Everything "
|
|
807
|
+
f"the {level} floor requires is already included; anything beyond it is one optional "
|
|
808
|
+
"list at the end, because a level is a floor and never a ceiling."
|
|
809
|
+
),
|
|
810
|
+
fields=tuple(fields),
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def locked_controls(level: str) -> set[str]:
|
|
815
|
+
"""Controls the level requires - shown as required, not offered as a choice, exactly as a
|
|
816
|
+
level-mandatory gate is. `tui/` reads this to decide what to lock."""
|
|
817
|
+
return set(catalogue.CONFORMANCE_LEVELS[level])
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
# ---------------------------------------------------------------------------------------------
|
|
821
|
+
# Section 6 — prerequisite gates
|
|
822
|
+
# ---------------------------------------------------------------------------------------------
|
|
823
|
+
|
|
824
|
+
def _from_candidates(
|
|
825
|
+
*, id: str, label: str, help: str, candidates: tuple[str, ...], depends_on=None,
|
|
826
|
+
validate: str = "nonempty", context: str = "", decides: str = "", wrong: str = "", seed: str = "",
|
|
827
|
+
) -> FieldSpec:
|
|
828
|
+
"""A field answered by picking, when there is anything to pick from.
|
|
829
|
+
|
|
830
|
+
`DR-38`'s rule: never offer something that isn't there. So when discovery found nothing - no
|
|
831
|
+
git, an unusual layout - this degrades to the plain text field it always was, rather than
|
|
832
|
+
presenting an empty dropdown that cannot be answered. `DR-54` (1): where a seed's path is
|
|
833
|
+
free, the first row offers to create it, and the field is a dropdown even with nothing else
|
|
834
|
+
to pick from - "create it" is something that is there.
|
|
835
|
+
"""
|
|
836
|
+
# `F75`: the cap is here, per field, after the caller has ranked for the field at hand -
|
|
837
|
+
# never on the scan.
|
|
838
|
+
shown = tuple(candidates)[: discover.SHOWN]
|
|
839
|
+
choices = tuple((c, c) for c in shown)
|
|
840
|
+
if seed:
|
|
841
|
+
# Short enough for the dropdown's row at 80 columns, path included.
|
|
842
|
+
choices = ((seed, f"create it: {seed}"),) + choices
|
|
843
|
+
help = (
|
|
844
|
+
f"{help}. The first row creates one for you, from a complete and true seed" if shown
|
|
845
|
+
else f"nothing in this repository fits; the first row creates {seed} for you, from a complete and true seed"
|
|
846
|
+
)
|
|
847
|
+
return FieldSpec(
|
|
848
|
+
id=id,
|
|
849
|
+
label=label,
|
|
850
|
+
kind="select" if choices else "text",
|
|
851
|
+
help=help,
|
|
852
|
+
choices=choices,
|
|
853
|
+
suggestions=shown,
|
|
854
|
+
validate=validate,
|
|
855
|
+
depends_on=depends_on,
|
|
856
|
+
context=context,
|
|
857
|
+
decides=decides,
|
|
858
|
+
wrong=wrong,
|
|
859
|
+
seed=seed,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
_GATE_STATUS_CHOICES = (
|
|
864
|
+
("required", "required - a precondition must exist before the gated paths change"),
|
|
865
|
+
("deferred", "deferred - not yet, with an owner and a date"),
|
|
866
|
+
("not_applicable", "not applicable - with a stated reason"),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _gate_fields(
|
|
871
|
+
gate_id: str, *, mandatory: bool, auto_status: str, found: discover.Discovered
|
|
872
|
+
) -> tuple[FieldSpec, ...]:
|
|
873
|
+
fields: list[FieldSpec] = []
|
|
874
|
+
|
|
875
|
+
if not mandatory and not auto_status:
|
|
876
|
+
fields.append(
|
|
877
|
+
FieldSpec(
|
|
878
|
+
id="status",
|
|
879
|
+
label=gate_id,
|
|
880
|
+
kind="choice",
|
|
881
|
+
choices=_GATE_STATUS_CHOICES,
|
|
882
|
+
# Short on purpose: at 80x24 this sits under three radio rows and a three-row
|
|
883
|
+
# gate description, and a ten-row help pushed itself below the fold.
|
|
884
|
+
help="r, d or n on this row; the three meanings are below",
|
|
885
|
+
decides=(
|
|
886
|
+
"whether the checker audits this gate. required: audited from effective_from; "
|
|
887
|
+
"deferred: not until revisit_by, failing after it; not applicable: never, with the reason written"
|
|
888
|
+
),
|
|
889
|
+
wrong="required without the practice fails every gated commit; not applicable where it applies hides the risk",
|
|
890
|
+
)
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
required_when = ("required",) if not mandatory else ("required",)
|
|
894
|
+
fields += [
|
|
895
|
+
# `SP032` requires this artefact to exist, be non-empty and carry no placeholder - so a
|
|
896
|
+
# value typed from memory is a profile that fails its own checker. Picking from what is
|
|
897
|
+
# actually in the repository removes that whole class of answer.
|
|
898
|
+
# `F44`. Ranking orders; it never establishes that anything is right. `rank_for_gate`
|
|
899
|
+
# returns matches first and then everything else, so a gate nothing matches offered twelve
|
|
900
|
+
# unrelated files under "Choose precondition artefact (12 found)" - a list that says one of
|
|
901
|
+
# these is the answer. `F40` fixed the same mistake in the proposal; this is the offer.
|
|
902
|
+
# Nothing is hidden - `DR-38`'s rule stands, the adopter still chooses from the whole list -
|
|
903
|
+
# but the help says whether any of it actually matched this gate.
|
|
904
|
+
_from_candidates(
|
|
905
|
+
id="artefact",
|
|
906
|
+
label="Precondition artefact",
|
|
907
|
+
help=(
|
|
908
|
+
"what must exist before the gated paths may change"
|
|
909
|
+
if discover.matched_for_gate(found.artefacts, gate_id)
|
|
910
|
+
else "nothing in this repository matches this gate"
|
|
911
|
+
+ (" - these are simply the files found here" if found.artefacts else "")
|
|
912
|
+
),
|
|
913
|
+
candidates=tuple(discover.rank_for_gate(found.artefacts, gate_id)),
|
|
914
|
+
depends_on=None if mandatory else ("status", required_when),
|
|
915
|
+
validate="tracked_path",
|
|
916
|
+
context=f"gate:{gate_id}",
|
|
917
|
+
decides=ARTEFACT_DECIDES,
|
|
918
|
+
wrong=ARTEFACT_WRONG,
|
|
919
|
+
seed=found.free_seeds.get(gate_id, ""),
|
|
920
|
+
),
|
|
921
|
+
# `F51`: **`effective_from` is asked, and this is the one of the four that came back.**
|
|
922
|
+
# `org/RELEASE_PLAN.md` names it: *"what `effective_from` should read ... is a human
|
|
923
|
+
# decision the wizard elicits and records verbatim, never one it makes on the human's
|
|
924
|
+
# behalf."* `ACT-032` derived it as a consequence rather than a judgement and did not amend
|
|
925
|
+
# the rule; a cross-provider reviewer found the contradiction.
|
|
926
|
+
#
|
|
927
|
+
# The safety argument is the stronger one. `SP033` refuses a future value and `SP034`
|
|
928
|
+
# refuses moving one forward, so a human's answer can only ever WIDEN or equal the audit
|
|
929
|
+
# window. Deriving "now" silently picked the narrowest value the rules permit, on the field
|
|
930
|
+
# that decides how much history the gate audit examines.
|
|
931
|
+
FieldSpec(
|
|
932
|
+
id="effective_from",
|
|
933
|
+
label="Effective from",
|
|
934
|
+
help=(
|
|
935
|
+
"YYYY-MM-DD, or a full instant. History before this is out of scope for the audit, "
|
|
936
|
+
"so an earlier value examines MORE of your history, never less"
|
|
937
|
+
),
|
|
938
|
+
default=_dt.date.today().isoformat(),
|
|
939
|
+
validate="effective_from",
|
|
940
|
+
depends_on=None if mandatory else ("status", required_when),
|
|
941
|
+
decides="how far back the audit looks: commits before this date are out of scope (SP033 to SP035)",
|
|
942
|
+
wrong="later than the artefact hides history the gate should have covered; earlier than it fails on history the artefact did not precede",
|
|
943
|
+
),
|
|
944
|
+
# `ACT-032`: `precondition_description`, `gated_description` and `enforcement` remain
|
|
945
|
+
# DERIVED in `sections.build_gate`. Each is
|
|
946
|
+
# a consequence of an answer already given, not a judgement of its own:
|
|
947
|
+
#
|
|
948
|
+
# - both descriptions restate what the gate is and which paths it covers. The framework
|
|
949
|
+
# already writes the first, in `catalogue.GATE_CATALOGUE`, and uses that same sentence
|
|
950
|
+
# to describe the gate on this very screen - so an adopter was being asked to
|
|
951
|
+
# paraphrase text sitting directly above the box;
|
|
952
|
+
# - `effective_from` is today; a gate cannot bind before it is declared, and the field
|
|
953
|
+
# was already defaulted to today with no honest reason to choose otherwise;
|
|
954
|
+
# - `enforcement` is a fixed schema enum, and `history_audit` + `review` are exactly the
|
|
955
|
+
# two that need no tooling an adopter may not have. It was already the field's default
|
|
956
|
+
# AND what `defaults.py` computed, so it was asked without ever being a live question.
|
|
957
|
+
#
|
|
958
|
+
# This is the borderline one: HOW a gate is enforced is a real decision for a repository
|
|
959
|
+
# with CI. It is derived rather than asked because the safe pair is the honest starting
|
|
960
|
+
# point, and the written profile is a file the adopter edits afterwards - which is what the
|
|
961
|
+
# run's closing message already tells them to do.
|
|
962
|
+
# A pathspec, not a path: `src/**` never has to exist as a file, so this stays typeable
|
|
963
|
+
# and offers the repository's real top-level directories as inline completions instead of
|
|
964
|
+
# constraining to them.
|
|
965
|
+
FieldSpec(
|
|
966
|
+
id="paths",
|
|
967
|
+
label="Gated paths",
|
|
968
|
+
help="a git pathspec, e.g. src/** - what may not proceed until the artefact exists",
|
|
969
|
+
suggestions=found.paths,
|
|
970
|
+
depends_on=None if mandatory else ("status", required_when),
|
|
971
|
+
decides="which changes the gate applies to: every commit touching these paths after effective_from must come after the artefact",
|
|
972
|
+
wrong="too narrow and the gate guards nothing; too wide and unrelated commits fail the audit",
|
|
973
|
+
),
|
|
974
|
+
]
|
|
975
|
+
|
|
976
|
+
if mandatory:
|
|
977
|
+
return tuple(fields)
|
|
978
|
+
|
|
979
|
+
if auto_status == "not_applicable":
|
|
980
|
+
# `builds_user_interface: false`, given earlier, already settles the status. The RATIONALE
|
|
981
|
+
# is still asked - `F32`/`ACT-022` found this branch writing a fixed string with no prompt
|
|
982
|
+
# call at all, and the fix was to ask for it with the old text offered as a default.
|
|
983
|
+
return (
|
|
984
|
+
FieldSpec(
|
|
985
|
+
id="rationale",
|
|
986
|
+
label=f"Rationale for {gate_id} being not applicable",
|
|
987
|
+
kind="textarea",
|
|
988
|
+
default="This repository has no user interface.",
|
|
989
|
+
help="why this interface gate does not apply; the answer above already settled that it does not",
|
|
990
|
+
decides="why the profile says this gate does not apply; read by reviewers, never by the checker",
|
|
991
|
+
wrong=RATIONALE_WRONG,
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
fields += [
|
|
996
|
+
FieldSpec(
|
|
997
|
+
id="owner",
|
|
998
|
+
label="Owner",
|
|
999
|
+
help="who owns the deferral - a named human",
|
|
1000
|
+
depends_on=("status", ("deferred",)),
|
|
1001
|
+
decides="who the deferral is filed under, and who is asked when revisit_by arrives",
|
|
1002
|
+
wrong="a deferral nobody owns is never revisited",
|
|
1003
|
+
),
|
|
1004
|
+
FieldSpec(
|
|
1005
|
+
id="revisit_by",
|
|
1006
|
+
label="Revisit by",
|
|
1007
|
+
help="YYYY-MM-DD. The checker fails once this date passes",
|
|
1008
|
+
validate="revisit_by",
|
|
1009
|
+
depends_on=("status", ("deferred",)),
|
|
1010
|
+
decides="the date the checker fails this gate unless it has been decided by then (SP054)",
|
|
1011
|
+
wrong="too far and the deferral is a permanent exemption; too near and the check fails before the work is done",
|
|
1012
|
+
),
|
|
1013
|
+
FieldSpec(
|
|
1014
|
+
id="rationale",
|
|
1015
|
+
label="Why this status",
|
|
1016
|
+
kind="textarea",
|
|
1017
|
+
help="why this gate is deferred or does not apply here, in your own words",
|
|
1018
|
+
default=example_answers.rationale_example(gate_id),
|
|
1019
|
+
depends_on=("status", ("deferred", "not_applicable")),
|
|
1020
|
+
decides="why the profile says this gate is deferred or does not apply; read by reviewers, never by the checker",
|
|
1021
|
+
wrong=RATIONALE_WRONG,
|
|
1022
|
+
),
|
|
1023
|
+
]
|
|
1024
|
+
return tuple(fields)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def gate_plan(
|
|
1028
|
+
*, level: str, builds_ui: bool, mode: str, found: discover.Discovered | None = None
|
|
1029
|
+
) -> tuple[GateSpec, ...]:
|
|
1030
|
+
"""The gates this run actually asks about, in `core/PREREQUISITE_GATES.md`'s catalogue order.
|
|
1031
|
+
|
|
1032
|
+
Reproduces `sections.ask_gates`'s applicability rules exactly, now as data:
|
|
1033
|
+
|
|
1034
|
+
- the level's floor is `required` and not a free choice;
|
|
1035
|
+
- at `standard`/`full` with a user interface, the four `DESIGN_GATES` join that floor;
|
|
1036
|
+
- at `standard`/`full` without one, those four are settled `not_applicable` and only their
|
|
1037
|
+
rationale is asked;
|
|
1038
|
+
- at `essential`, only `work_registration` is declared at all, because the checker reads
|
|
1039
|
+
nothing else at that level.
|
|
1040
|
+
"""
|
|
1041
|
+
found = found or discover.Discovered()
|
|
1042
|
+
mandatory = set(catalogue.LEVEL_REQUIRED_GATES[level])
|
|
1043
|
+
full_declaration = level in catalogue.LEVELS_REQUIRING_FULL_DECLARATION
|
|
1044
|
+
if builds_ui and full_declaration:
|
|
1045
|
+
mandatory |= catalogue.DESIGN_GATES
|
|
1046
|
+
|
|
1047
|
+
if not full_declaration:
|
|
1048
|
+
return (
|
|
1049
|
+
GateSpec(
|
|
1050
|
+
id="work_registration",
|
|
1051
|
+
section="Work and decisions",
|
|
1052
|
+
explanation=explanations.explain("work_registration", mode),
|
|
1053
|
+
mandatory=True,
|
|
1054
|
+
fields=_gate_fields("work_registration", mandatory=True, auto_status="", found=found),
|
|
1055
|
+
),
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
specs: list[GateSpec] = []
|
|
1059
|
+
for section_name, gate_ids in catalogue.sectioned_gates():
|
|
1060
|
+
for gate_id in gate_ids:
|
|
1061
|
+
is_design_gate = gate_id in catalogue.DESIGN_GATES
|
|
1062
|
+
auto_status = "not_applicable" if (is_design_gate and not builds_ui) else ""
|
|
1063
|
+
is_mandatory = gate_id in mandatory
|
|
1064
|
+
specs.append(
|
|
1065
|
+
GateSpec(
|
|
1066
|
+
id=gate_id,
|
|
1067
|
+
section=section_name,
|
|
1068
|
+
explanation=explanations.explain(gate_id, mode),
|
|
1069
|
+
mandatory=is_mandatory,
|
|
1070
|
+
auto_status=auto_status,
|
|
1071
|
+
fields=_gate_fields(
|
|
1072
|
+
gate_id, mandatory=is_mandatory, auto_status=auto_status, found=found
|
|
1073
|
+
),
|
|
1074
|
+
)
|
|
1075
|
+
)
|
|
1076
|
+
return tuple(specs)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def gates_plan(
|
|
1080
|
+
*, level: str, builds_ui: bool, mode: str, found: discover.Discovered | None = None
|
|
1081
|
+
) -> SectionPlan:
|
|
1082
|
+
"""The gate catalogue as a `SectionPlan`, so the screen↔plan join test has one rule for every
|
|
1083
|
+
section including this one. Its `fields` are every gate's fields, prefixed by gate id."""
|
|
1084
|
+
specs = gate_plan(level=level, builds_ui=builds_ui, mode=mode, found=found)
|
|
1085
|
+
# `DR-56`: the floor first, as the screen shows it; the profile itself keeps catalogue order
|
|
1086
|
+
# because `sections.build_gates` walks `gate_plan`, not this.
|
|
1087
|
+
specs = tuple(s for s in specs if s.mandatory) + tuple(s for s in specs if not s.mandatory)
|
|
1088
|
+
fields: list[FieldSpec] = []
|
|
1089
|
+
for spec in specs:
|
|
1090
|
+
for f in spec.fields:
|
|
1091
|
+
fields.append(
|
|
1092
|
+
FieldSpec(
|
|
1093
|
+
id=f"{spec.id}.{f.id}",
|
|
1094
|
+
label=f.label,
|
|
1095
|
+
kind=f.kind,
|
|
1096
|
+
help=f.help,
|
|
1097
|
+
default=f.default,
|
|
1098
|
+
choices=f.choices,
|
|
1099
|
+
validate=f.validate,
|
|
1100
|
+
suggestions=f.suggestions,
|
|
1101
|
+
decides=f.decides,
|
|
1102
|
+
wrong=f.wrong,
|
|
1103
|
+
context=f.context,
|
|
1104
|
+
choice_help=f.choice_help,
|
|
1105
|
+
seed=f.seed,
|
|
1106
|
+
depends_on=(
|
|
1107
|
+
(f"{spec.id}.{f.depends_on[0]}", f.depends_on[1])
|
|
1108
|
+
if f.depends_on is not None
|
|
1109
|
+
else None
|
|
1110
|
+
),
|
|
1111
|
+
)
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
total = len(catalogue.GATE_CATALOGUE)
|
|
1115
|
+
intro = (
|
|
1116
|
+
f"At {level}, only work_registration must be declared. The other {total - 1} gates are "
|
|
1117
|
+
"not read by the checker at this level and are skipped."
|
|
1118
|
+
if level not in catalogue.LEVELS_REQUIRING_FULL_DECLARATION
|
|
1119
|
+
else f"All {total} gates must be decided one way or the other at {level}. "
|
|
1120
|
+
"not applicable is a perfectly good answer; silence is not."
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
return SectionPlan(name="gates", title="Prerequisite gates", intro=intro, fields=tuple(fields))
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
# ---------------------------------------------------------------------------------------------
|
|
1127
|
+
# Wrap-up — adoption identity, roles, release route
|
|
1128
|
+
# ---------------------------------------------------------------------------------------------
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def adoption_plan(*, owner: str) -> SectionPlan:
|
|
1132
|
+
return SectionPlan(
|
|
1133
|
+
name="adoption",
|
|
1134
|
+
title="A few closing facts",
|
|
1135
|
+
fields=(
|
|
1136
|
+
FieldSpec(
|
|
1137
|
+
id="review_by",
|
|
1138
|
+
label="Review by",
|
|
1139
|
+
default=(_dt.date.today() + _dt.timedelta(days=180)).isoformat(),
|
|
1140
|
+
help="180 days is the suggested interval; the checker fails once this date passes",
|
|
1141
|
+
validate="review_by",
|
|
1142
|
+
decides="the date the checker fails this profile unless it has been reviewed by then (SP025), at most 400 days out (SP026)",
|
|
1143
|
+
wrong="a profile that fails on a date nobody planned for, or one that is never reviewed",
|
|
1144
|
+
),
|
|
1145
|
+
FieldSpec(
|
|
1146
|
+
id="framework_maintainer",
|
|
1147
|
+
label="Framework maintainer",
|
|
1148
|
+
default=owner,
|
|
1149
|
+
help="the change authority for the standard in this repository - often the same as owner",
|
|
1150
|
+
decides="who may upgrade or change the standard's installation here",
|
|
1151
|
+
wrong="upgrades with nobody accountable for them",
|
|
1152
|
+
),
|
|
1153
|
+
FieldSpec(
|
|
1154
|
+
id="repository_classification", label="Repository classification",
|
|
1155
|
+
help="how this repository is classified in the organisation's own terms; proposed from the data classification",
|
|
1156
|
+
decides="what the profile states about the repository; the checker never reads it",
|
|
1157
|
+
wrong=NOT_CHECKED_WRONG,
|
|
1158
|
+
),
|
|
1159
|
+
FieldSpec(
|
|
1160
|
+
id="decision_record_id",
|
|
1161
|
+
label="Adoption decision record ID",
|
|
1162
|
+
help="if none exists yet, this is the moment to name one - it does not need to be written yet",
|
|
1163
|
+
decides="the record the profile cites for this adoption; the checker requires the id, not the record",
|
|
1164
|
+
wrong="a citation to nothing",
|
|
1165
|
+
),
|
|
1166
|
+
FieldSpec(
|
|
1167
|
+
id="adoption_status",
|
|
1168
|
+
label="Adoption status",
|
|
1169
|
+
kind="choice",
|
|
1170
|
+
choices=(
|
|
1171
|
+
("in_progress", "in_progress"),
|
|
1172
|
+
("complete", "complete"),
|
|
1173
|
+
("blocked", "blocked"),
|
|
1174
|
+
("deferred", "deferred"),
|
|
1175
|
+
),
|
|
1176
|
+
help="where this adoption stands today; blocked and deferred ask why",
|
|
1177
|
+
decides="what the profile says about this adoption's state; nothing else follows from it",
|
|
1178
|
+
wrong="complete before the checker passes is a claim the checker contradicts on its next run",
|
|
1179
|
+
),
|
|
1180
|
+
FieldSpec(
|
|
1181
|
+
id="status_rationale",
|
|
1182
|
+
label="Why this adoption status?",
|
|
1183
|
+
kind="textarea",
|
|
1184
|
+
# `F105` / `DR-63`: `complete` explains itself as `blocked` and `deferred` do.
|
|
1185
|
+
depends_on=("adoption_status", ("blocked", "deferred", "complete")),
|
|
1186
|
+
help="what stands in the way, or what makes it complete, in your own words",
|
|
1187
|
+
decides="why the profile says adoption is complete, blocked or deferred; read by reviewers",
|
|
1188
|
+
wrong="a state with no stated reason",
|
|
1189
|
+
),
|
|
1190
|
+
FieldSpec(
|
|
1191
|
+
id="needs_validator",
|
|
1192
|
+
label="Does independent (Level 3) review apply to this adoption?",
|
|
1193
|
+
kind="bool",
|
|
1194
|
+
validate="",
|
|
1195
|
+
help="whether someone independent of the team validates this adoption",
|
|
1196
|
+
decides="whether an independent validator is named on the profile; the checker never reads it",
|
|
1197
|
+
wrong="a named validator who never validated is a false claim on the record",
|
|
1198
|
+
),
|
|
1199
|
+
FieldSpec(
|
|
1200
|
+
id="independent_validator",
|
|
1201
|
+
label="Independent validator",
|
|
1202
|
+
depends_on=("needs_validator", (True,)),
|
|
1203
|
+
help="who validates independently - a named human or body",
|
|
1204
|
+
decides="who the profile names as the independent validator; not checked",
|
|
1205
|
+
wrong="a named validator who never validated is a false claim on the record",
|
|
1206
|
+
),
|
|
1207
|
+
),
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def wrap_plan() -> SectionPlan:
|
|
1212
|
+
return SectionPlan(
|
|
1213
|
+
name="wrap",
|
|
1214
|
+
title="Roles and release",
|
|
1215
|
+
fields=(
|
|
1216
|
+
FieldSpec(
|
|
1217
|
+
id="human_roles",
|
|
1218
|
+
label="Human roles",
|
|
1219
|
+
kind="textarea",
|
|
1220
|
+
help='e.g. "Maintainer - Jane Doe. Sole change authority."',
|
|
1221
|
+
validate="",
|
|
1222
|
+
decides="the roles the profile records for its reader; the checker never reads them",
|
|
1223
|
+
wrong=NOT_CHECKED_WRONG,
|
|
1224
|
+
),
|
|
1225
|
+
FieldSpec(
|
|
1226
|
+
id="release_route",
|
|
1227
|
+
label="Release route",
|
|
1228
|
+
kind="textarea",
|
|
1229
|
+
help="human and platform release controls, in your own words",
|
|
1230
|
+
decides=RELEASE_ROUTE_DECIDES,
|
|
1231
|
+
wrong=RELEASE_ROUTE_WRONG,
|
|
1232
|
+
),
|
|
1233
|
+
),
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
# ---------------------------------------------------------------------------------------------
|
|
1238
|
+
# The whole run
|
|
1239
|
+
# ---------------------------------------------------------------------------------------------
|
|
1240
|
+
|
|
1241
|
+
# The profile's sections, in the order the builders and the provenance walk read them. This is
|
|
1242
|
+
# the shape of the STATE, not of the screens: `DR-47`'s flow presents `decisions`, `level`, the
|
|
1243
|
+
# gate list and a remainder form (`flow.STAGES`), and scatters their answers into these sections.
|
|
1244
|
+
SECTION_ORDER = (
|
|
1245
|
+
"mode",
|
|
1246
|
+
"identity",
|
|
1247
|
+
"stack",
|
|
1248
|
+
"risk",
|
|
1249
|
+
"level",
|
|
1250
|
+
"controls",
|
|
1251
|
+
"gates",
|
|
1252
|
+
"adoption",
|
|
1253
|
+
"wrap",
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
# The screens that carry a step number, in order. The remainder form and the scaffold offer are
|
|
1257
|
+
# conditional and unnumbered (`F45`).
|
|
1258
|
+
FLOW = ("decisions", "level", "gates")
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def recap_lines(state: dict) -> tuple[str, ...]:
|
|
1262
|
+
"""The mockup's "You told us:" block on the level screen - earlier answers restated so the
|
|
1263
|
+
choice is made in front of its own context rather than from memory."""
|
|
1264
|
+
lines = []
|
|
1265
|
+
risk = state.get("risk") or {}
|
|
1266
|
+
stack = state.get("stack") or {}
|
|
1267
|
+
if risk.get("materiality_definition"):
|
|
1268
|
+
lines.append(str(risk["materiality_definition"]))
|
|
1269
|
+
if "builds_user_interface" in stack:
|
|
1270
|
+
lines.append(
|
|
1271
|
+
"This repository builds a user interface."
|
|
1272
|
+
if stack["builds_user_interface"]
|
|
1273
|
+
else "No user interface."
|
|
1274
|
+
)
|
|
1275
|
+
if risk.get("data_classification"):
|
|
1276
|
+
lines.append(f"Data classification: {risk['data_classification']}.")
|
|
1277
|
+
return tuple(lines)
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def section_plan(
|
|
1281
|
+
name: str, *, repo: Path, state: dict, found: discover.Discovered | None = None
|
|
1282
|
+
) -> SectionPlan:
|
|
1283
|
+
"""The plan for one section, given everything answered before it.
|
|
1284
|
+
|
|
1285
|
+
Sequencing is data-dependent - `level` needs `mode` and `builds_user_interface`, `gates` needs
|
|
1286
|
+
`level` - which is exactly why the app cannot be handed a flat list of screens up front and why
|
|
1287
|
+
this function takes the accumulated state.
|
|
1288
|
+
"""
|
|
1289
|
+
# One scan serves every section. Callers that run the whole interview pass it in; a caller
|
|
1290
|
+
# asking for a single section (a test, a screen in isolation) gets a scan of its own.
|
|
1291
|
+
found = found if found is not None else discover.scan(repo)
|
|
1292
|
+
|
|
1293
|
+
mode = (state.get("mode") or {}).get("mode", "simple")
|
|
1294
|
+
builds_ui = bool((state.get("stack") or {}).get("builds_user_interface", False))
|
|
1295
|
+
level = (state.get("level") or {}).get("conformance_level", "essential")
|
|
1296
|
+
owner = (state.get("identity") or {}).get("owner", "")
|
|
1297
|
+
|
|
1298
|
+
if name == "mode":
|
|
1299
|
+
# Session state, never written: the register of explanation. `DR-47`'s flow has no
|
|
1300
|
+
# screen for it; plain English is the default and the only value the flow sets.
|
|
1301
|
+
return SectionPlan(name="mode", title="Explanations", fields=(
|
|
1302
|
+
FieldSpec(id="mode", label="register", kind="choice",
|
|
1303
|
+
choices=(("simple", "plain English"), ("advanced", "precise technical terms")),
|
|
1304
|
+
help="which register the explanations use", decides="only the wording of the explanations; never written",
|
|
1305
|
+
wrong="nothing: it is session state"),
|
|
1306
|
+
))
|
|
1307
|
+
if name == "identity":
|
|
1308
|
+
return identity_plan()
|
|
1309
|
+
if name == "stack":
|
|
1310
|
+
return stack_plan(repo)
|
|
1311
|
+
if name == "risk":
|
|
1312
|
+
return risk_plan()
|
|
1313
|
+
if name == "level":
|
|
1314
|
+
return level_plan(
|
|
1315
|
+
repo,
|
|
1316
|
+
builds_ui=builds_ui,
|
|
1317
|
+
mode=mode,
|
|
1318
|
+
recap=recap_lines(state),
|
|
1319
|
+
risk=state.get("risk") or {},
|
|
1320
|
+
)
|
|
1321
|
+
if name == "controls":
|
|
1322
|
+
scanner = str((state.get("controls") or {}).get("scanner.name") or discover.DEFAULT_SCANNER)
|
|
1323
|
+
return controls_plan(level=level, mode=mode, found=found, scanner=scanner)
|
|
1324
|
+
if name == "gates":
|
|
1325
|
+
return gates_plan(level=level, builds_ui=builds_ui, mode=mode, found=found)
|
|
1326
|
+
if name == "adoption":
|
|
1327
|
+
return adoption_plan(owner=owner)
|
|
1328
|
+
if name == "wrap":
|
|
1329
|
+
return wrap_plan()
|
|
1330
|
+
raise KeyError(f"unknown section: {name!r}")
|