@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/lib/fails-before.mjs +21 -6
- package/door/lib/gate.mjs +111 -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,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
|
|
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
|
-
|
|
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 (!
|
|
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
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.
|