@arjunkhera/atlas 0.3.46 → 0.3.49

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.
@@ -0,0 +1,212 @@
1
+ // `atlas tests after`: the base version of each changed, renamed or removed test case runs on the
2
+ // head code (roadmap R73, tracker item 29e1f17d; design docs/design-docs/passes-after/).
3
+ //
4
+ // It reads the changed-test list (changed-tests.mjs) and takes each unit test file that the pull
5
+ // request changes, renames or removes. It makes a tree of the head, puts in the base copy of each
6
+ // such file and the base copy of the test helpers, runner configs and kit manifests, and runs the
7
+ // old file there. So the old tests judge the new code.
8
+ //
9
+ // the old case on the new code the case in the PR result
10
+ // pass changed or renamed cleared
11
+ // any other changed or renamed owner: changed test (red)
12
+ // pass removed removed, passes (for the review job)
13
+ // any other removed owner: removed test (red)
14
+ // did not run or skipped any not run (not cleared)
15
+ //
16
+ // The command takes no card and no model verdict, so neither can clear a case. It runs head code
17
+ // with the environment, the tree and the runners of fails-before, and it holds no secret.
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { listChanged } from './changed-tests.mjs';
23
+ import { casesOf, commandOf, freshTree, gitTree, readReport, runEnv, runnerOf } from './fails-before.mjs';
24
+
25
+ export const VERSION = 1;
26
+
27
+ const TEST_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words', 'test-helper', 'runner-config', 'test-script', 'kit-manifest']);
28
+ const SUPPORT = new Set(['test-helper', 'runner-config', 'kit-manifest']);
29
+ const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
30
+ export const OWNER_RESULTS = Object.freeze(['owner: changed test', 'owner: removed test']);
31
+
32
+ function git(cwd, args, { allowFail = false, buffer = false } = {}) {
33
+ const r = spawnSync('git', args, { cwd, encoding: buffer ? 'buffer' : 'utf8', maxBuffer: 256 * 1024 * 1024 });
34
+ if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
35
+ if (r.status !== 0) {
36
+ if (allowFail) return null;
37
+ throw new Error(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
38
+ }
39
+ return r.stdout;
40
+ }
41
+ const usage = (message) => Object.assign(new Error(message), { usage: true });
42
+
43
+ // The text of a case with its own name taken out, so a case renamed with the same body matches.
44
+ const unnamed = (text, name) => text.split(name).join('\u0000');
45
+
46
+ // The old cases of one file that the head changed, renamed or removed.
47
+ export function oldCases(before, after) {
48
+ if (before === null) return { all: true, picked: [] };
49
+ if (after === null || before.rest !== after.rest) {
50
+ return { all: true, picked: [...before.cases.keys()].map((name) => ({ name, change: after?.cases.has(name) ? 'changed' : 'removed', to: null })) };
51
+ }
52
+ const picked = [];
53
+ const taken = new Set();
54
+ for (const [name, text] of before.cases) {
55
+ if (after.cases.has(name)) {
56
+ if (after.cases.get(name) !== text) picked.push({ name, change: 'changed', to: null });
57
+ continue;
58
+ }
59
+ const to = [...after.cases].find(([n, t]) => !before.cases.has(n) && !taken.has(n) && unnamed(t, n) === unnamed(text, name))?.[0];
60
+ if (to) { taken.add(to); picked.push({ name, change: 'renamed', to }); } else picked.push({ name, change: 'removed', to: null });
61
+ }
62
+ return { all: false, picked };
63
+ }
64
+
65
+ // What to run: for each changed, renamed or removed unit test file, the base path and its old cases.
66
+ export function plan({ root, base, head = 'HEAD', list = null }) {
67
+ if (!base && !list) throw usage('give the base: --base <ref>');
68
+ const top = git(resolve(root), ['rev-parse', '--show-toplevel']).trim();
69
+ const changed = list ? JSON.parse(readFileSync(resolve(list), 'utf8')) : listChanged({ root: top, base, head });
70
+ if (changed.changed_tests !== 1) throw new Error(`the changed-test list has version ${changed.changed_tests}; this command reads version 1`);
71
+ const show = (sha, path) => git(top, ['show', `${sha}:${path}`], { allowFail: true });
72
+ const files = [];
73
+ const plans = [];
74
+ for (const r of changed.rows.filter((x) => x.group === 'check-block' && x.status !== 'added')) {
75
+ const path = r.from ?? r.path;
76
+ if (!files.some((f) => f.path === path)) files.push({ path, runner: null, result: 'not run', why: 'a scenario test needs its environment; the planted-fault procedure covers it' });
77
+ }
78
+ for (const r of changed.rows.filter((x) => x.group === 'unit-test' && x.status !== 'added')) {
79
+ const path = r.from ?? r.path;
80
+ const runner = runnerOf(path);
81
+ const before = casesOf(show(changed.merge_base, path), path);
82
+ const after = r.status === 'removed' ? { cases: new Map(), rest: before?.rest ?? '', duplicates: new Set() } : casesOf(show(changed.head, r.path), r.path);
83
+ const { all, picked } = oldCases(before, after);
84
+ if (!picked.length && !all) continue;
85
+ if (!runner) { files.push({ path, runner: null, result: 'not run', why: 'no runner for this file yet' }); plans.push({ path, runner: null, picked, all }); continue; }
86
+ files.push({ path, runner, result: 'ran', why: null });
87
+ plans.push({ path, runner, picked, all, head: r.status === 'removed' ? null : r.path });
88
+ }
89
+ files.sort((x, y) => (x.path < y.path ? -1 : 1));
90
+ return {
91
+ top, changed, files, plans,
92
+ productChanged: changed.other_paths.some(isProduct),
93
+ testsChanged: changed.rows.some((x) => TEST_GROUPS.has(x.group)),
94
+ };
95
+ }
96
+
97
+ // Puts the base copy of a path into the tree.
98
+ function putBase(top, tree, sha, path) {
99
+ const bytes = git(top, ['show', `${sha}:${path}`], { allowFail: true, buffer: true });
100
+ if (bytes === null) return;
101
+ mkdirSync(dirname(join(tree, path)), { recursive: true });
102
+ writeFileSync(join(tree, path), bytes);
103
+ }
104
+
105
+ // Runs the old version of each planned file on a tree of the head. Returns { tree, report, env, files }.
106
+ export function runOld({ top, changed, plans, work = join(tmpdir(), 'atlas-after') }) {
107
+ const tree = join(work, 'repo');
108
+ const report = join(work, 'report', 'report.xml');
109
+ const env = runEnv(work);
110
+ const out = { tree, report, env, files: {} };
111
+ try {
112
+ for (const p of plans.filter((x) => x.runner)) {
113
+ // A fresh tree, home and report folder for each file, so no file of one run reaches the next.
114
+ freshTree(top, tree, changed.head);
115
+ for (const r of changed.rows.filter((x) => SUPPORT.has(x.group) && x.status !== 'added')) putBase(top, tree, changed.merge_base, r.from ?? r.path);
116
+ for (const r of changed.rows.filter((x) => x.group === 'unit-test' && x.status !== 'added')) {
117
+ // A renamed file runs at its old path only, so no case runs twice.
118
+ if (r.status === 'renamed') rmSync(join(tree, r.path), { force: true });
119
+ putBase(top, tree, changed.merge_base, r.from ?? r.path);
120
+ }
121
+ gitTree(tree);
122
+ for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true }); mkdirSync(join(work, dir), { recursive: true }); }
123
+ const command = commandOf(p.runner, p.path, report);
124
+ if (p.runner === 'pytest' && spawnSync('python3', ['-B', '-c', 'import pytest'], { cwd: tree, env }).status !== 0) {
125
+ out.files[p.path] = { command, cases: null, why: 'pytest is not installed', none: true };
126
+ continue;
127
+ }
128
+ const r = spawnSync(command[0] === 'node' ? process.execPath : command[0], command.slice(1), { cwd: tree, env, encoding: 'utf8', timeout: 10 * 60 * 1000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024 });
129
+ let cases = null;
130
+ let why = null;
131
+ if (r.error) why = `the runner did not finish: ${r.error.message}`;
132
+ else if (!existsSync(report)) why = `the runner wrote no report (exit ${r.status})`;
133
+ else {
134
+ try { cases = Object.fromEntries(readReport(readFileSync(report, 'utf8'))); } catch (error) { why = `the report does not read: ${error.message}`; }
135
+ }
136
+ out.files[p.path] = { command, cases, why };
137
+ }
138
+ } finally {
139
+ rmSync(tree, { recursive: true, force: true });
140
+ }
141
+ return out;
142
+ }
143
+
144
+ export function resultOf(change, after) {
145
+ if (after === 'skipped' || after === null) return 'not run';
146
+ if (change === 'removed') return after === 'pass' ? 'removed, passes' : 'owner: removed test';
147
+ return after === 'pass' ? 'cleared' : 'owner: changed test';
148
+ }
149
+
150
+ // The answer, from the plan and the run.
151
+ export function answerOf(prepared, run) {
152
+ const { changed, files, plans, productChanged, testsChanged } = prepared;
153
+ const answer = { passes_after: VERSION, base: changed.base, merge_base: changed.merge_base, head: changed.head, product_changed: productChanged, tests_changed: testsChanged, red: false, owner_count: 0, files: files.map((f) => ({ ...f })), cases: [], env: run?.env ?? null };
154
+ for (const p of plans) {
155
+ const ran = run?.files[p.path];
156
+ const file = answer.files.find((f) => f.path === p.path);
157
+ if (ran?.none) Object.assign(file, { result: 'not run', why: ran.why });
158
+ else if (ran?.why) file.why = ran.why;
159
+ const results = ran && !ran.none ? (ran.cases ?? {}) : null;
160
+ const picked = [...p.picked];
161
+ // A file that cannot be read to the end: every case of its report counts as changed.
162
+ if (p.all && results) for (const name of Object.keys(results)) if (!picked.some((c) => c.name === name)) picked.push({ name, change: 'changed', to: null });
163
+ if (!picked.length) {
164
+ answer.cases.push({ path: p.path, name: null, change: 'changed', to: null, after: results ? 'missing' : null, result: results ? 'owner: changed test' : 'not run' });
165
+ continue;
166
+ }
167
+ for (const c of picked) {
168
+ const after = results ? (results[c.name] ?? 'missing') : null;
169
+ answer.cases.push({ path: p.path, name: c.name, change: c.change, to: c.to, after, result: resultOf(c.change, after) });
170
+ }
171
+ }
172
+ answer.cases.sort((x, y) => (x.path === y.path ? String(x.name).localeCompare(String(y.name)) : x.path < y.path ? -1 : 1));
173
+ answer.owner_count = answer.cases.filter((c) => OWNER_RESULTS.includes(c.result)).length;
174
+ answer.red = answer.owner_count > 0;
175
+ return answer;
176
+ }
177
+
178
+ export function passesAfter({ root, base, head = 'HEAD', list = null, work = join(tmpdir(), 'atlas-after') }) {
179
+ const prepared = plan({ root, base, head, list });
180
+ return answerOf(prepared, prepared.plans.length ? runOld({ ...prepared, work }) : null);
181
+ }
182
+
183
+ // ---------------------------------------------------------------- the command
184
+
185
+ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
186
+
187
+ export function renderText(a) {
188
+ const ran = a.files.filter((f) => f.result === 'ran').length;
189
+ const out = [`passes-after against ${oneLine(a.base)} (merge base ${a.merge_base.slice(0, 7)}): ${a.cases.length} old case(s) in ${ran} file(s); ${a.owner_count} for the owner; tests changed: ${a.tests_changed ? 'yes' : 'no'}`];
190
+ for (const c of a.cases) out.push(` ${c.result.padEnd(20)} ${oneLine(c.path)} ${oneLine(c.name ?? '(the whole file)')}${c.to ? ` (renamed to: ${oneLine(c.to)})` : ''}`);
191
+ for (const f of a.files.filter((x) => x.result === 'not run')) out.push(` ${'not run'.padEnd(20)} ${oneLine(f.path)} ${oneLine(f.why)}`);
192
+ for (const f of a.files.filter((x) => x.result === 'ran' && x.why)) out.push(` ${'note'.padEnd(20)} ${oneLine(f.path)} ${oneLine(f.why)}`);
193
+ if (a.red) out.push(`RED: ${a.owner_count} case(s) for the owner. Only the owner clears a changed test that fails on the new code.`);
194
+ else if (!a.tests_changed) out.push('GREEN: the pull request changes no test.');
195
+ else if (a.cases.some((c) => c.result === 'not run')) out.push(`GREEN with gaps: ${a.cases.filter((c) => c.result === 'not run').length} old case(s) did not run, so they are not cleared.`);
196
+ else out.push('GREEN: each old case that the pull request changed still passes on the new code.');
197
+ return out;
198
+ }
199
+
200
+ export function afterCommand({ base, head, list, out, text = false }, root, line) {
201
+ try {
202
+ const answer = passesAfter({ root, base, head: head ?? 'HEAD', list });
203
+ const json = `${JSON.stringify(answer, null, 2)}\n`;
204
+ if (out) writeFileSync(resolve(out), json);
205
+ else if (!text) process.stdout.write(json);
206
+ if (text) for (const l of renderText(answer)) line(l);
207
+ return answer.red ? 1 : 0;
208
+ } catch (error) {
209
+ line(`after: ${error.message}`);
210
+ return error.usage ? 2 : 1;
211
+ }
212
+ }
@@ -21,8 +21,9 @@ export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'ta
21
21
  // The one label every tooling pull request carries. A person filters on it.
22
22
  export const TOOLING_LABEL = 'atlas-helper';
23
23
 
24
- // The only two files the tooling writer may write.
25
- export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH]);
24
+ // The only files the tooling writer may write: the check, its workflow, and
25
+ // the pull request template when the repo has none.
26
+ export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/pull_request_template.md']);
26
27
 
27
28
  export class ChangeError extends Error {}
28
29
 
@@ -20,8 +20,19 @@ import { dirname, join } from 'node:path';
20
20
  import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
21
21
  import { packagedCheck, packagePackageVersion, PACKAGE_CHECK_SOURCE } from './releases.mjs';
22
22
  import { status } from './status.mjs';
23
+ import { PR_TEMPLATE_PATH } from './upkeep.mjs';
23
24
 
24
- export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE };
25
+ export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
26
+
27
+ // The pull request template (roadmap R8). The writer puts it into a repo that
28
+ // has none, and never replaces a template the repo keeps.
29
+ export const PACKAGE_TEMPLATE_SOURCE = 'door/templates/pull_request_template.md';
30
+ // A package with no template file writes none.
31
+ const packagedTemplate = (packageRoot) => {
32
+ const full = join(packageRoot, PACKAGE_TEMPLATE_SOURCE);
33
+ return existsSync(full) ? readFileSync(full, 'utf8') : null;
34
+ };
35
+ const templateMissing = (root, packageRoot) => packagedTemplate(packageRoot) !== null && !existsSync(join(root, PR_TEMPLATE_PATH));
25
36
 
26
37
  export class ToolingRefusal extends Error {
27
38
  constructor(message, { current = false } = {}) { super(message); this.current = current; }
@@ -64,7 +75,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
64
75
  throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
65
76
  }
66
77
  const now = status({ root, packageRoot });
67
- if (now.state === 'current' && !workflowOnly) {
78
+ if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot)) {
68
79
  throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
69
80
  }
70
81
  if (now.state === 'unknown' && !replaceUnknown && !own) {
@@ -90,22 +101,33 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
90
101
  }
91
102
  rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
92
103
  }
93
- if (!dryRun && rows.every((row) => row.state === 'unchanged')) {
94
- throw new ToolingRefusal('this repo is already current: both files match the package, so there is nothing to write.', { current: true });
104
+ const template = workflowOnly ? null : packagedTemplate(packageRoot);
105
+ if (template !== null) {
106
+ const contents = template;
107
+ const full = join(root, PR_TEMPLATE_PATH);
108
+ const kept = existsSync(full);
109
+ if (!kept && !dryRun) {
110
+ mkdirSync(dirname(full), { recursive: true });
111
+ writeFileSync(full, contents);
112
+ }
113
+ rows.push({ path: PR_TEMPLATE_PATH, state: kept ? 'kept' : 'written', sha256: sha256(kept ? readFileSync(full, 'utf8') : contents), was: kept ? sha256(readFileSync(full, 'utf8')) : null });
114
+ }
115
+ if (!dryRun && rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
116
+ throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to write.', { current: true });
95
117
  }
96
- return { rows, version: packagePackageVersion(packageRoot), check_version: CHECK_VERSION, status: before?.state ?? null, changed: rows.some((row) => row.state !== 'unchanged') };
118
+ return { rows, version: packagePackageVersion(packageRoot), check_version: CHECK_VERSION, status: before?.state ?? null, changed: rows.some((row) => row.state !== 'unchanged' && row.state !== 'kept') };
97
119
  }
98
120
 
99
121
  // The change set the pull request machinery takes, for the two files above.
100
122
  export function changes(root, packageRoot, { replaceUnknown = false, workflowOnly = false } = {}) {
101
123
  mayWrite(root, packageRoot, { replaceUnknown, workflowOnly });
102
124
  const { rows, version } = write(root, packageRoot, { dryRun: true, workflowOnly });
103
- if (rows.every((row) => row.state === 'unchanged')) {
104
- throw new ToolingRefusal('this repo is already current: both files match the package, so there is nothing to open.', { current: true });
125
+ if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
126
+ throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
105
127
  }
106
- const wanted = packagedFiles(packageRoot);
128
+ const wanted = { ...packagedFiles(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
107
129
  return rows
108
- .filter((row) => row.state !== 'unchanged')
130
+ .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
109
131
  .map((row) => ({
110
132
  path: row.path,
111
133
  contents: wanted[row.path],
@@ -113,7 +135,9 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
113
135
  source: 'repo read',
114
136
  detail: row.path === CHECK_PATH
115
137
  ? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
116
- : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
138
+ : row.path === PR_TEMPLATE_PATH
139
+ ? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
140
+ : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
117
141
  },
118
142
  }));
119
143
  }
@@ -119,21 +119,57 @@ const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i
119
119
  const SHORT = /\bitems?\b[\s:#`(_-]*(?:id[\s:#`_-]*)?([0-9a-f]{8})\b/i;
120
120
  const NO_ITEM = /\bno item:\s*(\S[^\n]*)/i;
121
121
 
122
+ // The six headings of the pull request text (roadmap R8, design
123
+ // docs/design-docs/pr-template/). The template in door/templates/ holds them,
124
+ // and a test keeps the two equal. A later job reads the text under each one.
125
+ export const PR_HEADINGS = Object.freeze(['Definition of done', 'Kind of change', 'Undo', 'Behaviour change', 'Changed tests', 'Proof']);
126
+ export const PR_TEMPLATE_PATH = '.github/pull_request_template.md';
127
+
128
+ // A pull request text without its HTML comments. The template's own comment
129
+ // says "no item:", so the item rule and the heading rule never read a comment.
130
+ const withoutComments = (text) => String(text ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
131
+
132
+ // The sections of a pull request text: each `## ` heading and the text under it.
133
+ export function prSections(body) {
134
+ const sections = new Map();
135
+ let current = null;
136
+ for (const line of withoutComments(body).split('\n')) {
137
+ const heading = /^##\s+(.+?)\s*#*\s*$/.exec(line);
138
+ if (heading) { current = heading[1].toLowerCase(); sections.set(current, []); continue; }
139
+ if (current) sections.get(current).push(line);
140
+ }
141
+ return new Map([...sections].map(([name, lines]) => [name, lines.join('\n').trim()]));
142
+ }
143
+
144
+ // The findings of the text of a pull request that names an item.
145
+ export function prTextFindings(body) {
146
+ const sections = prSections(body);
147
+ const findings = [];
148
+ for (const heading of PR_HEADINGS) {
149
+ if (!sections.has(heading.toLowerCase())) findings.push(`pr text: missing heading "${heading}"`);
150
+ }
151
+ const done = sections.get('definition of done');
152
+ if (done !== undefined && !/^[ \t]*\d+[.)][ \t]+\S/m.test(done)) findings.push('pr text: "Definition of done" has no numbered line');
153
+ const behaviour = sections.get('behaviour change');
154
+ if (behaviour !== undefined && !/\b(yes|no)\b/i.test(behaviour)) findings.push('pr text: "Behaviour change" says neither yes nor no');
155
+ return findings;
156
+ }
157
+
122
158
  // Does the pull request name its tracker item? Read from a GitHub event file.
123
159
  export function itemNamed(event) {
124
160
  const pr = event?.pull_request;
125
161
  if (!pr) return { checked: false };
126
- if (pr.user?.type === 'Bot') return { checked: true, ok: true, how: `opened by a bot (${pr.user.login})` };
127
- const texts = [pr.title, pr.body, pr.head?.ref].map((one) => String(one ?? ''));
162
+ if (pr.user?.type === 'Bot') return { checked: true, ok: true, how: `opened by a bot (${pr.user.login})`, named: false };
163
+ const texts = [pr.title, withoutComments(pr.body), pr.head?.ref].map((one) => String(one ?? ''));
128
164
  for (const text of texts) {
129
165
  const full = text.match(UUID);
130
- if (full) return { checked: true, ok: true, how: `names item ${full[0]}` };
166
+ if (full) return { checked: true, ok: true, how: `names item ${full[0]}`, named: true };
131
167
  const short = text.match(SHORT);
132
- if (short) return { checked: true, ok: true, how: `names item ${short[1]}` };
168
+ if (short) return { checked: true, ok: true, how: `names item ${short[1]}`, named: true };
133
169
  }
134
170
  for (const text of texts) {
135
171
  const none = text.match(NO_ITEM);
136
- if (none) return { checked: true, ok: true, how: `no item: ${none[1].trim()}` };
172
+ if (none) return { checked: true, ok: true, how: `no item: ${none[1].trim()}`, named: false };
137
173
  }
138
174
  return { checked: true, ok: false };
139
175
  }
@@ -149,7 +185,11 @@ export function upkeepCheck(root, { event = null } = {}) {
149
185
  for (const one of dead.slice(0, MAX_LISTED)) findings.push(`dead link: ${one.file}:${one.line} -> ${one.target}`);
150
186
  if (dead.length > MAX_LISTED) findings.push(`dead link: ${dead.length - MAX_LISTED} more`);
151
187
  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');
152
- return { green: findings.length === 0, findings, index, dead, item };
188
+ // The heading check holds a pull request that names an item, in a repo that
189
+ // keeps the Atlas template. A repo without the template is not held yet.
190
+ const text = { checked: Boolean(item.named) && existsSync(join(root, PR_TEMPLATE_PATH)) };
191
+ if (text.checked) findings.push(...prTextFindings(event.pull_request.body));
192
+ return { green: findings.length === 0, findings, index, dead, item, text };
153
193
  }
154
194
 
155
195
  export function upkeepCommand({ what, event: eventFile, json = false }, root, line) {
@@ -163,6 +203,7 @@ export function upkeepCommand({ what, event: eventFile, json = false }, root, li
163
203
  line(`docs index: ${result.index.checked ? `${result.index.unlisted.length} not listed, ${result.index.gone.length} gone` : 'no docs/ folder'}`);
164
204
  line(`dead links: ${result.dead.length}`);
165
205
  line(`item: ${!result.item.checked ? 'not a pull request' : result.item.ok ? result.item.how : 'none named'}`);
206
+ line(`pr text: ${result.text.checked ? 'the six headings were read' : 'not checked'}`);
166
207
  for (const finding of result.findings) line(` - ${finding}`);
167
208
  line(`upkeep check: ${result.green ? 'GREEN' : 'RED'}`);
168
209
  return result.green ? 0 : 1;
@@ -0,0 +1,25 @@
1
+ <!-- The Atlas pull request text. Name the tracker item in the title, this text or the branch, or write "no item:" and a reason. When the text names an item, `atlas upkeep check` needs each heading below. -->
2
+
3
+ ## Definition of done
4
+
5
+ 1.
6
+
7
+ ## Kind of change
8
+
9
+
10
+
11
+ ## Undo
12
+
13
+
14
+
15
+ ## Behaviour change
16
+
17
+ No.
18
+
19
+ ## Changed tests
20
+
21
+ None.
22
+
23
+ ## Proof
24
+
25
+
@@ -0,0 +1,238 @@
1
+ # The owner-only list: the kinds of change that always come to the owner.
2
+ #
3
+ # The owner picked these eight classes on 24 October 2026 (decision 1c2f0d53,
4
+ # item 94b2e2ff). Design: docs/design-docs/owner-only-list/ in the Atlas repo.
5
+ #
6
+ # Each class has two examples and its net. `code` means the reader in
7
+ # door/lib/owner-only.mjs matches it on a diff. `review` means the review job
8
+ # reads for it. A code class lists its signs: exact things that the reader
9
+ # matches.
10
+ #
11
+ # The floor below holds in every repo, whatever the repo's own `owner_only:`
12
+ # list says. The reader takes the floor from this package and the repo list
13
+ # from the base commit, so a pull request cannot take itself off either one.
14
+ #
15
+ # Sign types:
16
+ # path a changed path that matches one of `paths`
17
+ # added_line an added line, in a file of `paths`, that matches `pattern`
18
+ # secret an added line that matches a secret pattern of shape/check.mjs
19
+ # json_keys a change to one of `keys` of a JSON file of `paths`
20
+ # surface a line that `line` matches, keyed by its first group, is gone
21
+ # at the head, or lost a part that `part` matches
22
+ # schema a JSON file of `paths` lost a key, lost an enum value, or
23
+ # gained a required field; versioned files compare newest to newest
24
+ # any_change any change to a file of `paths`
25
+ #
26
+ # A code class with no signs says where its code net comes from instead:
27
+ # `signs_from: repo` (each repo's `owner_only:` list) or `signs_from: floor`.
28
+ #
29
+ # Paths match on whole folder names and with no case. `**/` matches any depth.
30
+ # A path that ends in `/` is a folder.
31
+
32
+ classes:
33
+ - id: money
34
+ name: Money
35
+ examples:
36
+ - A new paid plan, or a change to a price.
37
+ - A cloud resource that bills by the hour.
38
+ net:
39
+ - review
40
+
41
+ - id: credentials
42
+ name: Credentials and keys
43
+ examples:
44
+ - A new API key, or a key written into a file.
45
+ - A change to the list of credentials that a repo names.
46
+ net:
47
+ - code
48
+ - review
49
+ signs:
50
+ - id: secret-value
51
+ type: secret
52
+ - id: credential-files
53
+ type: path
54
+ paths:
55
+ - "atlas/credentials.yaml"
56
+ - "**/.env"
57
+ - "**/.env.*"
58
+ - "**/*.pem"
59
+ - "**/*.key"
60
+
61
+ - id: outside-visibility
62
+ name: Outside visibility
63
+ examples:
64
+ - A public repo, or a public post.
65
+ - A release that removes a command that other people use.
66
+ net:
67
+ - code
68
+ - review
69
+ signs:
70
+ - id: release-surface
71
+ type: json_keys
72
+ paths:
73
+ - "**/package.json"
74
+ keys:
75
+ - name
76
+ - private
77
+ - publishConfig
78
+ - files
79
+ - bin
80
+ - main
81
+ - exports
82
+ - scripts
83
+ - id: removed-command-or-switch
84
+ type: surface
85
+ paths:
86
+ - "packages/atlas/door/cli.mjs"
87
+ line: "^ '?([a-z][a-z-]*)'?: \\{.*\\},?\\s*$"
88
+ part: "'?([a-z][a-z-]*)'?: '(?:switch|value|optional)'"
89
+ - id: removed-mcp-tool
90
+ type: surface
91
+ paths:
92
+ - "packages/atlas/work/mcp.mjs"
93
+ line: "^\\s*\\{ name: '([a-z_]+)'"
94
+ - id: kit-manifest
95
+ type: any_change
96
+ # A release moves only the "atlas" version line. That is not a sign.
97
+ version_key: atlas
98
+ paths:
99
+ - "**/test/atlas/manifest.json"
100
+ - "packages/atlas/tests/link-check.mjs"
101
+ - id: narrowed-schema
102
+ type: schema
103
+ paths:
104
+ - "packages/atlas/work/manifest-*.json"
105
+
106
+ - id: lost-data
107
+ name: Data that cannot be undone
108
+ examples:
109
+ - A migration that drops a table.
110
+ - A script that deletes rows from a live store.
111
+ net:
112
+ - code
113
+ - review
114
+ signs:
115
+ - id: drop-or-delete-in-migration
116
+ type: added_line
117
+ paths:
118
+ - "**/migrations/**"
119
+ - "**/migrate/**"
120
+ - "**/*.sql"
121
+ pattern: "(?i)\\b(drop|delete|truncate)\\b|\\b(drop|truncate)[A-Z_]"
122
+
123
+ - id: security
124
+ name: Security code
125
+ examples:
126
+ - The code that checks who may sign in.
127
+ - The code that keeps one tenant's rows from another.
128
+ net:
129
+ - code
130
+ - review
131
+ # No path holds sign-in code in every repo, so each repo lists its own
132
+ # security paths under `owner_only:` in atlas.yaml.
133
+ signs_from: repo
134
+
135
+ - id: new-service
136
+ name: A new outside service or a new repo
137
+ examples:
138
+ - A call to a new outside API.
139
+ - A new repo, or a new GitHub App.
140
+ net:
141
+ - review
142
+
143
+ - id: rule-book
144
+ name: The rule book
145
+ examples:
146
+ - An entry file, a path rule or a hook.
147
+ - The owner-only list itself.
148
+ net:
149
+ - code
150
+ - review
151
+ # The floor below is the code net of this class, with each repo's own list.
152
+ signs_from: floor
153
+
154
+ - id: direction
155
+ name: Direction
156
+ examples:
157
+ - What to build next.
158
+ - A goal of a product, or a change to a locked design.
159
+ net:
160
+ - review
161
+
162
+ # The floor: rule-book paths in every repo. Each entry is class rule-book.
163
+ # `follow` means the reader also reads the file for names: each file that it
164
+ # names with an @ import, a Markdown link or a path in backticks joins the
165
+ # floor, and the reader follows names out of that file in turn. A plugin
166
+ # root's skills, agents, hooks and commands are followed the same way.
167
+ # `version_only_exempt` lets a change to the version alone pass, because the
168
+ # owner took Atlas's own releases off the list.
169
+ floor:
170
+ - path: "**/CLAUDE.md"
171
+ rule: entry file
172
+ follow: true
173
+ - path: "**/CLAUDE.local.md"
174
+ rule: entry file
175
+ follow: true
176
+ - path: "**/AGENTS.md"
177
+ rule: entry file
178
+ follow: true
179
+ - path: "**/.claude/"
180
+ rule: .claude at any depth
181
+ follow: true
182
+ - path: "**/.claude-plugin/"
183
+ rule: .claude-plugin at any depth
184
+ - path: ".mcp.json"
185
+ rule: root MCP servers
186
+ - path: ".github/actions/"
187
+ rule: CI actions
188
+ - path: ".github/workflows/"
189
+ rule: CI workflows
190
+ - path: ".github/atlas/"
191
+ rule: the Atlas check copy
192
+ - path: "atlas.yaml"
193
+ rule: atlas.yaml keys
194
+ - path: "atlas/"
195
+ rule: repo facts
196
+ - path: "**/.gitattributes"
197
+ rule: git attributes
198
+ - path: ".gitmodules"
199
+ rule: submodules
200
+ - path: "**/.github/CODEOWNERS"
201
+ rule: code owners
202
+ - path: "docs/intake/"
203
+ rule: the owner's words
204
+ - path: "archive/"
205
+ rule: archive
206
+ - path: "packages/atlas/owner-only.yaml"
207
+ rule: the owner-only list
208
+ - path: "packages/atlas/door/lib/owner-only.mjs"
209
+ rule: the owner-only reader
210
+ - path: "packages/atlas/package.json"
211
+ rule: the package manifest
212
+ version_only_exempt: true
213
+ - path: "packages/atlas/.claude-plugin/plugin.json"
214
+ rule: the plugin manifest
215
+ version_only_exempt: true
216
+
217
+ # The entry map. A doc that only it names is not on the floor, and the reader
218
+ # does not follow names out of it (shape/check.mjs, the exemption).
219
+ entry_map: docs/index.md
220
+
221
+ # The keys of atlas.yaml whose change is always owner-only. A change to any
222
+ # other key is owner-only too, until roadmap R62 lists the safe ones. The one
223
+ # change that passes is an addition under `parts:`.
224
+ atlas_yaml:
225
+ add_only:
226
+ - parts
227
+ fixed:
228
+ - owner_only
229
+ - areas
230
+ - required_checks
231
+ - product
232
+ - product_paths
233
+ - repo
234
+ - kind
235
+ - kinds
236
+ - up_to_date
237
+ - brake
238
+ - spec