@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +10 -3
- package/door/lib/fails-before.mjs +65 -9
- package/door/lib/owner-only.mjs +778 -0
- package/door/lib/passes-after.mjs +212 -0
- package/door/lib/pull-request.mjs +3 -2
- package/door/lib/tooling.mjs +34 -10
- package/door/lib/upkeep.mjs +47 -6
- package/door/templates/pull_request_template.md +25 -0
- package/owner-only.yaml +238 -0
- package/package.json +3 -2
- package/shape/check.mjs +44 -3
- package/shape/releases.json +5 -0
- package/skills/lead/SKILL.md +1 -1
|
@@ -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
|
|
25
|
-
|
|
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
|
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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:
|
|
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
|
-
:
|
|
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
|
}
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
|
package/owner-only.yaml
ADDED
|
@@ -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
|