@arjunkhera/atlas 0.4.10 → 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.10",
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"
@@ -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.`);
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,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,12 +385,37 @@ function commitAudit({ repo, commit }, line, gh) {
303
385
  return 0;
304
386
  }
305
387
 
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
+
306
411
  // `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
307
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) {
308
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.`);
309
- if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line);
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);
310
417
  if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
311
- if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
418
+ if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
312
419
  if (what === 'ready') {
313
420
  const answer = {};
314
421
  let code = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.10",
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.