@arjunkhera/atlas 0.4.10 → 0.4.12
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/lib/fails-before.mjs +21 -6
- package/door/lib/gate.mjs +112 -4
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
|
@@ -268,7 +268,7 @@ export const commandOf = (runner, file, report) => (runner === 'node'
|
|
|
268
268
|
// A fresh tree of `sha` at `tree`, from git archive: no .git of the repo, so a test cannot read which
|
|
269
269
|
// commit it is. gitTree then gives it a .git of its own.
|
|
270
270
|
export function freshTree(top, tree, sha) {
|
|
271
|
-
rmSync(tree, { recursive: true, force: true });
|
|
271
|
+
rmSync(tree, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
272
272
|
mkdirSync(tree, { recursive: true });
|
|
273
273
|
const archive = spawnSync('git', ['archive', '--format=tar', sha], { cwd: top, maxBuffer: 1024 * 1024 * 1024 });
|
|
274
274
|
if (archive.status !== 0) throw new Error(`git archive failed: ${String(archive.stderr).trim()}`);
|
|
@@ -328,7 +328,9 @@ export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atla
|
|
|
328
328
|
out.files[p.path] = { command, cases, why };
|
|
329
329
|
}
|
|
330
330
|
} finally {
|
|
331
|
-
|
|
331
|
+
// A process that a test left behind can still write in the tree for a
|
|
332
|
+
// moment; Node then retries the remove (atlas#209: ENOTEMPTY in CI).
|
|
333
|
+
rmSync(tree, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
332
334
|
}
|
|
333
335
|
return out;
|
|
334
336
|
}
|
|
@@ -343,7 +345,7 @@ export function combine(prepared, oldRun, newRun) {
|
|
|
343
345
|
const answer = { fails_before: VERSION, base: changed.base, merge_base: changed.merge_base, head: changed.head, product_changed: productChanged, red: false, proven_count: 0, files: files.map((f) => ({ ...f })), cases: [], env: null, runs: null };
|
|
344
346
|
if (!productChanged || !plans.length) {
|
|
345
347
|
for (const p of plans) for (const c of p.picked) answer.cases.push({ path: p.path, name: c.name, change: c.change, before: null, after: null, result: c.duplicate ? 'duplicate' : 'not proven' });
|
|
346
|
-
return finish(answer);
|
|
348
|
+
return finish(answer, nothingProven(prepared, answer));
|
|
347
349
|
}
|
|
348
350
|
if (oldRun?.side !== 'old' || newRun?.side !== 'new') throw new Error('give one run file of the side old and one of the side new');
|
|
349
351
|
for (const run of [oldRun, newRun]) {
|
|
@@ -373,7 +375,18 @@ export function combine(prepared, oldRun, newRun) {
|
|
|
373
375
|
answer.cases.push({ path: p.path, name: c.name, change: c.change, before, after, result: c.duplicate ? 'duplicate' : resultOf(before, after) });
|
|
374
376
|
}
|
|
375
377
|
}
|
|
376
|
-
return finish(answer);
|
|
378
|
+
return finish(answer, nothingProven(prepared, answer));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Roadmap R6 done line 6: product code changed, the pull request changed a
|
|
382
|
+
// unit test file that runs here, and yet no case runs, such as a change of
|
|
383
|
+
// spaces in a case. The gate counts that file as a test change, so this check
|
|
384
|
+
// says red. A pull request whose only test change is a scenario file, or a
|
|
385
|
+
// file with no runner here, gets its proof elsewhere: the planted-fault
|
|
386
|
+
// procedure.
|
|
387
|
+
function nothingProven({ changed, productChanged }, answer) {
|
|
388
|
+
if (!productChanged || answer.cases.length) return false;
|
|
389
|
+
return changed.rows.some((r) => r.group === 'unit-test' && r.status !== 'removed' && runnerOf(r.path));
|
|
377
390
|
}
|
|
378
391
|
|
|
379
392
|
// Both runs, one after the other, then the answer. For a local run.
|
|
@@ -385,9 +398,10 @@ export function failsBefore({ root, base, head = 'HEAD', list = null, work = joi
|
|
|
385
398
|
return combine(prepared, oldRun, newRun);
|
|
386
399
|
}
|
|
387
400
|
|
|
388
|
-
function finish(answer) {
|
|
401
|
+
function finish(answer, nothing = false) {
|
|
389
402
|
answer.cases.sort((x, y) => (x.path === y.path ? String(x.name).localeCompare(String(y.name)) : x.path < y.path ? -1 : 1));
|
|
390
|
-
answer.
|
|
403
|
+
answer.nothing_proven = nothing;
|
|
404
|
+
answer.red = nothing || answer.cases.some((c) => RED_RESULTS.includes(c.result));
|
|
391
405
|
answer.proven_count = answer.cases.filter((c) => c.result === 'proven').length;
|
|
392
406
|
return answer;
|
|
393
407
|
}
|
|
@@ -403,6 +417,7 @@ export function renderText(a) {
|
|
|
403
417
|
for (const f of a.files.filter((x) => x.result === 'ran' && x.why)) out.push(` note ${oneLine(f.path)} ${oneLine(f.why)}`);
|
|
404
418
|
const red = a.cases.filter((c) => RED_RESULTS.includes(c.result));
|
|
405
419
|
if (red.length) out.push(`RED: ${red.length} case(s): ${[...new Set(red.map((c) => c.result))].join(', ')}. A case that passes on the old code cannot catch this change.`);
|
|
420
|
+
else if (a.nothing_proven) out.push('RED: nothing proven. Product code changed and a test file changed, but no new or changed case runs. Add or change a test case.');
|
|
406
421
|
else if (!a.product_changed) out.push('GREEN: no product code changed, so the new cases are not proven.');
|
|
407
422
|
else if (a.product_changed && !a.cases.length) out.push('GREEN: product code changed, and no new or changed case runs. Nothing is proven.');
|
|
408
423
|
else out.push(`GREEN: no red case. Green does not mean proven: ${a.proven_count} proven, ${a.cases.length - a.proven_count} not proven.`);
|
package/door/lib/gate.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
22
22
|
import { parseYaml } from '../../shape/check.mjs';
|
|
23
|
-
import { checkCommand, replayCommand } from './gate-check.mjs';
|
|
23
|
+
import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
24
|
|
|
25
25
|
export const BRANCH_RULESET = 'atlas gate';
|
|
26
26
|
export const TAG_RULESET = 'atlas release tags';
|
|
@@ -195,6 +195,86 @@ function checksAt(gh, repo, ref) {
|
|
|
195
195
|
const checkRuns = (gh, repo, sha) => gh('GET', `repos/${repo}/commits/${sha}/check-runs?per_page=100`)?.check_runs ?? [];
|
|
196
196
|
const checkLine = (row) => ` ${row.state === 'green' ? '✓' : '✗'} ${row.state.padEnd(8)} ${row.name}${row.conclusion && row.state === 'red' ? ` (${row.conclusion})` : ''}`;
|
|
197
197
|
|
|
198
|
+
// The base files that define each required check (roadmap R6 done line 8).
|
|
199
|
+
// A check counts only from a workflow run of one of its files, as the base
|
|
200
|
+
// branch holds them. The gate always comes from its own file.
|
|
201
|
+
function homesAt(gh, repo, ref, checks) {
|
|
202
|
+
const homes = new Map(checks.map((name) => [name, new Set()]));
|
|
203
|
+
const listing = gh('GET', `repos/${repo}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`);
|
|
204
|
+
for (const entry of Array.isArray(listing) ? listing : []) {
|
|
205
|
+
if (entry?.type !== 'file' || !/\.ya?ml$/.test(entry.name ?? '')) continue;
|
|
206
|
+
const file = gh('GET', `repos/${repo}/contents/${entry.path}?ref=${encodeURIComponent(ref)}`);
|
|
207
|
+
if (typeof file?.content !== 'string') continue;
|
|
208
|
+
for (const name of jobNames(Buffer.from(file.content, 'base64').toString('utf8')) ?? []) if (homes.has(name)) homes.get(name).add(entry.path);
|
|
209
|
+
}
|
|
210
|
+
if (homes.has(GATE_CHECK)) homes.set(GATE_CHECK, new Set([GATE_WORKFLOW]));
|
|
211
|
+
return homes;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The title that the gate workflow gives each run (its `run-name`). The base
|
|
215
|
+
// file sets it, so a pull request cannot write it.
|
|
216
|
+
export const gateRunTitle = (pr, sha) => `gate #${pr} at ${sha}`;
|
|
217
|
+
|
|
218
|
+
// Why one workflow run cannot carry a required check of this pull request,
|
|
219
|
+
// or null when it can. The run must come from a base file that defines the
|
|
220
|
+
// check, from a pull request event, on this head, for this pull request into
|
|
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 = () => [] }) {
|
|
226
|
+
if (!run) return 'no workflow run';
|
|
227
|
+
if (!homes.get(name)?.has(run.path)) return `from ${run.path ?? 'no file'}, not a base file that defines the check`;
|
|
228
|
+
const event = name === GATE_CHECK ? 'pull_request_target' : 'pull_request';
|
|
229
|
+
if (run.event !== event) return `from the event ${run.event}`;
|
|
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';
|
|
232
|
+
const pulls = run.pull_requests ?? [];
|
|
233
|
+
if (!pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
|
|
234
|
+
// GitHub lists every open pull request with this head, so a run made for a
|
|
235
|
+
// pull request into a side branch can also name this one. Such a run used
|
|
236
|
+
// the side branch's file, so it does not count.
|
|
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
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The check runs that may count for this pull request, and the ones refused.
|
|
251
|
+
function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
|
|
252
|
+
const homes = homesAt(gh, repo, baseRef, checks);
|
|
253
|
+
// Every pull request, open or closed, from the head branch into another
|
|
254
|
+
// branch. Read once, and only when a gate run needs it.
|
|
255
|
+
let sideList;
|
|
256
|
+
const sides = () => {
|
|
257
|
+
if (sideList !== undefined) return sideList;
|
|
258
|
+
const owner = head?.repo?.owner?.login ?? head?.user?.login;
|
|
259
|
+
if (!head?.ref || !owner) return (sideList = null);
|
|
260
|
+
const all = gh('GET', `repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${head.ref}`)}&state=all&per_page=100`) ?? [];
|
|
261
|
+
return (sideList = all.filter((each) => each.base?.ref !== baseRef));
|
|
262
|
+
};
|
|
263
|
+
const suites = new Map();
|
|
264
|
+
const runOf = (id) => {
|
|
265
|
+
if (!suites.has(id)) suites.set(id, id ? (gh('GET', `repos/${repo}/actions/runs?check_suite_id=${id}`)?.workflow_runs ?? [])[0] ?? null : null);
|
|
266
|
+
return suites.get(id);
|
|
267
|
+
};
|
|
268
|
+
const kept = [];
|
|
269
|
+
const refused = [];
|
|
270
|
+
for (const check of checkRuns(gh, repo, sha)) {
|
|
271
|
+
if (!checks.includes(check.name) || check.app?.id !== ACTIONS_APP_ID) continue;
|
|
272
|
+
const why = refusal(runOf(check.check_suite?.id), { name: check.name, pr, sha, baseRef, homes, sides });
|
|
273
|
+
if (why) refused.push({ name: check.name, why }); else kept.push(check);
|
|
274
|
+
}
|
|
275
|
+
return { kept, refused };
|
|
276
|
+
}
|
|
277
|
+
|
|
198
278
|
// `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
|
|
199
279
|
// every required check is green on its head commit, 1 when not.
|
|
200
280
|
export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
@@ -204,9 +284,12 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
|
204
284
|
if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
|
|
205
285
|
const sha = pull.head.sha;
|
|
206
286
|
const checks = checksAt(gh, repo, pull.base.ref);
|
|
207
|
-
const
|
|
287
|
+
const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, head: pull.head, checks });
|
|
288
|
+
const rows = checkStates(checks, kept);
|
|
208
289
|
line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
|
|
209
290
|
for (const row of rows) line(checkLine(row));
|
|
291
|
+
// A refused run names its check and the pin it broke, never a text of the run.
|
|
292
|
+
for (const one of refused) line(` - ignored a run of ${one.name}: ${one.why}`);
|
|
210
293
|
// Checks that ran on an old base prove nothing about the merge: two pull
|
|
211
294
|
// requests, each green alone, can turn the main branch red together (atlas#188).
|
|
212
295
|
const behind = Number(gh('GET', `repos/${repo}/compare/${encodeURIComponent(pull.base.ref)}...${sha}`)?.behind_by ?? NaN);
|
|
@@ -303,12 +386,37 @@ function commitAudit({ repo, commit }, line, gh) {
|
|
|
303
386
|
return 0;
|
|
304
387
|
}
|
|
305
388
|
|
|
389
|
+
// The required checks that are red on the base commit, each with the newest
|
|
390
|
+
// first-parent commit before it where that check was green (done line 7). The
|
|
391
|
+
// gate job reads them with its read-only token. It walks back at most
|
|
392
|
+
// `depth` commits; a check with no green commit in reach is left out.
|
|
393
|
+
export function redMain({ root, base }, gh, repo, depth = 30) {
|
|
394
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
395
|
+
const sha = git(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]);
|
|
396
|
+
let checks;
|
|
397
|
+
try { checks = factsOf(git(['show', `${sha}:atlas.yaml`])).checks.filter((name) => name !== GATE_CHECK); } catch { return []; }
|
|
398
|
+
const red = checkStates(checks, checkRuns(gh, repo, sha)).filter((row) => row.state === 'red').map((row) => row.name);
|
|
399
|
+
if (!red.length) return [];
|
|
400
|
+
const older = git(['rev-list', '--first-parent', `--max-count=${depth}`, `${sha}^`]).split('\n').filter(Boolean);
|
|
401
|
+
const found = [];
|
|
402
|
+
let left = red;
|
|
403
|
+
for (const commit of older) {
|
|
404
|
+
if (!left.length) break;
|
|
405
|
+
const states = checkStates(left, checkRuns(gh, repo, commit));
|
|
406
|
+
for (const row of states.filter((each) => each.state === 'green')) found.push({ check: row.name, green: commit });
|
|
407
|
+
left = left.filter((name) => !found.some((each) => each.check === name));
|
|
408
|
+
}
|
|
409
|
+
return found;
|
|
410
|
+
}
|
|
411
|
+
|
|
306
412
|
// `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
|
|
307
413
|
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
|
|
308
414
|
if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
|
|
309
|
-
|
|
415
|
+
// With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
|
|
416
|
+
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
417
|
+
if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line, named ? () => redMain({ root, base }, gh, repo) : null);
|
|
310
418
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
311
|
-
if (!
|
|
419
|
+
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
312
420
|
if (what === 'ready') {
|
|
313
421
|
const answer = {};
|
|
314
422
|
let code = 1;
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -111,7 +111,7 @@ set. A test keeps the two lists equal.
|
|
|
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
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 |
|
|
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. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
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
|
|
|
117
117
|
A person merges every file that `atlas tooling` writes.
|