@arjunkhera/atlas 0.4.9 → 0.4.11

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.9",
3
+ "version": "0.4.11",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -155,6 +155,10 @@ const HELP = `atlas — the door into a repo's Atlas files
155
155
  --body <file> the pull request text, for the "behaviour change: no" of a refactor
156
156
  --json print the answer as JSON
157
157
  --summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
158
+ atlas gate replay [--days 30] the static rules of the gate over the first-parent commits of the main branch; the owner share. Writes nothing
159
+ --root <repo> the clone to read (default: this folder)
160
+ --branch <name> the branch; default origin/HEAD
161
+ --json print the answer as JSON
158
162
 
159
163
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
160
164
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
@@ -196,7 +200,7 @@ export const FLAGS = Object.freeze({
196
200
  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' },
197
201
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
198
202
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
199
- 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' },
203
+ 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' },
200
204
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
201
205
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
202
206
  upgrade: { version: 'optional', yes: 'switch' },
@@ -437,7 +441,7 @@ function upkeepVerb(chosen) {
437
441
  }
438
442
 
439
443
  function gateVerb(chosen) {
440
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output }, line);
444
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch }, line);
441
445
  }
442
446
 
443
447
  function judgeVerb(chosen) {
@@ -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
- rmSync(tree, { recursive: true, force: true });
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.red = answer.cases.some((c) => RED_RESULTS.includes(c.result));
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.`);
@@ -185,9 +185,9 @@ export function coveredFiles(root, green, mergeBase) {
185
185
  // Line 6: new product code needs a new or changed test case, and fails-before
186
186
  // proves it. Line 7: a fix of a red main check that changes no test is exempt
187
187
  // for the files that main changed since that check was last green.
188
- function testRows({ root, mergeBase, head, body, redMain }) {
188
+ function testRows({ root, mergeBase, head, body, redMain, rules }) {
189
189
  const list = listChanged({ root, base: mergeBase, head });
190
- const product = list.other_paths.filter(productTest(root, mergeBase));
190
+ const product = list.other_paths.filter(productTest(root, rules ?? mergeBase));
191
191
  if (!product.length) return [];
192
192
  const cases = list.rows.filter((row) => CASE_GROUPS.has(row.group) && row.status !== 'removed');
193
193
  if (cases.length) return [{ kind: 'note', class: 'tests', cause: `${new Set(cases.map((row) => row.path)).size} test file(s) added or changed; fails-before proves them` }];
@@ -216,7 +216,7 @@ function testRows({ root, mergeBase, head, body, redMain }) {
216
216
  // The answer for one pull request. `redMain` lists the required checks that
217
217
  // are red on the merge base: [{ check, green }], where `green` is the newest
218
218
  // commit of main on which the check was green.
219
- export function judge({ root, base, head, body = '', redMain = [] }) {
219
+ export function judge({ root, base, head, body = '', redMain = [], rules = null }) {
220
220
  const top = resolve(root);
221
221
  const baseSha = commitOf(top, base);
222
222
  if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
@@ -233,10 +233,10 @@ export function judge({ root, base, head, body = '', redMain = [] }) {
233
233
  }
234
234
  answer.merge_base = mergeBase;
235
235
  const changes = changedPaths(top, mergeBase, headSha);
236
- for (const row of classify({ root: top, base: mergeBase, head: headSha }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
237
- answer.rows.push(...jobRows(top, mergeBase, headSha, changes));
236
+ for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
237
+ answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
238
238
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
239
- answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain }));
239
+ answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules }));
240
240
  return finish(answer);
241
241
  }
242
242
 
@@ -292,3 +292,82 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
292
292
  return error.usage ? 2 : 1;
293
293
  }
294
294
  }
295
+
296
+ // ------------------------------------------------------------------ the replay
297
+
298
+ // `atlas gate replay`: the static rules over the first-parent commits of a
299
+ // branch in the last `days` days (done line 13). It writes nothing. The test
300
+ // checks cannot be replayed, so it also counts the commits that changed an
301
+ // old test: passes-after could add those to the owner share.
302
+ export function replay({ root, days = 30, branch = null, now = new Date() }) {
303
+ const top = resolve(root);
304
+ const ref = branch ?? ([(git(top, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { allowFail: true }) ?? '').trim(), 'origin/main', 'origin/master'].find((name) => name && commitOf(top, name)) || 'HEAD');
305
+ if (!commitOf(top, ref)) throw new GateCheckError(`there is no branch "${ref}" in this clone.`);
306
+ const tip = commitOf(top, ref);
307
+ const since = new Date(now.getTime() - days * 864e5).toISOString();
308
+ const shas = (git(top, ['log', '--first-parent', `--since=${since}`, '--format=%H %P', ref]) ?? '').split('\n').filter(Boolean);
309
+ const facts = parseYaml(textAt(top, ref, 'atlas.yaml') ?? '') ?? {};
310
+ const rows = [];
311
+ for (const entry of shas) {
312
+ const [sha, parent] = entry.split(' ');
313
+ if (!parent) continue;
314
+ const answer = judge({ root: top, base: parent, head: sha, rules: tip });
315
+ const list = listChanged({ root: top, base: parent, head: sha });
316
+ const oldTest = list.rows.some((row) => CASE_GROUPS.has(row.group) && row.status !== 'added');
317
+ rows.push({
318
+ sha, result: answer.result,
319
+ owner: answer.rows.some((row) => row.kind === 'owner'),
320
+ red: answer.rows.some((row) => row.kind === 'red'),
321
+ old_test: oldTest,
322
+ causes: [...new Set(answer.rows.filter((row) => row.kind !== 'note').map((row) => `${row.kind}: ${row.class}: ${causeKey(row)}`))],
323
+ });
324
+ }
325
+ const count = (test) => rows.filter(test).length;
326
+ const share = (n) => (rows.length ? Math.round((n / rows.length) * 100) : 0);
327
+ const causes = new Map();
328
+ for (const row of rows) for (const cause of row.causes) causes.set(cause, (causes.get(cause) ?? 0) + 1);
329
+ return {
330
+ gate_replay: VERSION, branch: ref, since, commits: rows.length,
331
+ meaningful: Array.isArray(facts.product_paths),
332
+ owner: count((row) => row.owner), red: count((row) => row.red), success: count((row) => row.result === 'success'),
333
+ owner_share: share(count((row) => row.owner)),
334
+ old_test_share: share(count((row) => row.old_test)),
335
+ old_test_only_share: share(count((row) => row.old_test && !row.owner)),
336
+ causes: [...causes].sort((a, b) => b[1] - a[1]).map(([cause, n]) => ({ cause, commits: n })),
337
+ rows,
338
+ };
339
+ }
340
+
341
+ // One cause for the count: a list or floor rule keeps its rule, and a path
342
+ // cause keeps its folder, so one rule counts once over many files.
343
+ function causeKey(row) {
344
+ if (/^(list|floor|sign): /.test(row.cause)) return row.cause.replace(/ \(.*\)$/, '');
345
+ if (row.class === 'secret') return row.cause;
346
+ if (row.class === 'unlisted') return 'outside product_paths';
347
+ return row.cause.replace(/: .*$/, '').replace(/ src\/\S+| \S+\/\S+/g, '');
348
+ }
349
+
350
+ export function renderReplay(answer) {
351
+ const out = [`gate replay: ${answer.commits} first-parent commit(s) of ${answer.branch} since ${answer.since.slice(0, 10)}, by the list at ${answer.branch}`];
352
+ if (!answer.meaningful) out.push(' NOT MEANINGFUL: atlas.yaml lists no product_paths, so every path comes to the owner.');
353
+ out.push(` owner share (path and sign rules): ${answer.owner_share}% (${answer.owner} of ${answer.commits})`);
354
+ out.push(` changed an old test, where passes-after decides: ${answer.old_test_share}%; of those, not owner by path: ${answer.old_test_only_share}%`);
355
+ out.push(` red: ${answer.red}, success: ${answer.success}`);
356
+ out.push(' causes, by commits:');
357
+ for (const each of answer.causes.slice(0, 12)) out.push(` ${String(each.commits).padStart(4)} ${oneLine(each.cause)}`);
358
+ return out;
359
+ }
360
+
361
+ export function replayCommand({ root, days, branch, json = false }, line) {
362
+ try {
363
+ const n = days === undefined || days === true ? 30 : Number(days);
364
+ if (!Number.isInteger(n) || n < 1 || n > 365) throw Object.assign(new GateCheckError('--days must be a whole number from 1 to 365.'), { usage: true });
365
+ const answer = replay({ root, days: n, branch: branch && branch !== true ? branch : null });
366
+ if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
367
+ else for (const each of renderReplay(answer)) line(each);
368
+ return 0;
369
+ } catch (error) {
370
+ line(`gate replay: ${error.message}`);
371
+ return error.usage ? 2 : 1;
372
+ }
373
+ }
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 } 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,85 @@ 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.
222
+ export function refusal(run, { name, pr, sha, baseRef, homes }) {
223
+ if (!run) return 'no workflow run';
224
+ 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}`;
226
+ if (run.head_sha !== sha) return 'of another head commit';
227
+ const pulls = run.pull_requests ?? [];
228
+ if (!pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
229
+ // GitHub lists every open pull request with this head, so a run made for a
230
+ // pull request into a side branch can also name this one. Such a run used
231
+ // the side branch's file, so it does not count.
232
+ 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'}`;
233
+ return null;
234
+ }
235
+
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}`;
245
+ return null;
246
+ }
247
+
248
+ // The check runs that may count for this pull request, and the ones refused.
249
+ function pinnedRuns(gh, repo, { pr, sha, baseRef, checks }) {
250
+ const homes = homesAt(gh, repo, baseRef, checks);
251
+ const suites = new Map();
252
+ const runOf = (id) => {
253
+ if (!suites.has(id)) suites.set(id, id ? (gh('GET', `repos/${repo}/actions/runs?check_suite_id=${id}`)?.workflow_runs ?? [])[0] ?? null : null);
254
+ return suites.get(id);
255
+ };
256
+ const kept = [];
257
+ const refused = [];
258
+ 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 });
261
+ if (why) refused.push({ name: check.name, why }); else kept.push(check);
262
+ }
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
+ return { kept, refused };
275
+ }
276
+
198
277
  // `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
199
278
  // every required check is green on its head commit, 1 when not.
200
279
  export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
@@ -204,9 +283,12 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
204
283
  if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
205
284
  const sha = pull.head.sha;
206
285
  const checks = checksAt(gh, repo, pull.base.ref);
207
- const rows = checkStates(checks, checkRuns(gh, repo, sha));
286
+ const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, checks });
287
+ const rows = checkStates(checks, kept);
208
288
  line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
209
289
  for (const row of rows) line(checkLine(row));
290
+ // A refused run names its check and the pin it broke, never a text of the run.
291
+ for (const one of refused) line(` - ignored a run of ${one.name}: ${one.why}`);
210
292
  // Checks that ran on an old base prove nothing about the merge: two pull
211
293
  // requests, each green alone, can turn the main branch red together (atlas#188).
212
294
  const behind = Number(gh('GET', `repos/${repo}/compare/${encodeURIComponent(pull.base.ref)}...${sha}`)?.behind_by ?? NaN);
@@ -303,11 +385,37 @@ function commitAudit({ repo, commit }, line, gh) {
303
385
  return 0;
304
386
  }
305
387
 
306
- // `atlas gate apply|ready|audit|check`. Returns the exit code.
307
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, home = homedir() }, line, gh = ghApi) {
308
- if (!['apply', 'ready', 'audit', 'check'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit or check.`);
309
- if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line);
310
- if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
388
+ // The required checks that are red on the base commit, each with the newest
389
+ // first-parent commit before it where that check was green (done line 7). The
390
+ // gate job reads them with its read-only token. It walks back at most
391
+ // `depth` commits; a check with no green commit in reach is left out.
392
+ export function redMain({ root, base }, gh, repo, depth = 30) {
393
+ const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
394
+ const sha = git(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]);
395
+ let checks;
396
+ try { checks = factsOf(git(['show', `${sha}:atlas.yaml`])).checks.filter((name) => name !== GATE_CHECK); } catch { return []; }
397
+ const red = checkStates(checks, checkRuns(gh, repo, sha)).filter((row) => row.state === 'red').map((row) => row.name);
398
+ if (!red.length) return [];
399
+ const older = git(['rev-list', '--first-parent', `--max-count=${depth}`, `${sha}^`]).split('\n').filter(Boolean);
400
+ const found = [];
401
+ let left = red;
402
+ for (const commit of older) {
403
+ if (!left.length) break;
404
+ const states = checkStates(left, checkRuns(gh, repo, commit));
405
+ for (const row of states.filter((each) => each.state === 'green')) found.push({ check: row.name, green: commit });
406
+ left = left.filter((name) => !found.some((each) => each.check === name));
407
+ }
408
+ return found;
409
+ }
410
+
411
+ // `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
412
+ 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) {
413
+ if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
414
+ // With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
415
+ const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
416
+ if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line, named ? () => redMain({ root, base }, gh, repo) : null);
417
+ if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
418
+ if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
311
419
  if (what === 'ready') {
312
420
  const answer = {};
313
421
  let code = 1;
@@ -496,8 +496,11 @@ const newest = (paths) => paths.reduce((best, path) => (best === null || newer(p
496
496
 
497
497
  // ------------------------------------------------------------------ classify
498
498
 
499
- export function classify({ root, base, head, classes = loadClasses() }) {
500
- const { rules, product, tree: baseTree } = rulesAt({ root, ref: base, classes });
499
+ // `rules` names the commit whose list holds; the default is the base. The
500
+ // gate replay passes today's main branch, to ask what today's list would do.
501
+ export function classify({ root, base, head, rules: rulesRef = null, classes = loadClasses() }) {
502
+ const { rules, product, tree } = rulesAt({ root, ref: rulesRef ?? base, classes });
503
+ const baseTree = rulesRef ? treeAt(root, base) : tree;
501
504
  const headTree = treeAt(root, head);
502
505
  const changes = changedPaths(root, base, head);
503
506
  const rows = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
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",
@@ -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.