@arjunkhera/atlas 0.4.45 → 0.4.46
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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +9 -41
- package/door/lib/owner-only.mjs +1 -1
- package/door/lib/tooling.mjs +32 -1
- package/door/lib/upkeep.mjs +14 -82
- package/owner-only.yaml +17 -9
- package/package.json +1 -1
- package/shape/check.mjs +402 -487
- package/shape/releases.json +5 -0
- package/skills/lead/SKILL.md +7 -5
- package/door/lib/kind-drift.mjs +0 -86
package/shape/check.mjs
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// into a temp folder outside the checkout.
|
|
13
13
|
//
|
|
14
14
|
// What it reports: one state per part of the six files. Four states: filled,
|
|
15
|
-
// empty, stale, not
|
|
16
|
-
//
|
|
15
|
+
// empty, stale, not listed. It leads with the count of listed parts that are
|
|
16
|
+
// not filled.
|
|
17
17
|
//
|
|
18
18
|
// It carries the version of the Atlas package it came from, so a copy in a
|
|
19
19
|
// repo says where it came from. It refuses a spec version it cannot read.
|
|
@@ -38,404 +38,116 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
|
|
|
38
38
|
// Whether a copy is current or behind is not decided here. CI has no package
|
|
39
39
|
// and no network. The session verb decides it, from the release list that
|
|
40
40
|
// ships beside this file in the package, shape/releases.json.
|
|
41
|
-
export const CHECK_VERSION =
|
|
41
|
+
export const CHECK_VERSION = 10;
|
|
42
42
|
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
|
-
// The spec.
|
|
45
|
-
//
|
|
44
|
+
// The spec. Spec 3 is a catalog, not a table (roadmap R62). Each repo lists in
|
|
45
|
+
// atlas.yaml the parts it carries, and the check scores those and no others:
|
|
46
|
+
//
|
|
47
|
+
// - `parts:` names the file parts of the catalog by id;
|
|
48
|
+
// - each key of `procedures:` is one procedure part;
|
|
49
|
+
// - each key of `files:` is one facts section part;
|
|
50
|
+
// - `areas:`, when it is there, is the areas part;
|
|
51
|
+
// - atlas.yaml itself is always scored.
|
|
52
|
+
//
|
|
53
|
+
// Every scored part is required. A part the repo does not list says "not
|
|
54
|
+
// listed" and does not count. Spec 1 and 2 held a table of kinds, and the
|
|
55
|
+
// check read the parts from the kind. The tables missed real parts: Atlas,
|
|
56
|
+
// kind library, never had its credentials file read. Check version 10 retired
|
|
57
|
+
// them. A repo on spec 1 or 2 keeps its older copy of the check until
|
|
58
|
+
// `atlas tooling` moves it.
|
|
46
59
|
// ---------------------------------------------------------------------------
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// Deep frozen. `loadSpec` hands every caller the same object, so one caller
|
|
62
|
+
// must not be able to change what the next one reads (acceptance review of
|
|
63
|
+
// batch 14b, finding 9).
|
|
64
|
+
function deepFreeze(value) {
|
|
65
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
66
|
+
Object.freeze(value);
|
|
67
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// How a part is listed: by its id in `parts:`, as a key of `procedures:` or
|
|
73
|
+
// of `files:`, by the `areas:` key, or always.
|
|
74
|
+
export const LISTED_BY = Object.freeze(['parts', 'procedures', 'files', 'areas', 'always']);
|
|
75
|
+
|
|
76
|
+
export const SPEC_3 = deepFreeze({
|
|
77
|
+
spec: 3,
|
|
78
|
+
title: 'The parts a repo lists',
|
|
79
|
+
authority: 'roadmap R62, design docs/design-docs/parts-and-areas/.',
|
|
80
|
+
states: {
|
|
81
|
+
filled: 'The part exists and matches the repo.',
|
|
82
|
+
empty: 'The part is missing, or it holds nothing.',
|
|
83
|
+
stale: 'The part exists but disagrees with the repo.',
|
|
84
|
+
'not listed': 'atlas.yaml does not list the part, so the check does not score it.',
|
|
64
85
|
},
|
|
65
|
-
|
|
86
|
+
files: [
|
|
66
87
|
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"parts": [
|
|
73
|
-
{
|
|
74
|
-
"id": "claude-md",
|
|
75
|
-
"path": "CLAUDE.md",
|
|
76
|
-
"holds": "What the repo is, how to build it, the layout, the modules, and one pointer to each file below. Facts only.",
|
|
77
|
-
"kind": {
|
|
78
|
-
"service": "required",
|
|
79
|
-
"library": "required",
|
|
80
|
-
"schema": "required",
|
|
81
|
-
"plugin": "required",
|
|
82
|
-
"cli": "required"
|
|
83
|
-
},
|
|
84
|
-
"check": "markdown",
|
|
85
|
-
"max_lines": 200
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
"id": "agents-md",
|
|
89
|
-
"path": "AGENTS.md",
|
|
90
|
-
"holds": "Two lines that point at CLAUDE.md.",
|
|
91
|
-
"kind": {
|
|
92
|
-
"service": "required",
|
|
93
|
-
"library": "required",
|
|
94
|
-
"schema": "required",
|
|
95
|
-
"plugin": "required",
|
|
96
|
-
"cli": "required"
|
|
97
|
-
},
|
|
98
|
-
"check": "markdown",
|
|
99
|
-
"max_lines": 200
|
|
100
|
-
}
|
|
101
|
-
]
|
|
88
|
+
id: 'entry-map', number: 1, title: 'Entry map', class: 'instruction', written_by: 'the helper drafts; a person merges',
|
|
89
|
+
parts: [
|
|
90
|
+
{ id: 'claude-md', listed_by: 'parts', path: 'CLAUDE.md', holds: 'What the repo is, how to build it, the layout, the modules, and one pointer to each file below. Facts only.', check: 'markdown', max_lines: 200 },
|
|
91
|
+
{ id: 'agents-md', listed_by: 'parts', path: 'AGENTS.md', holds: 'Two lines that point at CLAUDE.md.', check: 'markdown', max_lines: 200 },
|
|
92
|
+
],
|
|
102
93
|
},
|
|
103
94
|
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"written_by": "the helper; a person merges",
|
|
109
|
-
"parts": [
|
|
110
|
-
{
|
|
111
|
-
"id": "rules",
|
|
112
|
-
"path": ".claude/rules",
|
|
113
|
-
"glob": "*.md",
|
|
114
|
-
"holds": "One constraint that holds under one path only, such as \"migrations are append-only\".",
|
|
115
|
-
"kind": {
|
|
116
|
-
"service": "optional",
|
|
117
|
-
"library": "optional",
|
|
118
|
-
"schema": "optional",
|
|
119
|
-
"plugin": "optional",
|
|
120
|
-
"cli": "optional"
|
|
121
|
-
},
|
|
122
|
-
"check": "path-rules"
|
|
123
|
-
}
|
|
124
|
-
]
|
|
95
|
+
id: 'path-rules', number: 2, title: 'Path rules', class: 'instruction', written_by: 'the helper; a person merges',
|
|
96
|
+
parts: [
|
|
97
|
+
{ id: 'rules', listed_by: 'parts', path: '.claude/rules', glob: '*.md', holds: 'One constraint that holds under one path only, such as "migrations are append-only".', check: 'path-rules' },
|
|
98
|
+
],
|
|
125
99
|
},
|
|
126
100
|
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
{
|
|
134
|
-
"id": "run-locally",
|
|
135
|
-
"procedure": "run-locally",
|
|
136
|
-
"holds": "One skill that brings the stack up on the owner's machine.",
|
|
137
|
-
"kind": {
|
|
138
|
-
"service": "required",
|
|
139
|
-
"library": "not applicable",
|
|
140
|
-
"schema": "not applicable",
|
|
141
|
-
"plugin": "not applicable",
|
|
142
|
-
"cli": "not applicable"
|
|
143
|
-
},
|
|
144
|
-
"check": "procedure"
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
"id": "test",
|
|
148
|
-
"procedure": "test",
|
|
149
|
-
"holds": "One skill that names the suites and runs them.",
|
|
150
|
-
"kind": {
|
|
151
|
-
"service": "required",
|
|
152
|
-
"library": "required",
|
|
153
|
-
"schema": "not applicable",
|
|
154
|
-
"plugin": "required",
|
|
155
|
-
"cli": "required"
|
|
156
|
-
},
|
|
157
|
-
"check": "procedure"
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
"id": "deploy",
|
|
161
|
-
"procedure": "deploy",
|
|
162
|
-
"holds": "One skill that deploys the service.",
|
|
163
|
-
"kind": {
|
|
164
|
-
"service": "required",
|
|
165
|
-
"library": "not applicable",
|
|
166
|
-
"schema": "not applicable",
|
|
167
|
-
"plugin": "not applicable",
|
|
168
|
-
"cli": "not applicable"
|
|
169
|
-
},
|
|
170
|
-
"check": "procedure"
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
"id": "release",
|
|
174
|
-
"procedure": "release",
|
|
175
|
-
"holds": "One skill that cuts a release.",
|
|
176
|
-
"kind": {
|
|
177
|
-
"service": "required",
|
|
178
|
-
"library": "required",
|
|
179
|
-
"schema": "required",
|
|
180
|
-
"plugin": "required",
|
|
181
|
-
"cli": "required"
|
|
182
|
-
},
|
|
183
|
-
"check": "procedure"
|
|
184
|
-
}
|
|
185
|
-
]
|
|
101
|
+
id: 'procedures', number: 3, title: 'Procedures', class: 'instruction', written_by: 'the helper; a person merges',
|
|
102
|
+
// One part for each key of `procedures:`. The name is the repo's own:
|
|
103
|
+
// run-locally, test, deploy, release, or any other.
|
|
104
|
+
parts: [
|
|
105
|
+
{ id: 'procedure', listed_by: 'procedures', holds: 'One skill that carries the procedure: how to run it, step by step.', check: 'procedure' },
|
|
106
|
+
],
|
|
186
107
|
},
|
|
187
108
|
{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
{
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"kind": {
|
|
199
|
-
"service": "required",
|
|
200
|
-
"library": "required",
|
|
201
|
-
"schema": "required",
|
|
202
|
-
"plugin": "required",
|
|
203
|
-
"cli": "required"
|
|
204
|
-
},
|
|
205
|
-
"check": "facts-root",
|
|
206
|
-
"keys": [
|
|
207
|
-
"spec",
|
|
208
|
-
"kind",
|
|
209
|
-
"product",
|
|
210
|
-
"repo"
|
|
211
|
-
]
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
"id": "deploy",
|
|
215
|
-
"section": "deploy",
|
|
216
|
-
"default_path": "atlas/deploy.yaml",
|
|
217
|
-
"holds": "One entry per environment: the platform, the target and the health endpoint.",
|
|
218
|
-
"kind": {
|
|
219
|
-
"service": "required",
|
|
220
|
-
"library": "not applicable",
|
|
221
|
-
"schema": "not applicable",
|
|
222
|
-
"plugin": "not applicable",
|
|
223
|
-
"cli": "not applicable"
|
|
224
|
-
},
|
|
225
|
-
"check": "facts-section",
|
|
226
|
-
"keys": [
|
|
227
|
-
"environments"
|
|
228
|
-
]
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
"id": "observability",
|
|
232
|
-
"section": "observability",
|
|
233
|
-
"default_path": "atlas/observability.yaml",
|
|
234
|
-
"holds": "Where the logs, the metrics, the traces and the dashboards are, per environment.",
|
|
235
|
-
"kind": {
|
|
236
|
-
"service": "required",
|
|
237
|
-
"library": "not applicable",
|
|
238
|
-
"schema": "not applicable",
|
|
239
|
-
"plugin": "not applicable",
|
|
240
|
-
"cli": "not applicable"
|
|
241
|
-
},
|
|
242
|
-
"check": "facts-section",
|
|
243
|
-
"keys": [
|
|
244
|
-
"environments"
|
|
245
|
-
]
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
"id": "credentials",
|
|
249
|
-
"section": "credentials",
|
|
250
|
-
"default_path": "atlas/credentials.yaml",
|
|
251
|
-
"holds": "Each credential by name, with the environment it belongs to. Names only, never a value.",
|
|
252
|
-
"kind": {
|
|
253
|
-
"service": "required",
|
|
254
|
-
"library": "not applicable",
|
|
255
|
-
"schema": "required",
|
|
256
|
-
"plugin": "optional",
|
|
257
|
-
"cli": "optional"
|
|
258
|
-
},
|
|
259
|
-
"check": "facts-credentials",
|
|
260
|
-
"keys": [
|
|
261
|
-
"credentials"
|
|
262
|
-
]
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
"id": "dependencies",
|
|
266
|
-
"section": "dependencies",
|
|
267
|
-
"default_path": "atlas/dependencies.yaml",
|
|
268
|
-
"holds": "The other repos this one needs, by repo id, and what it takes from each.",
|
|
269
|
-
"kind": {
|
|
270
|
-
"service": "required",
|
|
271
|
-
"library": "optional",
|
|
272
|
-
"schema": "optional",
|
|
273
|
-
"plugin": "optional",
|
|
274
|
-
"cli": "optional"
|
|
275
|
-
},
|
|
276
|
-
"check": "facts-section",
|
|
277
|
-
"keys_present": [
|
|
278
|
-
"repos"
|
|
279
|
-
]
|
|
280
|
-
},
|
|
281
|
-
{
|
|
282
|
-
"id": "upkeep",
|
|
283
|
-
"section": "upkeep",
|
|
284
|
-
"default_path": "atlas/upkeep.yaml",
|
|
285
|
-
"holds": "The budget and the kill switch for the upkeep task. Slice L4 fills it.",
|
|
286
|
-
"kind": {
|
|
287
|
-
"service": "optional",
|
|
288
|
-
"library": "optional",
|
|
289
|
-
"schema": "optional",
|
|
290
|
-
"plugin": "optional",
|
|
291
|
-
"cli": "optional"
|
|
292
|
-
},
|
|
293
|
-
"check": "facts-section",
|
|
294
|
-
"keys": [
|
|
295
|
-
"budget"
|
|
296
|
-
]
|
|
297
|
-
}
|
|
298
|
-
]
|
|
109
|
+
id: 'repo-facts', number: 4, title: 'Repo facts', class: 'instruction', written_by: 'an agent proposes; the owner merges',
|
|
110
|
+
parts: [
|
|
111
|
+
{ id: 'root', listed_by: 'always', path: 'atlas.yaml', holds: 'The spec version, the product id, the repo id, the parts, the procedure map, and one pointer per section.', check: 'facts-root', keys: ['spec', 'product', 'repo', 'parts'] },
|
|
112
|
+
{ id: 'deploy', listed_by: 'files', section: 'deploy', default_path: 'atlas/deploy.yaml', holds: 'One entry per environment: the platform, the target and the health endpoint.', check: 'facts-section', keys: ['environments'] },
|
|
113
|
+
{ id: 'observability', listed_by: 'files', section: 'observability', default_path: 'atlas/observability.yaml', holds: 'Where the logs, the metrics, the traces and the dashboards are, per environment.', check: 'facts-section', keys: ['environments'] },
|
|
114
|
+
{ id: 'credentials', listed_by: 'files', section: 'credentials', default_path: 'atlas/credentials.yaml', holds: 'Each credential by name, with the environment it belongs to. Names only, never a value.', check: 'facts-credentials', keys: ['credentials'] },
|
|
115
|
+
{ id: 'dependencies', listed_by: 'files', section: 'dependencies', default_path: 'atlas/dependencies.yaml', holds: 'The other repos this one needs, by repo id, and what it takes from each.', check: 'facts-section', keys_present: ['repos'] },
|
|
116
|
+
{ id: 'upkeep', listed_by: 'files', section: 'upkeep', default_path: 'atlas/upkeep.yaml', holds: 'The budget and the kill switch for the upkeep task.', check: 'facts-section', keys: ['budget'] },
|
|
117
|
+
{ id: 'areas', listed_by: 'areas', path: 'atlas.yaml', holds: 'The folders where Atlas flows run, and which of them read text that other people write.', check: 'areas' },
|
|
118
|
+
],
|
|
299
119
|
},
|
|
300
120
|
{
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
"written_by": "the upkeep task; merges on its own",
|
|
306
|
-
"parts": [
|
|
307
|
-
{
|
|
308
|
-
"id": "index",
|
|
309
|
-
"path": "docs/index.md",
|
|
310
|
-
"holds": "One line per file under docs/, with a status.",
|
|
311
|
-
"kind": {
|
|
312
|
-
"service": "required",
|
|
313
|
-
"library": "required",
|
|
314
|
-
"schema": "required",
|
|
315
|
-
"plugin": "required",
|
|
316
|
-
"cli": "required"
|
|
317
|
-
},
|
|
318
|
-
"check": "docs-index"
|
|
319
|
-
}
|
|
320
|
-
]
|
|
121
|
+
id: 'docs-index', number: 5, title: 'Docs index', class: 'docs', written_by: 'the upkeep task; merges on its own',
|
|
122
|
+
parts: [
|
|
123
|
+
{ id: 'docs-index', listed_by: 'parts', path: 'docs/index.md', holds: 'One line per file under docs/, with a status. A design folder is one line, for its design.yaml.', check: 'docs-index' },
|
|
124
|
+
],
|
|
321
125
|
},
|
|
322
126
|
{
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
127
|
+
id: 'tooling', number: 6, title: 'Tooling', class: 'instruction', written_by: 'the helper; a person merges',
|
|
128
|
+
parts: [
|
|
129
|
+
// The literal, not the constant: this object is data, and the constant
|
|
130
|
+
// is declared below it. A test keeps the two equal.
|
|
131
|
+
{ id: 'check-file', listed_by: 'parts', path: '.github/atlas/check.mjs', holds: 'The check itself, the copy this repo runs. One file, no dependency, carrying the Atlas version and the package commit it came from.', check: 'check-file' },
|
|
132
|
+
{ id: 'gate-workflow', listed_by: 'parts', path: '.github/workflows/atlas-gate.yml', holds: 'The gate workflow. Its shape job runs the base copy of .github/atlas/check.mjs over the merge base and the head of each pull request.', check: 'workflow' },
|
|
329
133
|
{
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
"holds": "The gate workflow. Its shape job runs the base copy of .github/atlas/check.mjs over the merge base and the head of each pull request.",
|
|
333
|
-
"kind": {
|
|
334
|
-
"service": "required",
|
|
335
|
-
"library": "required",
|
|
336
|
-
"schema": "required",
|
|
337
|
-
"plugin": "required",
|
|
338
|
-
"cli": "required"
|
|
339
|
-
},
|
|
340
|
-
"check": "workflow"
|
|
134
|
+
id: 'linters', listed_by: 'parts', holds: "The repo's own linters and formatters.", check: 'linters',
|
|
135
|
+
candidates: ['eslint.config.js', 'eslint.config.mjs', '.eslintrc', '.eslintrc.json', '.eslintrc.cjs', '.prettierrc', '.prettierrc.json', 'prettier.config.js', 'biome.json', 'ruff.toml', '.ruff.toml', 'setup.cfg', 'tox.ini', '.flake8', 'rustfmt.toml', '.golangci.yml', '.golangci.yaml', '.editorconfig'],
|
|
341
136
|
},
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
"holds": "The repo's own linters and formatters.",
|
|
345
|
-
"kind": {
|
|
346
|
-
"service": "optional",
|
|
347
|
-
"library": "optional",
|
|
348
|
-
"schema": "optional",
|
|
349
|
-
"plugin": "optional",
|
|
350
|
-
"cli": "optional"
|
|
351
|
-
},
|
|
352
|
-
"check": "linters",
|
|
353
|
-
"candidates": [
|
|
354
|
-
"eslint.config.js",
|
|
355
|
-
"eslint.config.mjs",
|
|
356
|
-
".eslintrc",
|
|
357
|
-
".eslintrc.json",
|
|
358
|
-
".eslintrc.cjs",
|
|
359
|
-
".prettierrc",
|
|
360
|
-
".prettierrc.json",
|
|
361
|
-
"prettier.config.js",
|
|
362
|
-
"biome.json",
|
|
363
|
-
"ruff.toml",
|
|
364
|
-
".ruff.toml",
|
|
365
|
-
"setup.cfg",
|
|
366
|
-
"tox.ini",
|
|
367
|
-
".flake8",
|
|
368
|
-
"rustfmt.toml",
|
|
369
|
-
".golangci.yml",
|
|
370
|
-
".golangci.yaml",
|
|
371
|
-
".editorconfig"
|
|
372
|
-
]
|
|
373
|
-
}
|
|
374
|
-
]
|
|
375
|
-
}
|
|
137
|
+
],
|
|
138
|
+
},
|
|
376
139
|
],
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
".claude/rules/*.md",
|
|
383
|
-
".claude/skills/*/SKILL.md",
|
|
384
|
-
"atlas.yaml",
|
|
385
|
-
"atlas/*.yaml"
|
|
386
|
-
]
|
|
387
|
-
}
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
// Deep frozen. `loadSpec` hands every caller the same object, so one caller
|
|
391
|
-
// must not be able to change what the next one reads (acceptance review of
|
|
392
|
-
// batch 14b, finding 9).
|
|
393
|
-
function deepFreeze(value) {
|
|
394
|
-
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
395
|
-
Object.freeze(value);
|
|
396
|
-
for (const child of Object.values(value)) deepFreeze(child);
|
|
397
|
-
}
|
|
398
|
-
return value;
|
|
399
|
-
}
|
|
400
|
-
deepFreeze(SPEC_1);
|
|
140
|
+
by_reference: {
|
|
141
|
+
rule: 'A file under docs/ that an instruction file points at is instruction class by reference (D7 finding R1). The check computes that set and reports each member.',
|
|
142
|
+
scanned: ['CLAUDE.md', 'AGENTS.md', '.claude/rules/*.md', '.claude/skills/*/SKILL.md', 'atlas.yaml', 'atlas/*.yaml'],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
401
145
|
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
// `spec: 1` means, and the spec version is the whole coupling between a
|
|
406
|
-
// repository's facts and this file (architecture rule 6; architect finding
|
|
407
|
-
// P11). The check reads both versions, so a repository stays green while it is
|
|
408
|
-
// still on spec 1, and it opts in by moving `atlas.yaml` to `spec: 2`.
|
|
409
|
-
//
|
|
410
|
-
// Nothing else moves. Spec 1 keeps its sixteen parts, and spec 2 has
|
|
411
|
-
// seventeen. Check version 7 took the six parts of the retired docs site out
|
|
412
|
-
// of both (roadmap R37).
|
|
413
|
-
//
|
|
414
|
-
// Check version 4 adds two kinds, plugin and cli, to both specs (issue #141).
|
|
415
|
-
// No cell of an older kind moves, so a repo that names service, library or
|
|
416
|
-
// schema reads the same table as before. The kind is now a second coupling
|
|
417
|
-
// beside the spec version: a repo that names plugin or cli needs check 4 or
|
|
418
|
-
// later. Stage 2 of the target design retires these fixed tables.
|
|
419
|
-
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
420
|
-
|
|
421
|
-
export const SPEC_2 = (() => {
|
|
422
|
-
const spec = clone(SPEC_1);
|
|
423
|
-
spec.spec = 2;
|
|
424
|
-
spec.authority = 'Atlas amendment A1.6, section 5, slice L2 stage one, and section 4.6.';
|
|
425
|
-
const tooling = spec.files.find((file) => file.id === 'tooling');
|
|
426
|
-
tooling.parts.unshift({
|
|
427
|
-
id: 'check-file',
|
|
428
|
-
// The literal, not the constant: this object is data, and the constant is
|
|
429
|
-
// declared below it. A test keeps the two equal.
|
|
430
|
-
path: '.github/atlas/check.mjs',
|
|
431
|
-
holds: 'The check itself, the copy this repo runs. One file, no dependency, carrying the Atlas version and the package commit it came from.',
|
|
432
|
-
kind: { service: 'required', library: 'required', schema: 'required', plugin: 'required', cli: 'required' },
|
|
433
|
-
check: 'check-file',
|
|
434
|
-
});
|
|
435
|
-
return deepFreeze(spec);
|
|
436
|
-
})();
|
|
146
|
+
// The ids that `parts:` may name, and the sections that `files:` may name.
|
|
147
|
+
export const LISTABLE_PARTS = Object.freeze(SPEC_3.files.flatMap((file) => file.parts).filter((part) => part.listed_by === 'parts').map((part) => part.id));
|
|
148
|
+
export const FILE_SECTIONS = Object.freeze(SPEC_3.files.flatMap((file) => file.parts).filter((part) => part.listed_by === 'files').map((part) => part.section));
|
|
437
149
|
|
|
438
|
-
export const SPECS = Object.freeze({
|
|
150
|
+
export const SPECS = Object.freeze({ 3: SPEC_3 });
|
|
439
151
|
|
|
440
152
|
export class SpecError extends Error {}
|
|
441
153
|
|
|
@@ -447,6 +159,7 @@ export function loadSpec(version) {
|
|
|
447
159
|
const wanted = decimal ? Number(version) : NaN;
|
|
448
160
|
if (!Number.isInteger(wanted) || wanted < 1) throw new SpecError(`atlas.yaml names spec "${version}"; the spec version must be a whole number`);
|
|
449
161
|
const spec = SPECS[wanted];
|
|
162
|
+
if (!spec && wanted < 3) throw new SpecError(`atlas.yaml is spec ${wanted}, which named a kind and read its parts from a kind table. Check version ${CHECK_VERSION} reads spec 3 only, where atlas.yaml lists its own parts (roadmap R62). Run "atlas tooling write", which prints the lines that move atlas.yaml to spec 3`);
|
|
450
163
|
if (!spec) throw new SpecError(`spec ${wanted} is unknown to this copy of the check, check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}. Refresh .github/atlas/check.mjs, or set spec to a version this copy reads`);
|
|
451
164
|
if (spec.spec !== wanted) throw new SpecError(`the spec object for version ${wanted} names version ${spec.spec}`);
|
|
452
165
|
return spec;
|
|
@@ -763,9 +476,9 @@ export function parseYaml(source, { strict = false } = {}) {
|
|
|
763
476
|
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
764
477
|
// A document marker is allowed only before the first content line. After
|
|
765
478
|
// that it starts a second document, and merging two documents into one map
|
|
766
|
-
// lets a later key silently override an earlier one (review R-4). `
|
|
767
|
-
// and `
|
|
768
|
-
// a silent wrong answer.
|
|
479
|
+
// lets a later key silently override an earlier one (review R-4). `spec`
|
|
480
|
+
// and `parts` decide which files a repo needs, so a silent override there
|
|
481
|
+
// is a silent wrong answer.
|
|
769
482
|
if (/^(---|\.\.\.)\s*$/.test(line)) {
|
|
770
483
|
if (lines.length) throw new YamlError('a second document starts here; this check reads one document per file', index, 0);
|
|
771
484
|
continue;
|
|
@@ -867,7 +580,7 @@ export function frontMatter(source) {
|
|
|
867
580
|
// The scanners. One per part of the spec.
|
|
868
581
|
// ---------------------------------------------------------------------------
|
|
869
582
|
|
|
870
|
-
export const STATES = Object.freeze(['filled', 'empty', 'stale', 'not
|
|
583
|
+
export const STATES = Object.freeze(['filled', 'empty', 'stale', 'not listed']);
|
|
871
584
|
export const NOT_FILLED = Object.freeze(['empty', 'stale']);
|
|
872
585
|
export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
873
586
|
const PINNED = /^[0-9a-f]{40}$/;
|
|
@@ -956,10 +669,108 @@ export function docsReferences(text) {
|
|
|
956
669
|
return [...out];
|
|
957
670
|
}
|
|
958
671
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// The docs index rule. One rule, here, for the check and for `atlas upkeep
|
|
674
|
+
// check` (roadmap R62 line 5). Before check version 10 each one read the index
|
|
675
|
+
// its own way: the check read every link, and upkeep read links outside code,
|
|
676
|
+
// skipped archive/ and fixtures/, and counted a design folder as one line. On
|
|
677
|
+
// one tree they gave two counts.
|
|
678
|
+
//
|
|
679
|
+
// 1. docs/index.md lists each .md file under docs/.
|
|
680
|
+
// 2. A file in a design folder (a folder that holds design.yaml) counts as
|
|
681
|
+
// listed when the index lists that folder's design.yaml.
|
|
682
|
+
// 3. Each listed path exists.
|
|
683
|
+
// 4. A link inside code is not a listing. Dot folders, node_modules, archive/
|
|
684
|
+
// and any folder named fixtures are skipped.
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
export const DOCS_INDEX_PATH = 'docs/index.md';
|
|
688
|
+
const DOCS_SKIP = new Set(['node_modules', 'archive', 'fixtures']);
|
|
689
|
+
|
|
690
|
+
// Every .md file under the root, as a posix path. Dot folders, node_modules,
|
|
691
|
+
// archive/ and any folder named fixtures are skipped.
|
|
692
|
+
export function markdownFiles(root, under = '') {
|
|
693
|
+
const out = [];
|
|
694
|
+
const visit = (rel) => {
|
|
695
|
+
let entries;
|
|
696
|
+
try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
|
|
697
|
+
for (const entry of entries) {
|
|
698
|
+
if (entry.name.startsWith('.')) continue;
|
|
699
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
700
|
+
if (entry.isDirectory()) { if (!DOCS_SKIP.has(entry.name)) visit(path); }
|
|
701
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(path);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
visit(under);
|
|
705
|
+
return out.sort();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Text outside code: fenced blocks of three or more marks, and inline code.
|
|
709
|
+
export function proseOf(text) {
|
|
710
|
+
const lines = String(text ?? '').split('\n');
|
|
711
|
+
const kept = [];
|
|
712
|
+
let fence = null;
|
|
713
|
+
for (const one of lines) {
|
|
714
|
+
const open = one.match(/^\s*(`{3,}|~{3,})/);
|
|
715
|
+
if (fence) {
|
|
716
|
+
if (open && open[1][0] === fence[0] && open[1].length >= fence.length && !one.trim().slice(open[1].length).trim()) fence = null;
|
|
717
|
+
kept.push('');
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (open) { fence = open[1]; kept.push(''); continue; }
|
|
721
|
+
kept.push(one.replace(/`+[^`\n]*`+/g, ''));
|
|
722
|
+
}
|
|
723
|
+
return kept.join('\n');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// The relative link targets in one markdown text, with their line numbers.
|
|
727
|
+
export function linkTargets(text) {
|
|
728
|
+
const out = [];
|
|
729
|
+
proseOf(text).split('\n').forEach((one, index) => {
|
|
730
|
+
for (const [, angled, bare] of one.matchAll(/\]\(\s*(?:<([^>\n]+)>|([^)\s]+))(?:\s+"[^"]*")?\s*\)/g)) {
|
|
731
|
+
const raw = angled ?? bare;
|
|
732
|
+
const target = raw.split('#')[0].split('?')[0];
|
|
733
|
+
if (!target || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('#')) continue;
|
|
734
|
+
if (/[{}$*\\]/.test(target)) continue;
|
|
735
|
+
let decoded = target;
|
|
736
|
+
try { decoded = decodeURIComponent(target); } catch { /* keep it as written */ }
|
|
737
|
+
out.push({ target: decoded, line: index + 1 });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// The nearest folder at or above `file` that holds design.yaml, or null.
|
|
744
|
+
function designFolderOf(root, file) {
|
|
745
|
+
let dir = posix.dirname(file);
|
|
746
|
+
while (dir && dir !== '.' && dir !== 'docs') {
|
|
747
|
+
if (existsSync(join(root, dir, 'design.yaml'))) return dir;
|
|
748
|
+
dir = posix.dirname(dir);
|
|
749
|
+
}
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// The answer: `checked` is false with no docs/ folder; `missing` is true with
|
|
754
|
+
// no index; `unlisted` and `gone` are sorted paths; `files` is the count of
|
|
755
|
+
// .md files under docs/ besides the index.
|
|
756
|
+
export function docsIndexRule(root) {
|
|
757
|
+
if (!isDirectory(root, 'docs')) return { checked: false, missing: false, files: 0, unlisted: [], gone: [] };
|
|
758
|
+
const onDisk = markdownFiles(root, 'docs').filter((path) => path !== DOCS_INDEX_PATH);
|
|
759
|
+
const text = readText(root, DOCS_INDEX_PATH);
|
|
760
|
+
if (text === null || !text.trim()) return { checked: true, missing: true, files: onDisk.length, unlisted: onDisk, gone: [] };
|
|
761
|
+
const listed = new Set();
|
|
762
|
+
for (const { target } of linkTargets(text)) {
|
|
763
|
+
if (target.startsWith('/')) continue;
|
|
764
|
+
const path = posix.normalize(posix.join('docs', target)).replace(/\/$/, '');
|
|
765
|
+
if (path.startsWith('docs/')) listed.add(path);
|
|
766
|
+
}
|
|
767
|
+
const unlisted = onDisk.filter((path) => {
|
|
768
|
+
if (listed.has(path)) return false;
|
|
769
|
+
const folder = designFolderOf(root, path);
|
|
770
|
+
return !(folder && listed.has(`${folder}/design.yaml`));
|
|
771
|
+
});
|
|
772
|
+
const gone = [...listed].filter((path) => !existsSync(join(root, path))).sort();
|
|
773
|
+
return { checked: true, missing: false, files: onDisk.length, unlisted, gone };
|
|
963
774
|
}
|
|
964
775
|
|
|
965
776
|
const result = (part, state, findings = []) => ({ id: part.id, path: part.path ?? part.default_path ?? null, procedure: part.procedure ?? null, holds: part.holds ?? '', class: part.class ?? null, state, findings });
|
|
@@ -1016,17 +827,14 @@ function checkProcedure(context, part) {
|
|
|
1016
827
|
}
|
|
1017
828
|
|
|
1018
829
|
// `scan` reports a missing or unreadable atlas.yaml as an error and returns
|
|
1019
|
-
// before it scans any part, because the file
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
// of batch 14b, finding 4).
|
|
830
|
+
// before it scans any part, because the file lists the parts. So this scanner
|
|
831
|
+
// only ever runs on a file that parsed (acceptance review of batch 14b,
|
|
832
|
+
// finding 4).
|
|
1023
833
|
function checkFactsRoot(context, part) {
|
|
1024
834
|
const missing = (part.keys ?? []).filter((key) => context.facts[key] === undefined || context.facts[key] === null || context.facts[key] === '');
|
|
1025
|
-
const findings = [];
|
|
1026
835
|
if (missing.length) return result(part, 'stale', [`atlas.yaml has no ${missing.join(', ')}`]);
|
|
1027
836
|
if (context.repoId && String(context.facts.repo) !== context.repoId) return result(part, 'stale', [`atlas.yaml names repo "${context.facts.repo}"; this checkout is ${context.repoId}`]);
|
|
1028
|
-
|
|
1029
|
-
return result(part, 'filled', findings);
|
|
837
|
+
return result(part, 'filled');
|
|
1030
838
|
}
|
|
1031
839
|
|
|
1032
840
|
function sectionPath(context, part) {
|
|
@@ -1057,12 +865,14 @@ const emptyValue = (value) => value === undefined || value === null || value ===
|
|
|
1057
865
|
|
|
1058
866
|
// A secret in any file the check reads, over the raw text, before anything is
|
|
1059
867
|
// parsed or echoed. The patterns used to run over atlas/credentials.yaml alone,
|
|
1060
|
-
// and every other facts file has its values quoted into findings: the
|
|
868
|
+
// and every other facts file has its values quoted into findings: the
|
|
1061
869
|
// repo id, a procedure's skill name, a pointer path (acceptance review of batch
|
|
1062
870
|
// 16, note S3-2).
|
|
1063
871
|
function secretIn(context, path, text) {
|
|
872
|
+
if (context.secretsAt.has(path)) return true;
|
|
1064
873
|
for (const pattern of context.secretPatterns) {
|
|
1065
874
|
if (pattern.test(text ?? '')) {
|
|
875
|
+
context.secretsAt.add(path);
|
|
1066
876
|
context.errors.push(`${path} holds something that looks like a secret value; no file the check reads carries one`);
|
|
1067
877
|
return true;
|
|
1068
878
|
}
|
|
@@ -1096,12 +906,7 @@ function checkCredentials(context, part) {
|
|
|
1096
906
|
// bad indentation used to take the parse-error path out of this function, so
|
|
1097
907
|
// the one content check in the whole check never ran on the one file it
|
|
1098
908
|
// guards (security persona, finding S2-6).
|
|
1099
|
-
|
|
1100
|
-
if (pattern.test(section.text ?? '')) {
|
|
1101
|
-
context.errors.push(`${section.path} holds something that looks like a secret value; this file carries names only`);
|
|
1102
|
-
return result(withPath, 'stale', [`${section.path} holds something that looks like a secret value`]);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
909
|
+
if (secretIn(context, section.path, section.text)) return result(withPath, 'stale', [`${section.path} holds something that looks like a secret value`]);
|
|
1105
910
|
if (section.error) return result(withPath, 'stale', [`${section.path} cannot be read: ${section.error}`]);
|
|
1106
911
|
const rows = section.data?.credentials;
|
|
1107
912
|
if (emptyValue(rows)) return result(withPath, 'stale', [`${section.path} has no credentials`]);
|
|
@@ -1111,18 +916,41 @@ function checkCredentials(context, part) {
|
|
|
1111
916
|
return result(withPath, 'filled', [`${names.length} credential name(s)`]);
|
|
1112
917
|
}
|
|
1113
918
|
|
|
919
|
+
// The areas part: where Atlas flows run, and which areas read text that
|
|
920
|
+
// other people write. Each entry is a path, or a map with `path` and
|
|
921
|
+
// `outside_text: true`. A path is "." or a folder inside the repo, by the
|
|
922
|
+
// same test as a pointer (design parts-and-areas, security 4).
|
|
923
|
+
const AREA_KEYS = Object.freeze(['path', 'outside_text']);
|
|
924
|
+
|
|
925
|
+
function checkAreas(context, part) {
|
|
926
|
+
const list = context.facts?.areas;
|
|
927
|
+
if (!Array.isArray(list) || !list.length) return result(part, 'empty', ['atlas.yaml has an areas key that is not a list of areas']);
|
|
928
|
+
const bad = [];
|
|
929
|
+
let outside = 0;
|
|
930
|
+
list.forEach((entry, index) => {
|
|
931
|
+
const at = `areas[${index}]`;
|
|
932
|
+
const map = typeof entry === 'string' ? { path: entry } : entry;
|
|
933
|
+
if (!map || typeof map !== 'object' || Array.isArray(map)) { bad.push(`${at} is not a path or a map with a path`); return; }
|
|
934
|
+
for (const key of Object.keys(map)) if (!AREA_KEYS.includes(key)) bad.push(`${at} has the key "${key}"; the keys are ${AREA_KEYS.join(' and ')}`);
|
|
935
|
+
if (map.outside_text !== undefined && typeof map.outside_text !== 'boolean') bad.push(`${at}.outside_text is not true or false`);
|
|
936
|
+
if (map.outside_text === true) outside += 1;
|
|
937
|
+
const path = typeof map.path === 'string' ? map.path.trim().replace(/\/+$/, '') : '';
|
|
938
|
+
if (!path) { bad.push(`${at} has no path`); return; }
|
|
939
|
+
if (path !== '.' && !insideRepo(context.root, path)) { bad.push(`${at} names a path outside the repo`); return; }
|
|
940
|
+
if (!isDirectory(context.root, path)) bad.push(`${at} names ${path}, which is not a folder`);
|
|
941
|
+
});
|
|
942
|
+
if (bad.length) return result(part, 'stale', bad.slice(0, MAX_LISTED));
|
|
943
|
+
return result(part, 'filled', [`${list.length} area(s), ${outside} read outside text`]);
|
|
944
|
+
}
|
|
945
|
+
|
|
1114
946
|
function checkDocsIndex(context, part) {
|
|
1115
|
-
const
|
|
1116
|
-
if (
|
|
1117
|
-
const listed = new Set(pointers(text).map((path) => (path.startsWith('docs/') ? path : posix.join('docs', path))));
|
|
1118
|
-
const onDisk = walk(context.root, 'docs').filter((path) => path.endsWith('.md') && path !== part.path);
|
|
1119
|
-
const unlisted = onDisk.filter((path) => !listed.has(path));
|
|
1120
|
-
const gone = [...listed].filter((path) => !existsSync(join(context.root, path)));
|
|
947
|
+
const rule = docsIndexRule(context.root);
|
|
948
|
+
if (!rule.checked || rule.missing) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1121
949
|
const findings = [];
|
|
1122
|
-
if (unlisted.length) findings.push(`${unlisted.length} file(s) under docs/ are not listed: ${unlisted.slice(0, MAX_LISTED).join(', ')}`);
|
|
1123
|
-
if (gone.length) findings.push(`${gone.length} listed file(s) are gone: ${gone.slice(0, MAX_LISTED).join(', ')}`);
|
|
950
|
+
if (rule.unlisted.length) findings.push(`${rule.unlisted.length} file(s) under docs/ are not listed: ${rule.unlisted.slice(0, MAX_LISTED).join(', ')}`);
|
|
951
|
+
if (rule.gone.length) findings.push(`${rule.gone.length} listed file(s) are gone: ${rule.gone.slice(0, MAX_LISTED).join(', ')}`);
|
|
1124
952
|
findings.push(...symlinkFindings(context.root, 'docs'));
|
|
1125
|
-
return findings.length ? result(part, 'stale', findings) : result(part, 'filled', [`${
|
|
953
|
+
return findings.length ? result(part, 'stale', findings) : result(part, 'filled', [`${rule.files} file(s) listed`]);
|
|
1126
954
|
}
|
|
1127
955
|
|
|
1128
956
|
// Every permission the workflow grants at anything other than read. It reads a
|
|
@@ -1334,6 +1162,7 @@ const CHECKS = {
|
|
|
1334
1162
|
'facts-section': checkFactsSection,
|
|
1335
1163
|
'facts-credentials': checkCredentials,
|
|
1336
1164
|
'docs-index': checkDocsIndex,
|
|
1165
|
+
areas: checkAreas,
|
|
1337
1166
|
workflow: checkWorkflow,
|
|
1338
1167
|
'check-file': checkCheckFile,
|
|
1339
1168
|
linters: checkLinters,
|
|
@@ -1435,12 +1264,56 @@ export function readFacts(root) {
|
|
|
1435
1264
|
} catch (error) { return { facts: null, error: `atlas.yaml cannot be read: ${error.message}` }; }
|
|
1436
1265
|
}
|
|
1437
1266
|
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1267
|
+
// The lists of atlas.yaml: what the repo says it carries. Each value is read
|
|
1268
|
+
// as it stands; `listErrors` says what is wrong with its shape.
|
|
1269
|
+
export function listsOf(facts) {
|
|
1270
|
+
const parts = Array.isArray(facts?.parts) ? facts.parts.map(String) : [];
|
|
1271
|
+
const procedures = facts?.procedures && typeof facts.procedures === 'object' && !Array.isArray(facts.procedures) ? Object.keys(facts.procedures) : [];
|
|
1272
|
+
const files = facts?.files && typeof facts.files === 'object' && !Array.isArray(facts.files) ? Object.keys(facts.files) : [];
|
|
1273
|
+
return { parts, procedures, files, areas: facts?.areas !== undefined };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// The errors of the lists. A list the check cannot read would hide a part, so
|
|
1277
|
+
// each one is an error, not a finding. `kind:` is an error too: spec 3 has
|
|
1278
|
+
// none, and a repo that keeps it may believe the kind still picks its parts.
|
|
1279
|
+
export function listErrors(facts) {
|
|
1280
|
+
const errors = [];
|
|
1281
|
+
if (!facts || typeof facts !== 'object') return errors;
|
|
1282
|
+
if (Object.hasOwn(facts, 'kind')) errors.push('atlas.yaml has a kind key. Spec 3 has no kind: the parts key lists what the repo carries. Remove the kind line');
|
|
1283
|
+
if (Object.hasOwn(facts, 'parts')) {
|
|
1284
|
+
if (!stringList(facts.parts) && !(Array.isArray(facts.parts) && !facts.parts.length)) errors.push('atlas.yaml: parts is not a list of part ids');
|
|
1285
|
+
else {
|
|
1286
|
+
const unknown = facts.parts.filter((id) => !LISTABLE_PARTS.includes(id));
|
|
1287
|
+
if (unknown.length) errors.push(`atlas.yaml: parts names ${unknown.join(', ')}, which the catalog does not hold; the ids are ${LISTABLE_PARTS.join(', ')}`);
|
|
1288
|
+
const twice = facts.parts.filter((id, index) => facts.parts.indexOf(id) !== index);
|
|
1289
|
+
if (twice.length) errors.push(`atlas.yaml: parts names ${[...new Set(twice)].join(', ')} more than once`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
for (const key of ['procedures', 'files']) {
|
|
1293
|
+
const value = facts[key];
|
|
1294
|
+
if (value !== undefined && value !== null && (typeof value !== 'object' || Array.isArray(value))) errors.push(`atlas.yaml: ${key} is not a map`);
|
|
1295
|
+
}
|
|
1296
|
+
const files = facts.files && typeof facts.files === 'object' && !Array.isArray(facts.files) ? Object.keys(facts.files) : [];
|
|
1297
|
+
const unknown = files.filter((section) => !FILE_SECTIONS.includes(section));
|
|
1298
|
+
if (unknown.length) errors.push(`atlas.yaml: files names ${unknown.join(', ')}, which the catalog does not hold; the sections are ${FILE_SECTIONS.join(', ')}`);
|
|
1299
|
+
return errors;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Is this catalog part listed? A procedure part is expanded before this.
|
|
1303
|
+
function isListed(part, lists) {
|
|
1304
|
+
if (part.listed_by === 'always') return true;
|
|
1305
|
+
if (part.listed_by === 'parts') return lists.parts.includes(part.id);
|
|
1306
|
+
if (part.listed_by === 'files') return lists.files.includes(part.section);
|
|
1307
|
+
if (part.listed_by === 'areas') return lists.areas;
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// `scoreAs` is the no-loss rule's one lever. It replaces the spec and the
|
|
1312
|
+
// lists this tree declares with the ones handed in, so two trees are scored
|
|
1313
|
+
// by the same yardstick. Without it a head that drops `rules` from `parts:`
|
|
1314
|
+
// would stop scoring its rules, and the part could go without a sign (design
|
|
1315
|
+
// parts-and-areas, security 2). Nothing else about the scan changes: the tree
|
|
1316
|
+
// still speaks for itself about every part.
|
|
1444
1317
|
export function scan({ root, repoId = null, secretPatterns: patterns = secretPatterns(), scoreAs = null }) {
|
|
1445
1318
|
const { facts, error: factsError } = readFacts(root);
|
|
1446
1319
|
const errors = [];
|
|
@@ -1455,58 +1328,77 @@ export function scan({ root, repoId = null, secretPatterns: patterns = secretPat
|
|
|
1455
1328
|
// canary, so a pattern edited to match nothing is loud (finding S2-5).
|
|
1456
1329
|
const missed = canaryMisses();
|
|
1457
1330
|
if (missed.length) errors.push(`${missed.length} secret pattern(s) no longer catch their own canary: ${missed.join(', ')}; this check cannot be trusted`);
|
|
1458
|
-
if (!facts && !factsError) errors.push('atlas.yaml does not exist, so the check cannot learn the spec version or the repo
|
|
1331
|
+
if (!facts && !factsError) errors.push('atlas.yaml does not exist, so the check cannot learn the spec version or the parts the repo lists');
|
|
1459
1332
|
if (factsError) errors.push(factsError);
|
|
1460
1333
|
errors.push(...ownerOnlyErrors(facts));
|
|
1461
|
-
const kind = scoreAs ? scoreAs.kind ?? null : facts?.kind ?? null;
|
|
1462
1334
|
const wantedSpec = scoreAs ? scoreAs.spec : facts?.spec;
|
|
1463
1335
|
let spec = null;
|
|
1464
1336
|
if (facts) {
|
|
1465
1337
|
try { spec = loadSpec(wantedSpec); }
|
|
1466
1338
|
catch (error) { errors.push(error.message); }
|
|
1467
1339
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1340
|
+
const context = { root, facts, factsError, repoId, secretPatterns: usable, errors, secretsAt: new Set() };
|
|
1341
|
+
// The sweep (roadmap R62 line 2). atlas.yaml's own values reach the report:
|
|
1342
|
+
// the repo id, a procedure's skill name, every pointer path. And a file
|
|
1343
|
+
// under atlas/ can hold a value whether the repo lists it or not: Atlas, a
|
|
1344
|
+
// library at spec 2, never had its credentials file read. So the patterns
|
|
1345
|
+
// read atlas.yaml and every file under atlas/, before any value is quoted
|
|
1346
|
+
// (acceptance review of batch 16, note S3-2).
|
|
1347
|
+
if (facts || factsError) secretIn(context, 'atlas.yaml', readText(root, 'atlas.yaml'));
|
|
1348
|
+
for (const path of walk(root, 'atlas')) secretIn(context, path, readText(root, path));
|
|
1349
|
+
if (!spec) return { ok: false, check_version: CHECK_VERSION, repo: repoId, spec: wantedSpec ?? null, scored_as: scoreAs, product: facts?.product ?? null, files: [], by_reference: [], counts: counts([], facts), errors };
|
|
1350
|
+
errors.push(...listErrors(scoreAs ? { ...facts, ...scoreAs.facts } : facts));
|
|
1351
|
+
|
|
1352
|
+
// The no-loss rule can score a tree at another tree's lists and pointers:
|
|
1353
|
+
// the parts it carries, and the paths that atlas.yaml names for the
|
|
1354
|
+
// procedures and the facts files. So a part that a pull request drops or
|
|
1355
|
+
// moves is also scored where the base had it (roadmap R7 line 3).
|
|
1356
|
+
if (scoreAs) context.facts = { ...facts, spec: scoreAs.spec, ...(scoreAs.facts ?? {}) };
|
|
1357
|
+
const lists = listsOf(context.facts);
|
|
1481
1358
|
const files = spec.files.map((file) => ({
|
|
1482
1359
|
id: file.id,
|
|
1483
1360
|
number: file.number,
|
|
1484
1361
|
title: file.title,
|
|
1485
1362
|
class: file.class,
|
|
1486
1363
|
written_by: file.written_by,
|
|
1487
|
-
parts: file.parts.
|
|
1488
|
-
const
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1364
|
+
parts: file.parts.flatMap((catalog) => {
|
|
1365
|
+
const expanded = catalog.listed_by === 'procedures'
|
|
1366
|
+
? lists.procedures.map((name) => ({ ...catalog, id: name, procedure: name }))
|
|
1367
|
+
: [catalog];
|
|
1368
|
+
return expanded.map((part) => {
|
|
1369
|
+
if (part.listed_by !== 'procedures' && !isListed(part, lists)) {
|
|
1370
|
+
const why = part.listed_by === 'files' ? `atlas.yaml files has no ${part.section} key` : part.listed_by === 'areas' ? 'atlas.yaml has no areas key; the repo is one area' : `atlas.yaml parts does not name ${part.id}`;
|
|
1371
|
+
return { ...result(part, 'not listed', [why]), required: 'not listed', listed: false };
|
|
1372
|
+
}
|
|
1373
|
+
const check = CHECKS[part.check];
|
|
1374
|
+
if (!check) throw new SpecError(`the spec names check "${part.check}", which this version of the check does not have`);
|
|
1375
|
+
return { ...check(context, part), required: 'required', listed: true };
|
|
1376
|
+
});
|
|
1493
1377
|
}),
|
|
1494
1378
|
}));
|
|
1495
|
-
return { ok: !errors.length, check_version: CHECK_VERSION, repo: repoId ?? facts.repo ?? null,
|
|
1379
|
+
return { ok: !errors.length, check_version: CHECK_VERSION, repo: repoId ?? facts.repo ?? null, spec: spec.spec, scored_as: scoreAs, product: facts.product ?? null, files, by_reference: byReference(context, spec), counts: counts(files, context.facts), errors };
|
|
1496
1380
|
}
|
|
1497
1381
|
|
|
1498
|
-
|
|
1499
|
-
|
|
1382
|
+
// The counts. Every listed part is required, so `required` and `listed` are
|
|
1383
|
+
// one number; the old name stays for the readers of the report. `procedures`
|
|
1384
|
+
// and `jobs` are the numbers outcome O7 counts for each repo (roadmap R62
|
|
1385
|
+
// line 6): the keys of `procedures:`, and the jobs that `jobs:` turns on.
|
|
1386
|
+
export function counts(files, facts = null) {
|
|
1387
|
+
const out = { filled: 0, empty: 0, stale: 0, 'not listed': 0, listed: 0, listed_not_filled: 0, required: 0, not_filled: 0, required_not_filled: 0, procedures: 0, jobs: 0 };
|
|
1500
1388
|
for (const file of files) for (const part of file.parts) {
|
|
1501
1389
|
out[part.state] += 1;
|
|
1502
|
-
if (part.required
|
|
1503
|
-
|
|
1504
|
-
if (NOT_FILLED.includes(part.state))
|
|
1505
|
-
out.not_filled += 1;
|
|
1506
|
-
if (part.required === 'required') out.required_not_filled += 1;
|
|
1507
|
-
if (part.required === 'optional') out.optional_not_filled += 1;
|
|
1508
|
-
}
|
|
1390
|
+
if (part.required !== 'required') continue;
|
|
1391
|
+
out.listed += 1;
|
|
1392
|
+
if (NOT_FILLED.includes(part.state)) out.listed_not_filled += 1;
|
|
1509
1393
|
}
|
|
1394
|
+
out.required = out.listed;
|
|
1395
|
+
out.not_filled = out.listed_not_filled;
|
|
1396
|
+
out.required_not_filled = out.listed_not_filled;
|
|
1397
|
+
out.procedures = listsOf(facts).procedures.length;
|
|
1398
|
+
const jobs = facts?.jobs;
|
|
1399
|
+
if (jobs && typeof jobs === 'object' && !Array.isArray(jobs)) {
|
|
1400
|
+
for (const value of Object.values(jobs)) out.jobs += Array.isArray(value) ? value.length : value ? 1 : 0;
|
|
1401
|
+
} else if (Array.isArray(jobs)) out.jobs = jobs.length;
|
|
1510
1402
|
return out;
|
|
1511
1403
|
}
|
|
1512
1404
|
|
|
@@ -1525,10 +1417,10 @@ export function counts(files) {
|
|
|
1525
1417
|
//
|
|
1526
1418
|
// Why per part and never a count. A count is defeated two ways. Empty one part,
|
|
1527
1419
|
// fill another, and the total does not move. Or change one line: `atlas.yaml`
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1420
|
+
// lists the parts, and `atlas.yaml` is inside the diff being scored, so a
|
|
1421
|
+
// head that drops `rules` from `parts:` turns a scored part into "not listed"
|
|
1422
|
+
// and the count falls. Scoring per part, with the base tree's `spec` and
|
|
1423
|
+
// lists, closes both (findings P2, P20; roadmap R62).
|
|
1532
1424
|
//
|
|
1533
1425
|
// Which copy scores. The base's, from check version 6 (roadmap R7). The gate
|
|
1534
1426
|
// job runs on pull_request_target, so it runs this file as the base holds it,
|
|
@@ -1549,7 +1441,9 @@ export function counts(files) {
|
|
|
1549
1441
|
// the rule still gates: it still finds any part that went backwards. Before
|
|
1550
1442
|
// check version 6 five cases switched the rule off and printed a warning that
|
|
1551
1443
|
// a person merges the file. Nothing switches it off now. The base copy scores,
|
|
1552
|
-
// and the base
|
|
1444
|
+
// and the base spec and lists score both trees, so no case needs to.
|
|
1445
|
+
export const OWNER_CASES = Object.freeze(['check-file', 'workflow', 'parts', 'spec', 'moved', 'first-run']);
|
|
1446
|
+
|
|
1553
1447
|
// A head whose atlas.yaml names a spec that this copy of the check cannot
|
|
1554
1448
|
// read. A head with no spec, or with an atlas.yaml that does not read, is not
|
|
1555
1449
|
// one: those stay errors.
|
|
@@ -1570,15 +1464,13 @@ export function sweepHead({ root, repoId = null, secretPatterns: patterns = secr
|
|
|
1570
1464
|
if (!usable.length) errors.push('the secret pattern list is empty or is not a list of patterns, so a credential value could not be caught; this check cannot be trusted');
|
|
1571
1465
|
const missed = canaryMisses();
|
|
1572
1466
|
if (missed.length) errors.push(`${missed.length} secret pattern(s) no longer catch their own canary: ${missed.join(', ')}; this check cannot be trusted`);
|
|
1573
|
-
const context = { root, secretPatterns: usable, errors };
|
|
1467
|
+
const context = { root, secretPatterns: usable, errors, secretsAt: new Set() };
|
|
1574
1468
|
const files = facts?.files && typeof facts.files === 'object' && !Array.isArray(facts.files) ? facts.files : {};
|
|
1575
1469
|
const pointed = Object.values(files).filter((path) => plainPath(path) && isFile(root, path));
|
|
1576
1470
|
for (const path of new Set(['atlas.yaml', ...walk(root, 'atlas'), ...pointed])) secretIn(context, path, readText(root, path));
|
|
1577
1471
|
return { ok: false, check_version: CHECK_VERSION, repo: repoId, kind: null, spec: facts?.spec ?? null, swept_only: true, files: [], by_reference: [], counts: counts([]), errors };
|
|
1578
1472
|
}
|
|
1579
1473
|
|
|
1580
|
-
export const OWNER_CASES = Object.freeze(['check-file', 'workflow', 'kind', 'spec', 'moved', 'first-run']);
|
|
1581
|
-
|
|
1582
1474
|
const fileHash = (root, path) => { const text = readText(root, path); return text === null ? null : sha256(text); };
|
|
1583
1475
|
const isEmptyDir = (root) => { try { return !readdirSync(root).length; } catch { return true; } };
|
|
1584
1476
|
|
|
@@ -1647,24 +1539,48 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, baseCommit = null,
|
|
|
1647
1539
|
owner('first-run', 'the base has no atlas.yaml, so there is nothing to lose; the owner reads the first shape');
|
|
1648
1540
|
return out;
|
|
1649
1541
|
}
|
|
1650
|
-
// A base atlas.yaml that does not read cannot name a
|
|
1542
|
+
// A base atlas.yaml that does not read cannot name a spec or its parts, so
|
|
1651
1543
|
// there is no yardstick. That is red. The owner merges the fix by hand.
|
|
1652
|
-
if (baseRead.error) return broken(`the base atlas.yaml does not read, so its
|
|
1544
|
+
if (baseRead.error) return broken(`the base atlas.yaml does not read, so its spec and parts are unknown: ${baseRead.error}`);
|
|
1653
1545
|
|
|
1654
|
-
// 2. The base tree's
|
|
1655
|
-
|
|
1546
|
+
// 2. The base tree's spec and lists score both trees. A head that drops a
|
|
1547
|
+
// part from a list still has the part scored, and the change gives "owner"
|
|
1548
|
+
// (design parts-and-areas, security 2).
|
|
1549
|
+
const baseFacts = baseRead.facts;
|
|
1656
1550
|
const headRead = readFacts(headRoot);
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1551
|
+
const headFacts = headRead.facts ?? {};
|
|
1552
|
+
const baseLists = listsOf(baseFacts);
|
|
1553
|
+
const headLists = listsOf(headFacts);
|
|
1554
|
+
out.base_facts = { spec: baseFacts.spec ?? null, parts: baseLists.parts, procedures: baseLists.procedures, files: baseLists.files, areas: baseLists.areas };
|
|
1555
|
+
out.head_facts = { spec: headFacts.spec ?? null, parts: headLists.parts, procedures: headLists.procedures, files: headLists.files, areas: headLists.areas };
|
|
1556
|
+
|
|
1557
|
+
const dropped = [
|
|
1558
|
+
...baseLists.parts.filter((id) => !headLists.parts.includes(id)).map((id) => `parts ${id}`),
|
|
1559
|
+
...baseLists.procedures.filter((name) => !headLists.procedures.includes(name)).map((name) => `procedures ${name}`),
|
|
1560
|
+
...baseLists.files.filter((section) => !headLists.files.includes(section)).map((section) => `files ${section}`),
|
|
1561
|
+
...(baseLists.areas && !headLists.areas ? ['areas'] : []),
|
|
1562
|
+
];
|
|
1563
|
+
if (dropped.length) {
|
|
1564
|
+
owner('parts', `atlas.yaml drops ${dropped.join(', ')} at the head; the base lists still score both trees, and only the owner approves a smaller list`);
|
|
1662
1565
|
}
|
|
1663
|
-
const unread = Object.hasOwn(SPECS, String(
|
|
1664
|
-
if (String(out.head_facts.spec) !== String(
|
|
1665
|
-
owner('spec', `atlas.yaml names spec "${
|
|
1566
|
+
const unread = Object.hasOwn(SPECS, String(out.base_facts.spec)) && headSpecUnread(headRead.facts);
|
|
1567
|
+
if (String(out.head_facts.spec) !== String(out.base_facts.spec)) {
|
|
1568
|
+
owner('spec', `atlas.yaml names spec "${out.base_facts.spec}" at the base and "${out.head_facts.spec}" at the head; this pull request changes what the spec version means${unread ? `; this copy of the check, check version ${CHECK_VERSION}, cannot read spec "${out.head_facts.spec}", so it did not score the head and only swept it for secrets` : ''}`);
|
|
1666
1569
|
}
|
|
1667
1570
|
|
|
1571
|
+
// The base lists, with the head's own pointers where the head still has the
|
|
1572
|
+
// key, and the base's where it dropped it.
|
|
1573
|
+
const pick = (key) => {
|
|
1574
|
+
const at = baseFacts[key];
|
|
1575
|
+
if (!at || typeof at !== 'object' || Array.isArray(at)) return at;
|
|
1576
|
+
const now = headFacts[key] && typeof headFacts[key] === 'object' && !Array.isArray(headFacts[key]) ? headFacts[key] : {};
|
|
1577
|
+
return Object.fromEntries(Object.keys(at).map((name) => [name, Object.hasOwn(now, name) ? now[name] : at[name]]));
|
|
1578
|
+
};
|
|
1579
|
+
const areasAt = (facts) => (baseLists.areas ? (facts.areas !== undefined ? facts.areas : baseFacts.areas) : undefined);
|
|
1580
|
+
const scoreAs = { spec: baseFacts.spec ?? null };
|
|
1581
|
+
const atHead = { ...scoreAs, facts: { parts: baseFacts.parts, procedures: pick('procedures'), files: pick('files'), areas: areasAt(headFacts) } };
|
|
1582
|
+
const atBase = { ...scoreAs, facts: { parts: baseFacts.parts, procedures: baseFacts.procedures, files: baseFacts.files, areas: baseFacts.areas } };
|
|
1583
|
+
|
|
1668
1584
|
// 3. Does the diff change the gate itself? The base copy judges, so the
|
|
1669
1585
|
// change cannot score itself, but only the owner may approve it.
|
|
1670
1586
|
for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH], ['workflow', OLD_WORKFLOW_PATH]]) {
|
|
@@ -1682,9 +1598,8 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, baseCommit = null,
|
|
|
1682
1598
|
// 4. Score both trees, by the same yardstick, and compare part by part. The
|
|
1683
1599
|
// head is scored twice: at its own pointers, and at the base's.
|
|
1684
1600
|
const base = scan({ root: baseRoot, repoId, secretPatterns: patterns, scoreAs });
|
|
1685
|
-
const head = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs });
|
|
1686
|
-
const
|
|
1687
|
-
const headAtBase = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs: { ...scoreAs, pointers } });
|
|
1601
|
+
const head = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs: atHead });
|
|
1602
|
+
const headAtBase = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs: atBase });
|
|
1688
1603
|
if (!base.files.length) return broken(`the base tree at ${baseRoot} could not be scored: ${base.errors.join('; ') || 'no parts came back'}`);
|
|
1689
1604
|
|
|
1690
1605
|
const headParts = partIndex(head);
|
|
@@ -1735,7 +1650,7 @@ export function gateAnswer(report, state) {
|
|
|
1735
1650
|
const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
1736
1651
|
export function gateLines(answer, state) {
|
|
1737
1652
|
const base = state?.base_head ? state.base_head.slice(0, 12) : 'none';
|
|
1738
|
-
const yard = state?.base_facts ? `
|
|
1653
|
+
const yard = state?.base_facts ? `spec ${oneLine(state.base_facts.spec)}, ${state.base_facts.parts.length + state.base_facts.procedures.length + state.base_facts.files.length} listed part(s)` : 'no yardstick';
|
|
1739
1654
|
const out = [`shape: base ${base}, ${yard}, ${state?.parts_compared ?? 0} instruction-class part(s) filled at the base`];
|
|
1740
1655
|
for (const row of answer.rows) out.push(` ${row.kind.padEnd(6)} ${row.class.padEnd(6)} ${oneLine(row.cause)}`);
|
|
1741
1656
|
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
@@ -1749,18 +1664,17 @@ export function gateLines(answer, state) {
|
|
|
1749
1664
|
// the job summary. Both list every part, its state and its findings.
|
|
1750
1665
|
// ---------------------------------------------------------------------------
|
|
1751
1666
|
|
|
1752
|
-
const MARK = { filled: '+', empty: '-', stale: '~', 'not
|
|
1753
|
-
const label = (part) => part.procedure ? `procedure ${part.procedure}` : part.path ?? part.id;
|
|
1667
|
+
const MARK = { filled: '+', empty: '-', stale: '~', 'not listed': '.' };
|
|
1668
|
+
const label = (part) => part.procedure ? `procedure ${part.procedure}` : part.id === 'areas' ? 'areas in atlas.yaml' : part.path ?? part.id;
|
|
1754
1669
|
|
|
1755
|
-
// The one number the owner sees is the count of
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1670
|
+
// The one number the owner sees is the count of LISTED parts not filled. A
|
|
1671
|
+
// part the repo does not list is not a gap (review finding S2-1); its row
|
|
1672
|
+
// still shows, so nothing is hidden.
|
|
1758
1673
|
export function headline(report) {
|
|
1759
|
-
if (!report.spec) return 'Atlas repo shape: the check could not run.';
|
|
1674
|
+
if (!report.spec || !report.files?.length) return 'Atlas repo shape: the check could not run.';
|
|
1760
1675
|
const { counts: seen } = report;
|
|
1761
|
-
return `Atlas repo shape: ${seen.
|
|
1762
|
-
+
|
|
1763
|
-
+ `Kind: ${report.kind}. Spec: ${report.spec}. Check version: ${report.check_version}.`;
|
|
1676
|
+
return `Atlas repo shape: ${seen.listed_not_filled} of ${seen.listed} listed part(s) not filled. `
|
|
1677
|
+
+ `Spec: ${report.spec}. Check version: ${report.check_version}.`;
|
|
1764
1678
|
}
|
|
1765
1679
|
|
|
1766
1680
|
export function toText(report) {
|
|
@@ -1795,7 +1709,7 @@ export function noLossLines(state) {
|
|
|
1795
1709
|
const read = state.base_head ? state.base_head.slice(0, 12) : 'a commit the tree does not name';
|
|
1796
1710
|
const promised = state.base_sha ? `, named ${state.base_sha.slice(0, 12)} by the pull request` : '';
|
|
1797
1711
|
out.push(` base ${read} in ${state.base_dir}${promised}`);
|
|
1798
|
-
if (state.base_facts) out.push(` scored both trees as
|
|
1712
|
+
if (state.base_facts) out.push(` scored both trees as spec ${state.base_facts.spec}, with the parts the base lists`);
|
|
1799
1713
|
if (state.parts_compared) out.push(` ${state.parts_compared} instruction-class part(s) were filled at the base`);
|
|
1800
1714
|
if (state.broken) out.push(` NO BASE TREE: ${state.broken}`);
|
|
1801
1715
|
for (const row of state.owner ?? []) out.push(` OWNER (${row.case}): ${row.why}`);
|
|
@@ -1815,8 +1729,8 @@ export function toMarkdown(report) {
|
|
|
1815
1729
|
out.push('');
|
|
1816
1730
|
}
|
|
1817
1731
|
for (const file of report.files) {
|
|
1818
|
-
out.push(`### ${file.number}. ${file.title}`, '', '| Part |
|
|
1819
|
-
for (const part of file.parts) out.push(`| \`${label(part)}\` | ${part.
|
|
1732
|
+
out.push(`### ${file.number}. ${file.title}`, '', '| Part | Listed | State | Findings |', '|---|---|---|---|');
|
|
1733
|
+
for (const part of file.parts) out.push(`| \`${label(part)}\` | ${part.listed ? 'yes' : 'no'} | ${part.state} | ${part.findings.map((finding) => finding.replaceAll('|', '\\|')).join('; ') || '—'} |`);
|
|
1820
1734
|
out.push('');
|
|
1821
1735
|
}
|
|
1822
1736
|
const instruction = report.by_reference.filter((row) => !row.exempt);
|
|
@@ -1851,11 +1765,12 @@ export function verdict(report, failOn) {
|
|
|
1851
1765
|
if (state?.regressions.length) {
|
|
1852
1766
|
return { green: false, why: `${state.regressions.length} instruction-class part(s) went backwards against the base: ${state.regressions.map((row) => row.label).join(', ')}` };
|
|
1853
1767
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1768
|
+
// Every listed part is required, so "required" and "any" ask the same thing
|
|
1769
|
+
// from check version 10. Both stay, so no workflow breaks.
|
|
1770
|
+
if (failOn !== 'error' && report.counts.listed_not_filled) return { green: false, why: `${report.counts.listed_not_filled} listed part(s) are not filled` };
|
|
1856
1771
|
const owned = state?.owner?.length ? `; only the owner approves this change (${[...new Set(state.owner.map((row) => row.case))].join(', ')}), so the gate says "owner"` : '';
|
|
1857
1772
|
const noLossWhy = state ? `; no instruction-class part went backwards${owned}` : '';
|
|
1858
|
-
return { green: true, why: `${report.counts.
|
|
1773
|
+
return { green: true, why: `${report.counts.listed_not_filled} listed part(s) are not filled; fail-on is "${failOn}"${noLossWhy}` };
|
|
1859
1774
|
}
|
|
1860
1775
|
|
|
1861
1776
|
// ---------------------------------------------------------------------------
|
|
@@ -1914,7 +1829,7 @@ export function run(argv, env = process.env) {
|
|
|
1914
1829
|
if (options.json) writeFileSync(options.json, `${JSON.stringify({ ...report, fail_on: options.failOn, green: decision.green }, null, 2)}\n`);
|
|
1915
1830
|
if (options.summary) writeFileSync(options.summary, toMarkdown(report));
|
|
1916
1831
|
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, toMarkdown(report));
|
|
1917
|
-
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `not-filled=${report.counts.not_filled}\nrequired-not-filled=${report.counts.required_not_filled}\
|
|
1832
|
+
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `not-filled=${report.counts.not_filled}\nrequired-not-filled=${report.counts.required_not_filled}\nlisted=${report.counts.listed}\nprocedures=${report.counts.procedures}\njobs=${report.counts.jobs}\ngreen=${decision.green}\n`);
|
|
1918
1833
|
return { report, decision, text, options };
|
|
1919
1834
|
}
|
|
1920
1835
|
|