@arjunkhera/atlas 0.4.12 → 0.4.14
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/agents/verifier.md +3 -0
- package/door/cli.mjs +11 -4
- package/door/lib/design.mjs +1 -1
- package/door/lib/doc-sources.mjs +284 -0
- package/door/lib/fails-before.mjs +16 -5
- package/door/lib/gate.mjs +19 -1
- package/door/lib/install.mjs +34 -0
- package/door/lib/upkeep.mjs +39 -3
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/sdlc-task/SKILL.md +7 -4
- package/skills/sdlc-task/design/README.md +6 -0
package/agents/verifier.md
CHANGED
|
@@ -132,3 +132,6 @@ private key is cited by path and line only.
|
|
|
132
132
|
|
|
133
133
|
**Never hold a write door.** You never receive the `atlas-work` verb server
|
|
134
134
|
or the knowledge-graph MCP server. If a briefing hands you one, refuse and say so.
|
|
135
|
+
Judge this from the names of the functions you can call. A text that names
|
|
136
|
+
the server, such as the repo's entry file, is not a server you hold (item
|
|
137
|
+
cfb9d22a).
|
package/door/cli.mjs
CHANGED
|
@@ -138,7 +138,9 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
138
138
|
--dry-run print what it would open; change nothing
|
|
139
139
|
The token is only GITHUB_TOKEN. The atlas-jobs workflow runs both, once a day
|
|
140
140
|
|
|
141
|
-
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, and in a pull request its tracker item
|
|
141
|
+
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, and in a pull request its tracker item
|
|
142
|
+
atlas upkeep replay --root <repo> what the doc-sources rule would have asked of the last merges; writes nothing
|
|
143
|
+
--last <n> how many merges on the first-parent line; default 30
|
|
142
144
|
--event <file> the GitHub pull request event; adds the item check
|
|
143
145
|
--json print JSON
|
|
144
146
|
|
|
@@ -199,7 +201,7 @@ export const FLAGS = Object.freeze({
|
|
|
199
201
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
200
202
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
|
|
201
203
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
202
|
-
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
204
|
+
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value' },
|
|
203
205
|
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value' },
|
|
204
206
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
205
207
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
@@ -239,7 +241,12 @@ export function options(argv, declared = {}) {
|
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
const rootOf = (chosen) => resolve(chosen.root === undefined || chosen.root === true ? process.cwd() : chosen.root);
|
|
242
|
-
|
|
244
|
+
// A reader that closes early, such as `atlas ste docs | head`, is not an error
|
|
245
|
+
// (349cd191). The command stops writing and runs to its end, so its exit code
|
|
246
|
+
// and the files it writes stay whole.
|
|
247
|
+
let readerClosed = false;
|
|
248
|
+
const line = (text = '') => { if (!readerClosed) process.stdout.write(`${text}\n`); };
|
|
249
|
+
process.stdout.on('error', (error) => { if (error.code === 'EPIPE') { readerClosed = true; return; } throw error; });
|
|
243
250
|
|
|
244
251
|
function toolingCommand(chosen) {
|
|
245
252
|
const root = rootOf(chosen);
|
|
@@ -437,7 +444,7 @@ function jobsVerb(chosen) {
|
|
|
437
444
|
}
|
|
438
445
|
|
|
439
446
|
function upkeepVerb(chosen) {
|
|
440
|
-
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
|
|
447
|
+
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last }, rootOf(chosen), line);
|
|
441
448
|
}
|
|
442
449
|
|
|
443
450
|
function gateVerb(chosen) {
|
package/door/lib/design.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export const PARTS_FILE = join(PACKAGE_ROOT, 'skills', 'sdlc-task', 'design', 'p
|
|
|
27
27
|
export const DESIGN_FILE = 'design.yaml';
|
|
28
28
|
export const NEEDS = Object.freeze(['needed', 'optional', 'none', 'shared']);
|
|
29
29
|
// design.yaml holds what a design is, never where it stands.
|
|
30
|
-
export const DESIGN_KEYS = Object.freeze(['title', 'item', 'product', 'kinds', 'shared', 'look']);
|
|
30
|
+
export const DESIGN_KEYS = Object.freeze(['title', 'item', 'product', 'kinds', 'shared', 'look', 'sources', 'sources_why']);
|
|
31
31
|
export const STATE_KEYS = Object.freeze(['status', 'state', 'stage', 'approved', 'locked', 'lock', 'shipped', 'delivered']);
|
|
32
32
|
export const PART_KEYS = Object.freeze(['part', 'title']);
|
|
33
33
|
// What a part's `picture` key may say: `one` means the part holds at least one
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Docs name their code (roadmap R39, item aeaa89ad). Code, no model, no key,
|
|
2
|
+
// no network. `atlas upkeep check` and `atlas upkeep replay` use it.
|
|
3
|
+
//
|
|
4
|
+
// Target design 4.5: "Each doc and each procedure names the code it
|
|
5
|
+
// describes, in a short list of paths at its top." Flow 5 step 3: a pull
|
|
6
|
+
// request that changes that code updates the doc, or marks it "still true".
|
|
7
|
+
//
|
|
8
|
+
// A unit is what names its code:
|
|
9
|
+
// 1. a .md file under docs/, by its front matter: `sources:` (a list of
|
|
10
|
+
// paths from the repo root; a path that ends in / is a folder), or
|
|
11
|
+
// `sources_why:` (why it names no code);
|
|
12
|
+
// 2. a design folder (a folder under docs/ that holds design.yaml), by the
|
|
13
|
+
// same two keys in design.yaml. Its parts are one unit with it;
|
|
14
|
+
// 3. a procedure: the SKILL.md that atlas.yaml `procedures:` names, by the
|
|
15
|
+
// same two keys in its front matter.
|
|
16
|
+
// A doc that must not change (a locked text, the owner's own words) can name
|
|
17
|
+
// its code in a `sources.yaml` beside it, or in a folder above it under docs/:
|
|
18
|
+
// `files: { <name>: { sources: [...] } }` for one file, or `sources:` and
|
|
19
|
+
// `why:` for every doc below that names nothing itself.
|
|
20
|
+
//
|
|
21
|
+
// A generated page (`generated: true`) is kept by the code that writes it,
|
|
22
|
+
// so a pull request that touches its code is never asked to mark it.
|
|
23
|
+
//
|
|
24
|
+
// The rule reads no repo kind: a service, a library and a plugin use it alike.
|
|
25
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
26
|
+
import { join, posix } from 'node:path';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
29
|
+
import { markdownFiles } from './upkeep.mjs';
|
|
30
|
+
|
|
31
|
+
const SIDECAR = 'sources.yaml';
|
|
32
|
+
const DESIGN = 'design.yaml';
|
|
33
|
+
const MAX_LISTED = 20;
|
|
34
|
+
|
|
35
|
+
const readText = (root, path) => { try { return readFileSync(join(root, path), 'utf8'); } catch { return null; } };
|
|
36
|
+
const isDir = (root, path) => { try { return statSync(join(root, path)).isDirectory(); } catch { return false; } };
|
|
37
|
+
const yamlOf = (text) => { try { return parseYaml(text) ?? {}; } catch { return null; } };
|
|
38
|
+
|
|
39
|
+
function headOf(text) {
|
|
40
|
+
const match = String(text ?? '').replace(/\r\n?/g, '\n').match(/^---\n([\s\S]*?)\n---\n?/);
|
|
41
|
+
return match ? yamlOf(match[1]) : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The declared names of one source of keys: `sources` (and `sources_why`,
|
|
45
|
+
// or `why` in a sidecar).
|
|
46
|
+
function declared(keys, whyKey) {
|
|
47
|
+
if (!keys || typeof keys !== 'object') return null;
|
|
48
|
+
const why = keys[whyKey];
|
|
49
|
+
if (keys.sources === undefined && (why === undefined || why === null || why === '')) return null;
|
|
50
|
+
const list = keys.sources === undefined || keys.sources === null || keys.sources === 'none' ? [] : keys.sources;
|
|
51
|
+
return { sources: Array.isArray(list) ? list.map((one) => String(one).trim()).filter(Boolean) : [String(list)], why: why ? String(why) : null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The nearest folder at or above `file`, under docs/, that holds design.yaml.
|
|
55
|
+
function designFolderOf(root, file) {
|
|
56
|
+
let dir = posix.dirname(file);
|
|
57
|
+
while (dir && dir !== '.' && dir !== 'docs') {
|
|
58
|
+
if (existsSync(join(root, dir, DESIGN))) return dir;
|
|
59
|
+
dir = posix.dirname(dir);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What the sidecars at or above `file` say about it, nearest first.
|
|
65
|
+
function fromSidecar(root, file) {
|
|
66
|
+
const name = posix.basename(file);
|
|
67
|
+
let dir = posix.dirname(file);
|
|
68
|
+
let rel = name;
|
|
69
|
+
while (dir && dir !== '.') {
|
|
70
|
+
const text = readText(root, posix.join(dir, SIDECAR));
|
|
71
|
+
if (text !== null) {
|
|
72
|
+
const data = yamlOf(text);
|
|
73
|
+
if (data === null) return { error: `${posix.join(dir, SIDECAR)} cannot be read` };
|
|
74
|
+
const own = declared(data.files?.[rel], 'why');
|
|
75
|
+
if (own) return { ...own, where: posix.join(dir, SIDECAR) };
|
|
76
|
+
const all = declared(data, 'why');
|
|
77
|
+
if (all) return { ...all, where: posix.join(dir, SIDECAR) };
|
|
78
|
+
}
|
|
79
|
+
if (dir === 'docs') break;
|
|
80
|
+
rel = `${posix.basename(dir)}/${rel}`;
|
|
81
|
+
dir = posix.dirname(dir);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The procedures of atlas.yaml, as SKILL.md paths that exist.
|
|
87
|
+
function procedureFiles(root) {
|
|
88
|
+
const facts = yamlOf(readText(root, 'atlas.yaml') ?? '') ?? {};
|
|
89
|
+
const named = Object.values(facts.procedures ?? {}).filter((one) => typeof one === 'string' && one.trim());
|
|
90
|
+
return [...new Set(named)].map((name) => posix.join('.claude/skills', name, 'SKILL.md')).filter((path) => existsSync(join(root, path))).sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Every unit of the repo, with what it names. `named` is null when the unit
|
|
94
|
+
// names neither its code nor a reason.
|
|
95
|
+
export function docUnits(root) {
|
|
96
|
+
const units = [];
|
|
97
|
+
const folders = new Set();
|
|
98
|
+
for (const file of markdownFiles(root, 'docs')) {
|
|
99
|
+
if (file.startsWith('docs/site/')) continue;
|
|
100
|
+
const folder = designFolderOf(root, file);
|
|
101
|
+
if (folder) {
|
|
102
|
+
if (folders.has(folder)) continue;
|
|
103
|
+
folders.add(folder);
|
|
104
|
+
const where = posix.join(folder, DESIGN);
|
|
105
|
+
units.push({ unit: folder, kind: 'design', where, named: declared(yamlOf(readText(root, where) ?? ''), 'sources_why') ?? fromSidecar(root, where), generated: false });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const head = headOf(readText(root, file));
|
|
109
|
+
units.push({ unit: file, kind: 'doc', where: file, named: declared(head, 'sources_why') ?? fromSidecar(root, file), generated: head?.generated === true });
|
|
110
|
+
}
|
|
111
|
+
for (const file of procedureFiles(root)) {
|
|
112
|
+
units.push({ unit: posix.dirname(file), kind: 'procedure', where: file, named: declared(headOf(readText(root, file)), 'sources_why'), generated: false });
|
|
113
|
+
}
|
|
114
|
+
return units;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Line 1: each unit names its code, or why it names none.
|
|
118
|
+
export const unnamed = (units) => units.filter((one) => !one.named || one.named.error);
|
|
119
|
+
|
|
120
|
+
// Line 2: each named path resolves. A path that does not makes its doc stale.
|
|
121
|
+
export function unresolved(root, units) {
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const one of units) {
|
|
124
|
+
for (const source of one.named?.sources ?? []) {
|
|
125
|
+
const path = source.replace(/\/+$/, '');
|
|
126
|
+
if (!path || path.startsWith('/') || path.split('/').includes('..') || !existsSync(join(root, path))) out.push({ unit: one.unit, where: one.where, source });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Does a changed path fall under one named source? A folder source covers
|
|
133
|
+
// each path below it.
|
|
134
|
+
export function covers(root, source, path) {
|
|
135
|
+
const clean = source.replace(/\/+$/, '');
|
|
136
|
+
if (path === clean) return true;
|
|
137
|
+
const folder = source.endsWith('/') || isDir(root, clean);
|
|
138
|
+
return folder && path.startsWith(`${clean}/`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Is a unit itself changed by the pull request?
|
|
142
|
+
const touchedItself = (unit, changed) => changed.some((path) => path === unit.unit || path === unit.where || path.startsWith(`${unit.unit}/`));
|
|
143
|
+
|
|
144
|
+
// The pull request text that marks a doc "still true": one line that names the
|
|
145
|
+
// unit (its path, or for a design folder its folder) and says "still true".
|
|
146
|
+
export function stillTrue(body, unit) {
|
|
147
|
+
const text = String(body ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
148
|
+
return text.split('\n').some((line) => /still true/i.test(line) && (line.includes(unit.unit) || line.includes(unit.where)));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Line 4: the units whose named code a pull request changes. Each one needs an
|
|
152
|
+
// update in the same pull request, or a "still true" mark in its text.
|
|
153
|
+
export function touchedUnits(root, units, changed, body = '') {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const one of units) {
|
|
156
|
+
if (one.generated || !one.named?.sources?.length) continue;
|
|
157
|
+
const hits = changed.filter((path) => one.named.sources.some((source) => covers(root, source, path)));
|
|
158
|
+
if (!hits.length) continue;
|
|
159
|
+
const updated = touchedItself(one, changed);
|
|
160
|
+
const marked = !updated && stillTrue(body, one);
|
|
161
|
+
out.push({ unit: one.unit, where: one.where, hits, updated, marked, ok: updated || marked });
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
167
|
+
|
|
168
|
+
// The paths a range changes, old and new names both. A range of the form
|
|
169
|
+
// `base...head` starts from the commit the two share.
|
|
170
|
+
export function changedPaths(root, range) {
|
|
171
|
+
const out = git(root, ['diff', '--name-status', '-M', '--no-color', range]);
|
|
172
|
+
const paths = new Set();
|
|
173
|
+
for (const row of out.split('\n').filter(Boolean)) for (const path of row.split('\t').slice(1)) paths.add(path);
|
|
174
|
+
return [...paths].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The findings for `atlas upkeep check`. `pr` is the event's pull request, or
|
|
178
|
+
// null on the main branch. `gate` says whether a touched doc with no update
|
|
179
|
+
// and no mark is a finding (true) or a note (false).
|
|
180
|
+
export function docSourceFindings(root, { pr = null, gate = false } = {}) {
|
|
181
|
+
const units = docUnits(root);
|
|
182
|
+
const findings = [];
|
|
183
|
+
const notes = [];
|
|
184
|
+
// A repo where no doc names its code yet has not taken up the rule: one
|
|
185
|
+
// note, not a finding for each doc.
|
|
186
|
+
const adopted = units.some((one) => one.named && !one.named.error && !one.generated);
|
|
187
|
+
if (!adopted && units.length) notes.push(`doc sources: no doc names its code yet (${units.length} doc(s) and procedure(s)). Add sources: or sources_why: to each`);
|
|
188
|
+
for (const one of adopted ? unnamed(units) : []) findings.push(`doc sources: ${one.where} names no code. Add sources: (paths) or sources_why: (why none)${one.named?.error ? `; ${one.named.error}` : ''}`);
|
|
189
|
+
for (const one of unresolved(root, units).slice(0, MAX_LISTED)) findings.push(`doc sources: ${one.where} is stale: it names ${one.source}, which does not exist`);
|
|
190
|
+
let touched = null;
|
|
191
|
+
const base = pr?.base?.sha;
|
|
192
|
+
const head = pr?.head?.sha;
|
|
193
|
+
if (base && head && existsSync(join(root, '.git'))) {
|
|
194
|
+
let range = null;
|
|
195
|
+
try {
|
|
196
|
+
range = changedPaths(root, `${base}...${head}`);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
findings.push(`doc sources: the changed paths could not be read: ${String(error.message).split('\n')[0]}`);
|
|
199
|
+
}
|
|
200
|
+
if (range) {
|
|
201
|
+
touched = touchedUnits(root, units, range, pr.body);
|
|
202
|
+
for (const one of touched.filter((t) => !t.ok)) {
|
|
203
|
+
const text = `doc sources: ${one.unit} names ${one.hits.slice(0, 3).join(', ')}${one.hits.length > 3 ? ` and ${one.hits.length - 3} more` : ''}. Update it in this pull request, or write "${one.unit}: still true" in its text`;
|
|
204
|
+
(gate ? findings : notes).push(text);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { units: units.length, findings, notes, touched };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------- the replay (R39 line 3) ----------
|
|
212
|
+
|
|
213
|
+
// The lines of the old side that a commit changes, per old path.
|
|
214
|
+
function changedLines(root, parent, commit) {
|
|
215
|
+
const out = git(root, ['diff', '-U0', '-M', '--no-color', parent, commit]);
|
|
216
|
+
const lines = new Map();
|
|
217
|
+
let file = null;
|
|
218
|
+
for (const row of out.split('\n')) {
|
|
219
|
+
if (row.startsWith('--- ')) { file = row === '--- /dev/null' ? null : row.slice(6); continue; }
|
|
220
|
+
const hunk = row.match(/^@@ -(\d+)(?:,(\d+))? /);
|
|
221
|
+
if (hunk && file) {
|
|
222
|
+
const start = Number(hunk[1]);
|
|
223
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
224
|
+
if (!lines.has(file)) lines.set(file, []);
|
|
225
|
+
// A pure insert (count 0) lands between start and start + 1.
|
|
226
|
+
lines.get(file).push([start, count ? start + count - 1 : start + 1]);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The `path:line` citations in a unit's text, resolved against the repo root,
|
|
233
|
+
// then against each folder it names, then against packages/<name>/.
|
|
234
|
+
const CITE = /`?([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5}):(\d+)(?:-(\d+))?/g;
|
|
235
|
+
function citedLines(root, unit) {
|
|
236
|
+
const files = unit.kind === 'design'
|
|
237
|
+
? markdownFiles(root, unit.unit).concat([unit.where])
|
|
238
|
+
: [unit.where];
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
const text = readText(root, file) ?? '';
|
|
242
|
+
for (const m of text.matchAll(CITE)) {
|
|
243
|
+
const tries = [m[1], ...(unit.named?.sources ?? []).filter((s) => s.endsWith('/')).map((s) => posix.join(s, m[1])), posix.join('packages/atlas', m[1])];
|
|
244
|
+
const path = tries.find((one) => existsSync(join(root, one)));
|
|
245
|
+
if (path) out.push({ path, from: Number(m[2]), to: Number(m[3] ?? m[2]) });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// For the last `last` merges on the first-parent line of `ref`, count the
|
|
252
|
+
// units that each rule pulls in: rule "file" (a changed path falls under a
|
|
253
|
+
// named source) and rule "line" (a changed line is a line the unit cites).
|
|
254
|
+
// It reads the units of the current tree, and writes nothing.
|
|
255
|
+
export function replay(root, { last = 30, ref = 'HEAD' } = {}) {
|
|
256
|
+
const units = docUnits(root).filter((one) => !one.generated && one.named?.sources?.length);
|
|
257
|
+
const cites = new Map(units.map((one) => [one.unit, citedLines(root, one)]));
|
|
258
|
+
const merges = git(root, ['log', '--first-parent', '--merges', `-n${Number(last)}`, '--format=%H %s', ref]).split('\n').filter(Boolean);
|
|
259
|
+
const rows = [];
|
|
260
|
+
const byUnit = new Map(units.map((one) => [one.unit, { file: 0, line: 0 }]));
|
|
261
|
+
for (const row of merges) {
|
|
262
|
+
const [sha, ...words] = row.split(' ');
|
|
263
|
+
const changed = changedPaths(root, `${sha}^1..${sha}`);
|
|
264
|
+
const lines = changedLines(root, `${sha}^1`, sha);
|
|
265
|
+
const file = touchedUnits(root, units, changed);
|
|
266
|
+
const line = units.filter((one) => cites.get(one.unit).some((c) => (lines.get(c.path) ?? []).some(([a, b]) => a <= c.to && c.from <= b)));
|
|
267
|
+
for (const one of file) byUnit.get(one.unit).file += 1;
|
|
268
|
+
for (const one of line) byUnit.get(one.unit).line += 1;
|
|
269
|
+
rows.push({ sha: sha.slice(0, 7), title: words.join(' '), changed: changed.length, file: file.map((one) => ({ unit: one.unit, updated: one.updated })), line: line.map((one) => one.unit) });
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
merges: rows.length,
|
|
273
|
+
units: units.length,
|
|
274
|
+
rows,
|
|
275
|
+
totals: {
|
|
276
|
+
file: rows.reduce((n, r) => n + r.file.length, 0),
|
|
277
|
+
fileNotUpdated: rows.reduce((n, r) => n + r.file.filter((one) => !one.updated).length, 0),
|
|
278
|
+
line: rows.reduce((n, r) => n + r.line.length, 0),
|
|
279
|
+
mergesWithFile: rows.filter((r) => r.file.length).length,
|
|
280
|
+
mergesWithLine: rows.filter((r) => r.line.length).length,
|
|
281
|
+
},
|
|
282
|
+
byUnit: [...byUnit].filter(([, n]) => n.file || n.line).map(([unit, n]) => ({ unit, ...n })).sort((a, b) => b.file - a.file || b.line - a.line || a.unit.localeCompare(b.unit)),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -180,7 +180,8 @@ export function casesOf(text, path) {
|
|
|
180
180
|
|
|
181
181
|
// The text of each <testcase> element, in order, so a failure can be sorted.
|
|
182
182
|
function caseTexts(xml) {
|
|
183
|
-
|
|
183
|
+
// Node writes a ">" in a name as it is, so a quoted value may hold one.
|
|
184
|
+
return [...String(xml).matchAll(/<testcase\b(?:[^>"']|"[^"]*"|'[^']*')*?(?:\/>|>([\s\S]*?)<\/testcase>)/g)].map((m) => m[1] ?? '');
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
// The results of one report: Map of case name to pass, assertion, error or skipped.
|
|
@@ -193,7 +194,9 @@ export function readReport(xml) {
|
|
|
193
194
|
cases.forEach((c, i) => {
|
|
194
195
|
let result = c.result;
|
|
195
196
|
if (result === 'fail') result = /AssertionError|ERR_ASSERTION/.test(`${c.message ?? ''}\n${texts[i] ?? ''}`) ? 'assertion' : 'error';
|
|
196
|
-
|
|
197
|
+
// The junit reporter of node writes a double quote as "&quot;", so one
|
|
198
|
+
// unescape leaves """ (f2d07e35). Undo the second escape here.
|
|
199
|
+
const name = c.name.replaceAll('"', '"').replace(/\[.*\]$/, '');
|
|
197
200
|
// When one name holds several results, the worst one counts: a fail beats a pass.
|
|
198
201
|
if (!out.has(name) || rank[result] > rank[out.get(name)]) out.set(name, result);
|
|
199
202
|
});
|
|
@@ -209,6 +212,11 @@ export function resultOf(before, after) {
|
|
|
209
212
|
return 'error before';
|
|
210
213
|
}
|
|
211
214
|
|
|
215
|
+
// A case with only a changed helper makes no new claim, so passing on the
|
|
216
|
+
// old code is no fault: it reads "helper only". It proves nothing either, and
|
|
217
|
+
// passes-after checks that its old text still passes on the new code.
|
|
218
|
+
const helperResult = (change, result) => (change === 'helper' && result === 'passes before' ? 'helper only' : result);
|
|
219
|
+
|
|
212
220
|
export const RED_RESULTS = Object.freeze(['passes before', 'fails after', 'skipped', 'duplicate']);
|
|
213
221
|
|
|
214
222
|
// ---------------------------------------------------------------- the plan
|
|
@@ -243,7 +251,10 @@ export function plan({ root, base, head = 'HEAD', list = null }) {
|
|
|
243
251
|
else {
|
|
244
252
|
if (r.status !== 'added' && before.rest !== after.rest) all = true;
|
|
245
253
|
for (const [name, text] of after.cases) {
|
|
246
|
-
|
|
254
|
+
// A case whose own text is the same, in a file whose text outside the
|
|
255
|
+
// cases changed, has only a changed helper (item 46498d2e).
|
|
256
|
+
const same = before.cases.has(name) && squash(before.cases.get(name), r.path) === squash(text, r.path);
|
|
257
|
+
if (all || !same) picked.push({ name, change: !before.cases.has(name) ? 'added' : same ? 'helper' : 'changed', duplicate: after.duplicates.has(name) });
|
|
247
258
|
}
|
|
248
259
|
}
|
|
249
260
|
if (!picked.length && !all && r.status !== 'added') continue;
|
|
@@ -372,7 +383,7 @@ export function combine(prepared, oldRun, newRun) {
|
|
|
372
383
|
for (const c of picked) {
|
|
373
384
|
const before = b.cases?.[c.name] ?? 'missing';
|
|
374
385
|
const after = headCases[c.name] ?? 'missing';
|
|
375
|
-
answer.cases.push({ path: p.path, name: c.name, change: c.change, before, after, result: c.duplicate ? 'duplicate' : resultOf(before, after) });
|
|
386
|
+
answer.cases.push({ path: p.path, name: c.name, change: c.change, before, after, result: c.duplicate ? 'duplicate' : helperResult(c.change, resultOf(before, after)) });
|
|
376
387
|
}
|
|
377
388
|
}
|
|
378
389
|
return finish(answer, nothingProven(prepared, answer));
|
|
@@ -385,7 +396,7 @@ export function combine(prepared, oldRun, newRun) {
|
|
|
385
396
|
// file with no runner here, gets its proof elsewhere: the planted-fault
|
|
386
397
|
// procedure.
|
|
387
398
|
function nothingProven({ changed, productChanged }, answer) {
|
|
388
|
-
if (!productChanged || answer.cases.
|
|
399
|
+
if (!productChanged || answer.cases.some((c) => c.result !== 'helper only')) return false;
|
|
389
400
|
return changed.rows.some((r) => r.group === 'unit-test' && r.status !== 'removed' && runnerOf(r.path));
|
|
390
401
|
}
|
|
391
402
|
|
package/door/lib/gate.mjs
CHANGED
|
@@ -247,6 +247,22 @@ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [] }
|
|
|
247
247
|
return null;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
// Why one check run is not a job that ran in its workflow run, or null when
|
|
251
|
+
// it is. A job token with `checks: write` can post a check run under any name.
|
|
252
|
+
// GitHub files it under the GitHub Actions app, in the check suite of a run
|
|
253
|
+
// of the base file, and lists it as a job of that run. The probe of 26
|
|
254
|
+
// October (R10 P10, atlas-scratch#7) showed it: such a run had no runner, no
|
|
255
|
+
// labels and no steps. GitHub refuses to change a check run that a job made
|
|
256
|
+
// (atlas-scratch#8), so only a new one can lie, and it never had a runner.
|
|
257
|
+
export function jobRefusal(job, check, run) {
|
|
258
|
+
if (!job) return 'no job of the workflow run has this check run';
|
|
259
|
+
if (run?.id === undefined || Number(job.run_id) !== Number(run.id)) return 'a job of another workflow run';
|
|
260
|
+
// Only a green run can lie in the merge's favour. A red or cancelled job
|
|
261
|
+
// that never got a runner still counts, so an older green try cannot win.
|
|
262
|
+
if (check?.conclusion === 'success' && (job.runner_id === null || job.runner_id === undefined)) return 'a check run that no runner ran (posted through the API)';
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
250
266
|
// The check runs that may count for this pull request, and the ones refused.
|
|
251
267
|
function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
|
|
252
268
|
const homes = homesAt(gh, repo, baseRef, checks);
|
|
@@ -269,7 +285,9 @@ function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
|
|
|
269
285
|
const refused = [];
|
|
270
286
|
for (const check of checkRuns(gh, repo, sha)) {
|
|
271
287
|
if (!checks.includes(check.name) || check.app?.id !== ACTIONS_APP_ID) continue;
|
|
272
|
-
const
|
|
288
|
+
const run = runOf(check.check_suite?.id);
|
|
289
|
+
const why = refusal(run, { name: check.name, pr, sha, baseRef, homes, sides })
|
|
290
|
+
?? jobRefusal(check.id === undefined ? null : gh('GET', `repos/${repo}/actions/jobs/${check.id}`), check, run);
|
|
273
291
|
if (why) refused.push({ name: check.name, why }); else kept.push(check);
|
|
274
292
|
}
|
|
275
293
|
return { kept, refused };
|
package/door/lib/install.mjs
CHANGED
|
@@ -299,6 +299,38 @@ export function proveCopy({ packageRoot, exec = defaultExec }) {
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
// The release notes of each version after `from`, up to `to` (roadmap R52,
|
|
303
|
+
// item c271b9dd). The release job writes them into the body of each GitHub
|
|
304
|
+
// release. They explain a release and prove nothing: the checks above prove
|
|
305
|
+
// it. Anyone who can push can edit a body, so the upgrade prints it as text
|
|
306
|
+
// from GitHub, with a cap on its length.
|
|
307
|
+
const NOTE_VERSION = /^v(\d+\.\d+\.\d+)$/;
|
|
308
|
+
const versionParts = (version) => version.split('.').map(Number);
|
|
309
|
+
export const newer = (a, b) => { const [x, y] = [versionParts(a), versionParts(b)]; for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] > y[i]; return false; };
|
|
310
|
+
export function releaseNotes({ gh, repo, from, to }) {
|
|
311
|
+
const rows = gh(['api', '--method', 'GET', `repos/${repo}/releases?per_page=100`]);
|
|
312
|
+
return (Array.isArray(rows) ? rows : [])
|
|
313
|
+
.map((row) => ({ version: NOTE_VERSION.exec(row.tag_name ?? '')?.[1], body: String(row.body ?? '') }))
|
|
314
|
+
.filter((row) => row.version && newer(row.version, from) && !newer(row.version, to))
|
|
315
|
+
.sort((a, b) => (newer(a.version, b.version) ? 1 : -1));
|
|
316
|
+
}
|
|
317
|
+
const MAX_NOTE_LINES = 60;
|
|
318
|
+
export function notesLines({ gh, repo, from, to }) {
|
|
319
|
+
let notes;
|
|
320
|
+
try { notes = releaseNotes({ gh, repo, from, to }); } catch (error) {
|
|
321
|
+
return [`The release notes could not be read: ${firstLines(error, 1)}. The checks above still hold.`];
|
|
322
|
+
}
|
|
323
|
+
if (!notes.length) return [`No release notes for the versions after ${from}.`];
|
|
324
|
+
const out = [`What changed, from the release notes on GitHub (${notes.length} version${notes.length === 1 ? '' : 's'} after ${from}). The notes explain; the checks above prove:`];
|
|
325
|
+
for (const { version, body } of notes) {
|
|
326
|
+
const text = body.replace(/\r/g, '').replace(/\n+$/, '').split('\n');
|
|
327
|
+
out.push('', `Atlas ${version}:`);
|
|
328
|
+
for (const one of text.slice(0, MAX_NOTE_LINES)) out.push(` | ${one}`);
|
|
329
|
+
if (text.length > MAX_NOTE_LINES) out.push(` | … ${text.length - MAX_NOTE_LINES} more lines on GitHub`);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
302
334
|
export async function upgrade({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec, ask = defaultAsk }) {
|
|
303
335
|
const { name, version: installed } = packageJson(packageRoot);
|
|
304
336
|
const repo = repoOf(packageRoot);
|
|
@@ -334,6 +366,8 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
|
|
|
334
366
|
return 1;
|
|
335
367
|
}
|
|
336
368
|
line(` every one of ${files.size} files matches that commit`);
|
|
369
|
+
line('');
|
|
370
|
+
for (const text of notesLines({ gh, repo, from: installed, to: target })) line(text);
|
|
337
371
|
|
|
338
372
|
const current = existsSync(packageRoot) ? filesUnder(packageRoot) : new Map();
|
|
339
373
|
line('');
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -13,11 +13,22 @@
|
|
|
13
13
|
// purpose, so both are skipped; so is text inside code.
|
|
14
14
|
// 3. Item named. With --event only: the pull request title, body or branch
|
|
15
15
|
// names a tracker item, or says "no item:" and a reason.
|
|
16
|
+
// 4. Doc sources (roadmap R39, lib/doc-sources.mjs). Each doc and each
|
|
17
|
+
// procedure names its code, or why it names none, and each named path
|
|
18
|
+
// exists. With --event, each doc whose code the pull request changes is
|
|
19
|
+
// updated in it, or marked "still true" in its text (TOUCHED_DOCS_GATE).
|
|
16
20
|
//
|
|
17
21
|
// It reads the tree and the event file, and writes nothing.
|
|
18
22
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
19
23
|
import { join, dirname, posix, resolve } from 'node:path';
|
|
20
24
|
import { listChanged } from './changed-tests.mjs';
|
|
25
|
+
import { docSourceFindings, replay } from './doc-sources.mjs';
|
|
26
|
+
|
|
27
|
+
// R39 line 4 gates only after the replay of line 3. The replay of the last 30
|
|
28
|
+
// merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
|
|
29
|
+
// updated; rule "line" pulled in none. So rule "file" gates. False makes a
|
|
30
|
+
// touched doc with no update and no mark a note, not a finding.
|
|
31
|
+
export const TOUCHED_DOCS_GATE = true;
|
|
21
32
|
|
|
22
33
|
const INDEX = 'docs/index.md';
|
|
23
34
|
const SKIP_DIRS = new Set(['node_modules', 'archive', 'fixtures']);
|
|
@@ -220,6 +231,8 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
220
231
|
if (index.gone.length) findings.push(`docs index: ${index.gone.length} listed path(s) are gone: ${index.gone.slice(0, MAX_LISTED).join(', ')}`);
|
|
221
232
|
for (const one of dead.slice(0, MAX_LISTED)) findings.push(`dead link: ${one.file}:${one.line} -> ${one.target}`);
|
|
222
233
|
if (dead.length > MAX_LISTED) findings.push(`dead link: ${dead.length - MAX_LISTED} more`);
|
|
234
|
+
const sources = docSourceFindings(root, { pr: event?.pull_request ?? null, gate: TOUCHED_DOCS_GATE });
|
|
235
|
+
findings.push(...sources.findings);
|
|
223
236
|
if (item.checked && !item.ok) findings.push('item: the pull request names no tracker item. Put the item id in the title, the body or the branch, or write "no item:" and a reason');
|
|
224
237
|
// The heading check holds a pull request that names an item, in a repo that
|
|
225
238
|
// keeps the Atlas template. A repo without the template is not held yet.
|
|
@@ -230,11 +243,12 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
230
243
|
if (text.tests.error) findings.push(`pr text: the changed-test list could not be made: ${text.tests.error}`);
|
|
231
244
|
else if (text.tests.checked) findings.push(...changedTestsFindings(event.pull_request.body, text.tests.rows));
|
|
232
245
|
}
|
|
233
|
-
return { green: findings.length === 0, findings, index, dead, item, text };
|
|
246
|
+
return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
|
|
234
247
|
}
|
|
235
248
|
|
|
236
|
-
export function upkeepCommand({ what, event: eventFile, json = false }, root, line) {
|
|
237
|
-
if (what
|
|
249
|
+
export function upkeepCommand({ what, event: eventFile, json = false, last }, root, line) {
|
|
250
|
+
if (what === 'replay') return replayCommand({ last, json }, root, line);
|
|
251
|
+
if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, or atlas upkeep replay.`);
|
|
238
252
|
let event = null;
|
|
239
253
|
if (eventFile !== undefined) {
|
|
240
254
|
try { event = JSON.parse(readFileSync(resolve(eventFile), 'utf8')); } catch (error) { throw new Error(`cannot read the event file ${eventFile}: ${error.message}`); }
|
|
@@ -246,7 +260,29 @@ export function upkeepCommand({ what, event: eventFile, json = false }, root, li
|
|
|
246
260
|
line(`item: ${!result.item.checked ? 'not a pull request' : result.item.ok ? result.item.how : 'none named'}`);
|
|
247
261
|
line(`pr text: ${result.text.checked ? 'the six headings were read' : 'not checked'}`);
|
|
248
262
|
if (result.text.checked) line(`changed tests: ${result.text.tests?.checked ? (result.text.tests.error ? 'no list' : `${result.text.tests.rows.filter((row) => NAMED_GROUPS.has(row.group)).length} row(s) to name`) : `not checked (${result.text.tests?.why})`}`);
|
|
263
|
+
line(`doc sources: ${result.sources.units} doc(s) and procedure(s)${result.sources.touched ? `; this pull request touches the code of ${result.sources.touched.length}` : ''}`);
|
|
249
264
|
for (const finding of result.findings) line(` - ${finding}`);
|
|
265
|
+
for (const note of result.notes) line(` note: ${note}`);
|
|
250
266
|
line(`upkeep check: ${result.green ? 'GREEN' : 'RED'}`);
|
|
251
267
|
return result.green ? 0 : 1;
|
|
252
268
|
}
|
|
269
|
+
|
|
270
|
+
// `atlas upkeep replay`: what the doc-sources rule (R39 line 4) would have
|
|
271
|
+
// asked of the last merges. It writes nothing.
|
|
272
|
+
function replayCommand({ last = '30', json = false }, root, line) {
|
|
273
|
+
const n = Number(last);
|
|
274
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`--last takes a whole number, not "${last}"`);
|
|
275
|
+
const out = replay(root, { last: n });
|
|
276
|
+
if (json) { process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); return 0; }
|
|
277
|
+
line(`replay of the last ${out.merges} merge(s), with the sources of ${out.units} doc(s) and procedure(s) in this tree`);
|
|
278
|
+
line('rule "file": a changed path falls under a path the doc names. Rule "line": a changed line is a line the doc cites as path:line.');
|
|
279
|
+
for (const row of out.rows) {
|
|
280
|
+
const file = row.file.map((one) => `${one.unit}${one.updated ? ' (updated)' : ''}`);
|
|
281
|
+
line(` ${row.sha} ${row.title.slice(0, 60)} · ${row.changed} path(s) · file ${row.file.length}${file.length ? `: ${file.join(', ')}` : ''} · line ${row.line.length}${row.line.length ? `: ${row.line.join(', ')}` : ''}`);
|
|
282
|
+
}
|
|
283
|
+
const t = out.totals;
|
|
284
|
+
line(`totals: rule "file" pulls in ${t.file} doc(s) over ${t.mergesWithFile} merge(s), ${t.fileNotUpdated} not updated in the merge; rule "line" pulls in ${t.line} doc(s) over ${t.mergesWithLine} merge(s)`);
|
|
285
|
+
line('by doc (file, line):');
|
|
286
|
+
for (const one of out.byUnit) line(` ${one.unit}: ${one.file}, ${one.line}`);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -110,7 +110,7 @@ set. A test keeps the two lists equal.
|
|
|
110
110
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
111
111
|
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
112
112
|
| `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
|
|
113
|
-
| `atlas upkeep` | `check` before you ask for a merge
|
|
113
|
+
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates |
|
|
114
114
|
| `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
115
115
|
| `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
|
|
116
116
|
|
|
@@ -135,10 +135,13 @@ A lock is the owner's approval of a short design.
|
|
|
135
135
|
`tripwires`): a discovery that touches the definition of done, security, a
|
|
136
136
|
schema or cost stops the build and goes back to the owner, as a change after
|
|
137
137
|
approval (above).
|
|
138
|
-
6. Test with the repo's own `test` procedure, which `atlas.yaml` names.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
6. Test with the repo's own `test` procedure, which `atlas.yaml` names.
|
|
139
|
+
Update what the change touches, and run each generator.
|
|
140
|
+
`atlas upkeep check` lists each doc or procedure whose `sources:` names a
|
|
141
|
+
changed path. Update each one, or write "<doc path>: still true" and why
|
|
142
|
+
in the pull request text. Open a pull request. Its text holds the
|
|
143
|
+
definition of done, the kind of change, the undo, and the list of existing
|
|
144
|
+
tests it changes or removes, and it links the tracker item.
|
|
142
145
|
7. **Proof before the merge.** Run the `atlas:verifier` agent on the pull
|
|
143
146
|
request. It proves each line of the definition of done from a fresh run and
|
|
144
147
|
posts its own proof comment. When it returns a comment file instead,
|
|
@@ -27,6 +27,12 @@ Set `shared: true` when many repos use the design, such as Atlas itself.
|
|
|
27
27
|
Then "Each kind of repo" is needed. For a design in one product it is
|
|
28
28
|
optional.
|
|
29
29
|
|
|
30
|
+
Name the code the design describes under `sources:` in `design.yaml`.
|
|
31
|
+
It is a list of paths from the repo root. A path that ends in `/` is a
|
|
32
|
+
folder. A design with no code yet says why under `sources_why:`. A pull
|
|
33
|
+
request that changes a named path updates the design, or marks it "still
|
|
34
|
+
true" (roadmap R39). `atlas upkeep check` reads both keys.
|
|
35
|
+
|
|
30
36
|
## All parts by kind
|
|
31
37
|
|
|
32
38
|
`needed` means the check fails when the part is missing or empty.
|