@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/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 applicable. It leads with the count of required parts
16
- // that are not filled.
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 = 9;
41
+ export const CHECK_VERSION = 10;
42
42
 
43
43
  // ---------------------------------------------------------------------------
44
- // The spec. One object per version. The check reads a version it has here and
45
- // refuses any other.
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
- export const SPEC_1 = {
49
- "spec": 1,
50
- "title": "The files every repo carries",
51
- "authority": "the Atlas M2 study, design page D1 with its D7 amendment, and the lock block, slice L1.",
52
- "states": {
53
- "filled": "The part exists and matches the repo.",
54
- "empty": "The part is missing, or it holds nothing.",
55
- "stale": "The part exists but disagrees with the repo.",
56
- "not applicable": "This repo kind does not need the part."
57
- },
58
- "kinds": {
59
- "service": "A running product with a deploy target, logs and metrics.",
60
- "library": "Code another repo takes as a dependency. It is not deployed.",
61
- "schema": "Migrations and their release. It has no service and no library build.",
62
- "plugin": "A Claude Code plugin. It is installed, not deployed, and the merge proves it.",
63
- "cli": "A command-line tool people install. It is not deployed."
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
- "files": [
86
+ files: [
66
87
  {
67
- "id": "entry-map",
68
- "number": 1,
69
- "title": "Entry map",
70
- "class": "instruction",
71
- "written_by": "the helper drafts; a person merges",
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
- "id": "path-rules",
105
- "number": 2,
106
- "title": "Path rules",
107
- "class": "instruction",
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
- "id": "procedures",
128
- "number": 3,
129
- "title": "Procedures",
130
- "class": "instruction",
131
- "written_by": "the helper; a person merges",
132
- "parts": [
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
- "id": "repo-facts",
189
- "number": 4,
190
- "title": "Repo facts",
191
- "class": "instruction",
192
- "written_by": "an agent proposes; the owner merges",
193
- "parts": [
194
- {
195
- "id": "root",
196
- "path": "atlas.yaml",
197
- "holds": "The spec version, the kind, the product id, the repo id, the procedure map, and one pointer per section.",
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
- "id": "docs-index",
302
- "number": 5,
303
- "title": "Docs index",
304
- "class": "docs",
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
- "id": "tooling",
324
- "number": 6,
325
- "title": "Tooling",
326
- "class": "instruction",
327
- "written_by": "the helper; a person merges",
328
- "parts": [
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
- "id": "atlas-check",
331
- "path": ".github/workflows/atlas-gate.yml",
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
- "id": "linters",
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
- "by_reference": {
378
- "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.",
379
- "scanned": [
380
- "CLAUDE.md",
381
- "AGENTS.md",
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
- // Spec 2 adds one part: the check file itself, scored inside file 6.
403
- //
404
- // Why a new version and not a line in spec 1. Adding a scored part changes what
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({ 1: SPEC_1, 2: SPEC_2 });
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). `kind`
767
- // and `spec` decide which files a repo needs, so a silent override there is
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 applicable']);
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
- function entryFor(part, kind) {
960
- const state = part.kind?.[kind];
961
- if (!state) throw new SpecError(`the spec gives part ${part.id} no state for kind ${kind}`);
962
- return state;
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 names the kind, and the kind
1020
- // decides which parts a repo needs. So this scanner only ever runs on a file
1021
- // that parsed. The two dead guards that stood here are gone (acceptance review
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
- if (!context.facts.procedures) findings.push('atlas.yaml has no procedures map, so no procedure can be found');
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 kind, 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
- for (const pattern of context.secretPatterns) {
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 text = readText(context.root, part.path);
1116
- if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
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', [`${onDisk.length} file(s) listed`]);
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
- // `scoreAs` is the no-loss rule's one lever. It replaces the kind and the spec this
1439
- // tree declares with the ones handed in, so two trees are scored by the same
1440
- // yardstick. Without it the base's `kind: service` and the head's
1441
- // `kind: library` would be two different spec tables, and the comparison would
1442
- // mean nothing (architect finding P2). Nothing else about the scan changes: the
1443
- // tree still speaks for itself about every part.
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 kind');
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
- if (spec && !spec.kinds[kind]) { errors.push(`atlas.yaml names kind "${kind}"; spec ${spec.spec} knows ${Object.keys(spec.kinds).join(', ')}`); spec = null; }
1469
- if (!spec) return { ok: false, check_version: CHECK_VERSION, repo: repoId, kind, spec: wantedSpec ?? null, scored_as: scoreAs, product: facts?.product ?? null, files: [], by_reference: [], counts: counts([]), errors };
1470
-
1471
- // The no-loss rule can score a tree at another tree's pointers: the paths
1472
- // that atlas.yaml names for the procedures and the facts files. So a part
1473
- // that a pull request moves is also scored where the base had it (roadmap R7
1474
- // line 3).
1475
- const pointed = scoreAs?.pointers ? { ...facts, files: scoreAs.pointers.files, procedures: scoreAs.pointers.procedures } : facts;
1476
- const context = { root, facts: pointed, factsError, repoId, secretPatterns: usable, errors };
1477
- // atlas.yaml's own values reach the report: the kind, the repo id, a
1478
- // procedure's skill name, every pointer path. Scan it before any of them is
1479
- // quoted (acceptance review of batch 16, note S3-2).
1480
- secretIn(context, 'atlas.yaml', readText(root, 'atlas.yaml'));
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.map((part) => {
1488
- const required = entryFor(part, kind);
1489
- if (required === 'not applicable') return { ...result(part, 'not applicable', [`a ${kind} repo does not need it`]), required };
1490
- const check = CHECKS[part.check];
1491
- if (!check) throw new SpecError(`the spec names check "${part.check}", which this version of the check does not have`);
1492
- return { ...check(context, part), required };
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, kind, spec: spec.spec, scored_as: scoreAs, product: facts.product ?? null, files, by_reference: byReference(context, spec), counts: counts(files), errors };
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
- export function counts(files) {
1499
- const out = { filled: 0, empty: 0, stale: 0, 'not applicable': 0, required: 0, optional: 0, not_filled: 0, required_not_filled: 0, optional_not_filled: 0 };
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 === 'required') out.required += 1;
1503
- if (part.required === 'optional') out.optional += 1;
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
- // names the `kind`, the kind decides which parts are required, and `atlas.yaml`
1529
- // is inside the diff being scored, so `kind: service` to `kind: library` turns
1530
- // four required parts into "not applicable" and the count falls. Scoring per
1531
- // part, with the base tree's `kind` and `spec`, closes both (findings P2, P20).
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 kind and spec score both trees, so no case needs to.
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 kind or a spec, so
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 kind and spec are unknown: ${baseRead.error}`);
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 kind and spec score both trees.
1655
- const scoreAs = { kind: baseRead.facts.kind ?? null, spec: baseRead.facts.spec ?? null };
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
- out.base_facts = scoreAs;
1658
- out.head_facts = { kind: headRead.facts?.kind ?? null, spec: headRead.facts?.spec ?? null };
1659
-
1660
- if (String(out.head_facts.kind) !== String(scoreAs.kind)) {
1661
- owner('kind', `atlas.yaml names kind "${scoreAs.kind}" at the base and "${out.head_facts.kind}" at the head; this pull request changes which parts the repo needs`);
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(scoreAs.spec)) && headSpecUnread(headRead.facts);
1664
- if (String(out.head_facts.spec) !== String(scoreAs.spec)) {
1665
- owner('spec', `atlas.yaml names spec "${scoreAs.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` : ''}`);
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 pointers = { files: baseRead.facts.files, procedures: baseRead.facts.procedures };
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 ? `kind ${oneLine(state.base_facts.kind)}, spec ${oneLine(state.base_facts.spec)}` : 'no yardstick';
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 applicable': '.' };
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 REQUIRED files not filled.
1756
- // An optional file that is empty is not a gap: the spec says optional (review
1757
- // finding S2-1). The optional count follows it, so nothing is hidden.
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.required_not_filled} of ${seen.required} required file(s) not filled; `
1762
- + `${seen.optional_not_filled} of ${seen.optional} optional; ${seen['not applicable']} not applicable. `
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 kind ${state.base_facts.kind}, spec ${state.base_facts.spec}, from the base tree`);
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 | Needed | State | Findings |', '|---|---|---|---|');
1819
- for (const part of file.parts) out.push(`| \`${label(part)}\` | ${part.required} | ${part.state} | ${part.findings.map((finding) => finding.replaceAll('|', '\\|')).join('; ') || '—'} |`);
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
- if (failOn === 'required' && report.counts.required_not_filled) return { green: false, why: `${report.counts.required_not_filled} required file(s) are not filled` };
1855
- if (failOn === 'any' && report.counts.not_filled) return { green: false, why: `${report.counts.not_filled} file(s) are not filled` };
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.required_not_filled} required file(s) are not filled; fail-on is "${failOn}"${noLossWhy}` };
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}\nkind=${report.kind ?? ''}\ngreen=${decision.green}\n`);
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