@arjunkhera/atlas 0.4.11 → 0.4.13

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -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
- const line = (text = '') => process.stdout.write(`${text}\n`);
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) {
@@ -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
- return [...String(xml).matchAll(/<testcase\b[^>]*?(?:\/>|>([\s\S]*?)<\/testcase>)/g)].map((m) => m[1] ?? '');
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
- const name = c.name.replace(/\[.*\]$/, '');
197
+ // The junit reporter of node writes a double quote as "&amp;quot;", so one
198
+ // unescape leaves "&quot;" (f2d07e35). Undo the second escape here.
199
+ const name = c.name.replaceAll('&quot;', '"').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
- if (all || !before.cases.has(name) || squash(before.cases.get(name), r.path) !== squash(text, r.path)) picked.push({ name, change: before.cases.has(name) ? 'changed' : 'added', duplicate: after.duplicates.has(name) });
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.length) return false;
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
@@ -218,36 +218,64 @@ export const gateRunTitle = (pr, sha) => `gate #${pr} at ${sha}`;
218
218
  // Why one workflow run cannot carry a required check of this pull request,
219
219
  // or null when it can. The run must come from a base file that defines the
220
220
  // check, from a pull request event, on this head, for this pull request into
221
- // its base branch.
222
- export function refusal(run, { name, pr, sha, baseRef, homes }) {
221
+ // its base branch. The gate runs on pull_request_target, and its title must
222
+ // name this pull request and this head. The probe of 26 October (done line
223
+ // 15, atlas#211) showed that its run and its check run sit on the head commit,
224
+ // like the others.
225
+ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [] }) {
223
226
  if (!run) return 'no workflow run';
224
227
  if (!homes.get(name)?.has(run.path)) return `from ${run.path ?? 'no file'}, not a base file that defines the check`;
225
- if (run.event !== 'pull_request') return `from the event ${run.event}`;
228
+ const event = name === GATE_CHECK ? 'pull_request_target' : 'pull_request';
229
+ if (run.event !== event) return `from the event ${run.event}`;
226
230
  if (run.head_sha !== sha) return 'of another head commit';
231
+ if (name === GATE_CHECK && run.display_title !== gateRunTitle(pr, sha)) return 'of another pull request or head';
227
232
  const pulls = run.pull_requests ?? [];
228
233
  if (!pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
229
234
  // GitHub lists every open pull request with this head, so a run made for a
230
235
  // pull request into a side branch can also name this one. Such a run used
231
236
  // the side branch's file, so it does not count.
232
237
  if (pulls.some((each) => each.base?.ref !== baseRef)) return `also of a pull request into ${pulls.find((each) => each.base?.ref !== baseRef).base?.ref ?? 'another branch'}`;
238
+ // GitHub builds that list when it is read, from the open pull requests of
239
+ // the head branch. Once a side pull request closes, its gate run names only
240
+ // this one, and its title can be set to match. So the gate also refuses a
241
+ // head branch that ever had a pull request into another branch.
242
+ if (name === GATE_CHECK) {
243
+ const side = sides();
244
+ if (side === null) return 'no head branch to check for side pull requests';
245
+ if (side.length) return `the head branch also had #${side[0].number} into ${side[0].base?.ref ?? 'another branch'}`;
246
+ }
233
247
  return null;
234
248
  }
235
249
 
236
- // The same for a run of the gate. It runs on pull_request_target, so it sits
237
- // on a commit of the base branch, not on the head. Its title names the pull
238
- // request and the head; the run must come from the base branch and its file.
239
- export function gateRefusal(run, { pr, sha, baseRef, onBase }) {
240
- if (run.path !== GATE_WORKFLOW) return `from ${run.path ?? 'no file'}, not ${GATE_WORKFLOW}`;
241
- if (run.event !== 'pull_request_target') return `from the event ${run.event}`;
242
- if (run.display_title !== gateRunTitle(pr, sha)) return 'of another pull request or head';
243
- if (run.head_branch !== baseRef) return `from the branch ${run.head_branch}, not ${baseRef}`;
244
- if (!onBase(run.head_sha)) return `from a commit that is not on ${baseRef}`;
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)';
245
263
  return null;
246
264
  }
247
265
 
248
266
  // The check runs that may count for this pull request, and the ones refused.
249
- function pinnedRuns(gh, repo, { pr, sha, baseRef, checks }) {
267
+ function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
250
268
  const homes = homesAt(gh, repo, baseRef, checks);
269
+ // Every pull request, open or closed, from the head branch into another
270
+ // branch. Read once, and only when a gate run needs it.
271
+ let sideList;
272
+ const sides = () => {
273
+ if (sideList !== undefined) return sideList;
274
+ const owner = head?.repo?.owner?.login ?? head?.user?.login;
275
+ if (!head?.ref || !owner) return (sideList = null);
276
+ const all = gh('GET', `repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${head.ref}`)}&state=all&per_page=100`) ?? [];
277
+ return (sideList = all.filter((each) => each.base?.ref !== baseRef));
278
+ };
251
279
  const suites = new Map();
252
280
  const runOf = (id) => {
253
281
  if (!suites.has(id)) suites.set(id, id ? (gh('GET', `repos/${repo}/actions/runs?check_suite_id=${id}`)?.workflow_runs ?? [])[0] ?? null : null);
@@ -256,21 +284,12 @@ function pinnedRuns(gh, repo, { pr, sha, baseRef, checks }) {
256
284
  const kept = [];
257
285
  const refused = [];
258
286
  for (const check of checkRuns(gh, repo, sha)) {
259
- if (!checks.includes(check.name) || check.name === GATE_CHECK || check.app?.id !== ACTIONS_APP_ID) continue;
260
- const why = refusal(runOf(check.check_suite?.id), { name: check.name, pr, sha, baseRef, homes });
287
+ if (!checks.includes(check.name) || check.app?.id !== ACTIONS_APP_ID) continue;
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);
261
291
  if (why) refused.push({ name: check.name, why }); else kept.push(check);
262
292
  }
263
- if (checks.includes(GATE_CHECK)) {
264
- const onBase = (commit) => ['ahead', 'identical'].includes(gh('GET', `repos/${repo}/compare/${commit}...${encodeURIComponent(baseRef)}`)?.status);
265
- const runs = gh('GET', `repos/${repo}/actions/workflows/${GATE_WORKFLOW.split('/').pop()}/runs?event=pull_request_target&per_page=100`)?.workflow_runs ?? [];
266
- for (const run of runs.filter((each) => each.display_title === gateRunTitle(pr, sha))) {
267
- const why = gateRefusal(run, { pr, sha, baseRef, onBase });
268
- if (why) { refused.push({ name: GATE_CHECK, why }); continue; }
269
- for (const job of gh('GET', `repos/${repo}/actions/runs/${run.id}/jobs`)?.jobs ?? []) {
270
- if (job.name === GATE_CHECK) kept.push({ ...job, app: { id: ACTIONS_APP_ID } });
271
- }
272
- }
273
- }
274
293
  return { kept, refused };
275
294
  }
276
295
 
@@ -283,7 +302,7 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
283
302
  if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
284
303
  const sha = pull.head.sha;
285
304
  const checks = checksAt(gh, repo, pull.base.ref);
286
- const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, checks });
305
+ const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, head: pull.head, checks });
287
306
  const rows = checkStates(checks, kept);
288
307
  line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
289
308
  for (const row of rows) line(checkLine(row));
@@ -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('');
@@ -13,11 +13,21 @@
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. Until the replay of
20
+ // R39 line 3 is read, that last rule only reports (TOUCHED_DOCS_GATE).
16
21
  //
17
22
  // It reads the tree and the event file, and writes nothing.
18
23
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
19
24
  import { join, dirname, posix, resolve } from 'node:path';
20
25
  import { listChanged } from './changed-tests.mjs';
26
+ import { docSourceFindings, replay } from './doc-sources.mjs';
27
+
28
+ // R39 line 4 gates only after the replay of line 3. False: a touched doc with
29
+ // no update and no mark is a note, not a finding.
30
+ export const TOUCHED_DOCS_GATE = false;
21
31
 
22
32
  const INDEX = 'docs/index.md';
23
33
  const SKIP_DIRS = new Set(['node_modules', 'archive', 'fixtures']);
@@ -220,6 +230,8 @@ export function upkeepCheck(root, { event = null } = {}) {
220
230
  if (index.gone.length) findings.push(`docs index: ${index.gone.length} listed path(s) are gone: ${index.gone.slice(0, MAX_LISTED).join(', ')}`);
221
231
  for (const one of dead.slice(0, MAX_LISTED)) findings.push(`dead link: ${one.file}:${one.line} -> ${one.target}`);
222
232
  if (dead.length > MAX_LISTED) findings.push(`dead link: ${dead.length - MAX_LISTED} more`);
233
+ const sources = docSourceFindings(root, { pr: event?.pull_request ?? null, gate: TOUCHED_DOCS_GATE });
234
+ findings.push(...sources.findings);
223
235
  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
236
  // The heading check holds a pull request that names an item, in a repo that
225
237
  // keeps the Atlas template. A repo without the template is not held yet.
@@ -230,11 +242,12 @@ export function upkeepCheck(root, { event = null } = {}) {
230
242
  if (text.tests.error) findings.push(`pr text: the changed-test list could not be made: ${text.tests.error}`);
231
243
  else if (text.tests.checked) findings.push(...changedTestsFindings(event.pull_request.body, text.tests.rows));
232
244
  }
233
- return { green: findings.length === 0, findings, index, dead, item, text };
245
+ return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
234
246
  }
235
247
 
236
- export function upkeepCommand({ what, event: eventFile, json = false }, root, line) {
237
- if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check.`);
248
+ export function upkeepCommand({ what, event: eventFile, json = false, last }, root, line) {
249
+ if (what === 'replay') return replayCommand({ last, json }, root, line);
250
+ if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, or atlas upkeep replay.`);
238
251
  let event = null;
239
252
  if (eventFile !== undefined) {
240
253
  try { event = JSON.parse(readFileSync(resolve(eventFile), 'utf8')); } catch (error) { throw new Error(`cannot read the event file ${eventFile}: ${error.message}`); }
@@ -246,7 +259,29 @@ export function upkeepCommand({ what, event: eventFile, json = false }, root, li
246
259
  line(`item: ${!result.item.checked ? 'not a pull request' : result.item.ok ? result.item.how : 'none named'}`);
247
260
  line(`pr text: ${result.text.checked ? 'the six headings were read' : 'not checked'}`);
248
261
  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})`}`);
262
+ 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
263
  for (const finding of result.findings) line(` - ${finding}`);
264
+ for (const note of result.notes) line(` note: ${note}`);
250
265
  line(`upkeep check: ${result.green ? 'GREEN' : 'RED'}`);
251
266
  return result.green ? 0 : 1;
252
267
  }
268
+
269
+ // `atlas upkeep replay`: what the doc-sources rule (R39 line 4) would have
270
+ // asked of the last merges. It writes nothing.
271
+ function replayCommand({ last = '30', json = false }, root, line) {
272
+ const n = Number(last);
273
+ if (!Number.isInteger(n) || n < 1) throw new Error(`--last takes a whole number, not "${last}"`);
274
+ const out = replay(root, { last: n });
275
+ if (json) { process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); return 0; }
276
+ line(`replay of the last ${out.merges} merge(s), with the sources of ${out.units} doc(s) and procedure(s) in this tree`);
277
+ 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.');
278
+ for (const row of out.rows) {
279
+ const file = row.file.map((one) => `${one.unit}${one.updated ? ' (updated)' : ''}`);
280
+ 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(', ')}` : ''}`);
281
+ }
282
+ const t = out.totals;
283
+ 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)`);
284
+ line('by doc (file, line):');
285
+ for (const one of out.byUnit) line(` ${one.unit}: ${one.file}, ${one.line}`);
286
+ return 0;
287
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
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. Open a
139
- pull request. Its text holds the definition of done, the kind of change, the
140
- undo, and the list of existing tests it changes or removes, and it links the
141
- tracker item.
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.