@arjunkhera/atlas 0.4.3 → 0.4.5

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.3",
3
+ "version": "0.4.5",
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
@@ -33,6 +33,7 @@ import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunComman
33
33
  import { runKept } from '../tests/kept.mjs';
34
34
  import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
35
35
  import { issuesCommand as testsIssues } from './lib/issues.mjs';
36
+ import { jobsCommand } from './lib/jobs.mjs';
36
37
  import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
37
38
  import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
38
39
  import { afterCommand as testsAfter } from './lib/passes-after.mjs';
@@ -129,6 +130,14 @@ const HELP = `atlas — the door into a repo's Atlas files
129
130
  --tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
130
131
  --evidence <dir> where the evidence files are; default <area>/test/evidence
131
132
 
133
+ atlas jobs check --root <repo> --repo <owner/name> the watcher: run the check of each Atlas job that atlas.yaml names; findings as JSON
134
+ --branch <name> the default branch; default GitHub's answer
135
+ --out <file> write the JSON here; default the terminal
136
+ atlas jobs file --findings <file> --repo <owner/name> open one atlas-job issue for each new finding of a jobs check; never closes one
137
+ --max <n> open at most n issues in one run; default 10. The rest are listed, and the exit is 1
138
+ --dry-run print what it would open; change nothing
139
+ The token is only GITHUB_TOKEN. The atlas-jobs workflow runs both, once a day
140
+
132
141
  atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, and in a pull request its tracker item
133
142
  --event <file> the GitHub pull request event; adds the item check
134
143
  --json print JSON
@@ -180,6 +189,7 @@ export const FLAGS = Object.freeze({
180
189
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
181
190
  view: { product: 'value', name: 'value', out: 'value' },
182
191
  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' },
192
+ jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
183
193
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
184
194
  gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
185
195
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
@@ -413,6 +423,10 @@ async function testsCommand(chosen) {
413
423
  throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, after, sweep, covers, kept, issues or rerun.`);
414
424
  }
415
425
 
426
+ function jobsVerb(chosen) {
427
+ return jobsCommand({ what: chosen._[1], root: rootOf(chosen), repo: chosen.repo, branch: chosen.branch, out: chosen.out, findings: chosen.findings, max: chosen.max, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
428
+ }
429
+
416
430
  function upkeepVerb(chosen) {
417
431
  return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
418
432
  }
@@ -457,6 +471,7 @@ export async function main(argv = process.argv.slice(2)) {
457
471
  case 'design': return designCommand(chosen);
458
472
  case 'view': return viewCommand(chosen);
459
473
  case 'tests': return testsCommand(chosen);
474
+ case 'jobs': return jobsVerb(chosen);
460
475
  case 'upkeep': return upkeepVerb(chosen);
461
476
  case 'gate': return gateVerb(chosen);
462
477
  case 'judge': return judgeVerb(chosen);
@@ -0,0 +1,15 @@
1
+ # An Atlas job (the routines design, records part). A job does nothing until a
2
+ # repo names it in its atlas.yaml:
3
+ #
4
+ # jobs:
5
+ # atlas: [red-main]
6
+ #
7
+ # The check is Atlas code (door/lib/jobs.mjs). It reads the runs of the main
8
+ # branch only, never a run from a fork. `fields` are the typed fields that a
9
+ # finding of this job may hold. Atlas code drops every other field.
10
+ title: The newest CI run on the main branch passes
11
+ when: red-main
12
+ needs: [tests]
13
+ act: atlas:red-main
14
+ may: pull-request
15
+ fields: [workflow, run_id, head_sha, conclusion]
@@ -47,6 +47,10 @@
47
47
  {
48
48
  "kit_sha256": "8d6ae3c4dede74bafe292a992de3ef650e5c5566fb282081a9de23b8d30d9025",
49
49
  "first_package": "0.3.39"
50
+ },
51
+ {
52
+ "kit_sha256": "41d64800daf644174dd1daef1c95ccc2ca7a8460dbe3e36bcf2625d05fb0479e",
53
+ "first_package": "0.3.42"
50
54
  }
51
55
  ]
52
56
  }
package/door/lib/gate.mjs CHANGED
@@ -206,9 +206,17 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
206
206
  const rows = checkStates(checks, checkRuns(gh, repo, sha));
207
207
  line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
208
208
  for (const row of rows) line(checkLine(row));
209
- const ready = rows.every((row) => row.state === 'green');
210
- line(ready ? 'READY: every required check is green. Merge with a merge commit, at this head.' : 'NOT READY: do not merge. Wait for a pending check; fix a red or missing one.');
211
- return ready ? 0 : 1;
209
+ // Checks that ran on an old base prove nothing about the merge: two pull
210
+ // requests, each green alone, can turn the main branch red together (atlas#188).
211
+ const behind = Number(gh('GET', `repos/${repo}/compare/${encodeURIComponent(pull.base.ref)}...${sha}`)?.behind_by ?? NaN);
212
+ if (!(behind >= 0)) throw new GateError(`GitHub gave no compare of ${pull.base.ref} and ${sha.slice(0, 7)}.`);
213
+ line(` ${behind === 0 ? '✓' : '✗'} ${(behind === 0 ? 'current' : 'behind').padEnd(8)} ${behind === 0 ? `the head holds every commit of ${pull.base.ref}` : `${pull.base.ref} has ${behind} commit(s) that the head does not hold`}`);
214
+ const green = rows.every((row) => row.state === 'green');
215
+ if (green && behind === 0) { line('READY: every required check is green, on top of the current main branch. Merge with a merge commit, at this head.'); return 0; }
216
+ line(behind > 0
217
+ ? `NOT READY: do not merge. Merge ${pull.base.ref} in, wait for green, then run ready again.`
218
+ : 'NOT READY: do not merge. Wait for a pending check; fix a red or missing one.');
219
+ return 1;
212
220
  }
213
221
 
214
222
  // The merge door hook (packages/atlas/hooks/door.mjs) reads this file before
@@ -22,7 +22,7 @@ import { resolve } from 'node:path';
22
22
  import { LABEL } from '../../tests/sweep.mjs';
23
23
 
24
24
  const usage = (message) => Object.assign(new Error(message), { usage: true });
25
- const TOKEN_SHAPE = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\b[0-9a-f]{40}\b)/;
25
+ export const TOKEN_SHAPE = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\b[0-9a-f]{40}\b)/;
26
26
  const SEGMENT = /^[A-Za-z0-9_.-]+$/;
27
27
  const PAGE = 100;
28
28
  export const DEFAULT_BOT = 'github-actions[bot]';
@@ -43,7 +43,7 @@ export function apiBase(env) {
43
43
  return text.replace(/\/+$/, '');
44
44
  }
45
45
 
46
- async function api(base, key, method, path, body) {
46
+ export async function api(base, key, method, path, body) {
47
47
  const response = await fetch(`${base}${path}`, {
48
48
  method,
49
49
  headers: {
@@ -63,6 +63,12 @@ async function api(base, key, method, path, body) {
63
63
 
64
64
  export const LABEL_COLOR = 'c5def5';
65
65
  export const LABEL_TEXT = 'Opened by the daily sweep of the scenario tests (atlas tests sweep)';
66
+ // Each label an issue may carry, and its line. A label with no line here gets a plain one.
67
+ const LABEL_TEXTS = {
68
+ [LABEL]: LABEL_TEXT,
69
+ 'atlas-job': 'Opened by the Atlas watcher (atlas-jobs.yml); a runner acts on it',
70
+ };
71
+ const labelText = (label) => LABEL_TEXTS[label] ?? (label.startsWith('job:') ? `The Atlas job ${label.slice(4)}` : 'Opened by Atlas');
66
72
 
67
73
  // The label must exist before the first issue: a token that may only write issues may not create a label by
68
74
  // naming it in an issue. GET the label; on 404, create it. A 422 means it exists by now (a race), which is fine.
@@ -72,7 +78,7 @@ async function ensureLabel(base, key, repo, label, line) {
72
78
  const have = await api(base, key, 'GET', `/repos/${repo}/labels/${name}`);
73
79
  if (have.ok) return;
74
80
  if (have.status !== 404) { line(`label GitHub answered ${have.status} when the label ${label} was read; going on`); return; }
75
- const made = await api(base, key, 'POST', `/repos/${repo}/labels`, { name: label, color: LABEL_COLOR, description: LABEL_TEXT });
81
+ const made = await api(base, key, 'POST', `/repos/${repo}/labels`, { name: label, color: LABEL_COLOR, description: labelText(label) });
76
82
  if (made.ok || made.status === 422) line(`label ${made.ok ? 'created' : 'already exists'}: ${label}`);
77
83
  else line(`label GitHub answered ${made.status} when the label ${label} was created; going on`);
78
84
  }
@@ -99,42 +105,50 @@ export function readFindings(text) {
99
105
  return data.findings;
100
106
  }
101
107
 
102
- // An issue of the bot that holds the marker and stops a new one.
103
- const stops = (issue, marker, bot) => !issue.pull_request
108
+ // An issue of the bot that holds the marker and stops a new one. `closedStops` says which closed issue
109
+ // still stops one: for the sweep, one closed as not_planned.
110
+ const sweepClosedStops = (issue) => issue.state_reason === 'not_planned';
111
+ const stops = (issue, marker, bot, closedStops) => !issue.pull_request
104
112
  && issue.user?.login === bot
105
113
  && typeof issue.body === 'string' && issue.body.includes(marker)
106
- && (issue.state === 'open' || (issue.state === 'closed' && issue.state_reason === 'not_planned'));
114
+ && (issue.state === 'open' || (issue.state === 'closed' && closedStops(issue)));
107
115
 
108
- export async function openIssues({ findings, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line }) {
116
+ // `label` is the label whose issues are read for markers: the sweep's own by default. Every label of a
117
+ // finding is made before its first issue, and the issue must carry `label`.
118
+ export async function openIssues({ findings, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, label = LABEL, closedStops = sweepClosedStops }) {
109
119
  const base = apiBase(env);
110
120
  const key = env.GITHUB_TOKEN || '';
111
121
  let known = [];
112
122
  if (!dryRun || key) {
113
123
  if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
114
- known = await labelled(base, key, repo, LABEL);
124
+ known = await labelled(base, key, repo, label);
115
125
  } else line('dry run with no GITHUB_TOKEN: the existing issues are not read, so every finding shows as new.');
116
126
  const opened = [];
117
127
  const skipped = [];
118
128
  const deferred = [];
119
129
  const failed = [];
120
130
  const seen = new Set();
121
- let labelEnsured = false;
131
+ const ensured = new Set();
122
132
  for (const f of findings) {
123
133
  const { title, body, marker, labels } = f.issue;
124
- const hit = known.find((i) => stops(i, marker, bot));
134
+ const hit = known.find((i) => stops(i, marker, bot, closedStops));
125
135
  if (hit || seen.has(marker)) { skipped.push(f.key); line(`skip ${f.key}: ${hit ? `issue #${hit.number} (${hit.state}) of ${bot} holds the marker` : 'a finding of this file has the same marker'}`); continue; }
126
136
  seen.add(marker);
127
137
  if (opened.length >= max) { deferred.push(f.key); line(`later ${f.key}: the limit of ${max} issue(s) for one run is reached`); continue; }
128
138
  const cut = String(title).slice(0, 200);
129
139
  if (dryRun) { opened.push(f.key); line(`would open ${f.key}: ${cut} [${labels.join(', ')}]`); continue; }
130
140
  try {
131
- if (!labelEnsured) { labelEnsured = true; await ensureLabel(base, key, repo, LABEL, line); }
141
+ for (const name of [label, ...labels]) {
142
+ if (ensured.has(name)) continue;
143
+ ensured.add(name);
144
+ await ensureLabel(base, key, repo, name, line);
145
+ }
132
146
  const r = await api(base, key, 'POST', `/repos/${repo}/issues`, { title: cut, body, labels });
133
147
  if (!r.ok) throw new Error(`GitHub answered ${r.status}`);
134
148
  opened.push(f.key);
135
149
  line(`opened #${r.data?.number ?? '?'} ${f.key}: ${cut}${r.data?.html_url ? ` ${r.data.html_url}` : ''}`);
136
- const has = (r.data?.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === LABEL);
137
- if (!has) { failed.push(f.key); line(`label ${f.key}: the new issue does not carry the label ${LABEL}; the token may not create labels. Create the label by hand.`); }
150
+ const has = (r.data?.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === label);
151
+ if (!has) { failed.push(f.key); line(`label ${f.key}: the new issue does not carry the label ${label}; the token may not create labels. Create the label by hand.`); }
138
152
  } catch (error) {
139
153
  failed.push(f.key);
140
154
  line(`failed ${f.key}: ${String(error.message).split(key).join('(token)')}`);
@@ -0,0 +1,241 @@
1
+ // `atlas jobs check` and `atlas jobs file`: the watcher of the routines design
2
+ // (its records and security parts).
3
+ //
4
+ // atlas jobs check --root <repo> --repo <owner/name> [--branch <name>] [--out <file>]
5
+ // atlas jobs file --findings <file> --repo <owner/name> [--max <n>] [--dry-run]
6
+ //
7
+ // The workflow `.github/workflows/atlas-jobs.yml` runs both, from the default branch, once a day.
8
+ // `atlas tooling` writes it from door/templates/atlas-jobs.yml.
9
+ //
10
+ // check reads `jobs: { atlas: [...] }` from atlas.yaml and runs the check of each Atlas job it
11
+ // names. It reads; it writes no issue. Its job in the workflow holds read permissions only.
12
+ // file reads the JSON of the check, keeps the typed fields of each finding and drops the rest, and
13
+ // opens one `atlas-job` issue for each new finding, through openIssues (issues.mjs).
14
+ //
15
+ // The check envelope (records part, "The check envelope"):
16
+ // { "jobs": 1, "state": "none" | "found" | "broken", "findings": [ { "job", "key", <typed fields> } ] }
17
+ // A finding holds ids, paths, versions and numbers only. No prose reaches an issue: every line of an
18
+ // issue is Atlas code, and the finding goes into it as one JSON block.
19
+ //
20
+ // A finding is new when no issue of the bot holds its marker, except for one that is closed. A closed
21
+ // issue with the label `handed-over` still stops a new one for 30 days: the runner gave the finding to
22
+ // the tracker. The token is only GITHUB_TOKEN, as for `atlas tests issues`.
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
24
+ import { dirname, join, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { parseYaml } from '../../shape/check.mjs';
27
+ import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
28
+
29
+ export const JOBS_LABEL = 'atlas-job';
30
+ export const HANDED_OVER = 'handed-over';
31
+ export const HANDED_OVER_DAYS = 30;
32
+ export const JOBS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'jobs');
33
+ export const ENVELOPE = 1;
34
+ export const STATES = Object.freeze(['none', 'found', 'broken']);
35
+ export const MAX_FINDINGS = 50;
36
+
37
+ const usage = (message) => Object.assign(new Error(message), { usage: true });
38
+ const DAY = 24 * 60 * 60 * 1000;
39
+
40
+ // A job id, a key, and each typed value. A value is an id, a path, a version or a number: no space and
41
+ // no prose. A key may hold a path, so it takes a slash and a dot.
42
+ const JOB_ID = /^[a-z][a-z0-9-]{0,40}$/;
43
+ const KEY = /^[A-Za-z0-9._/:@+-]{1,200}$/;
44
+ const VALUE = /^[A-Za-z0-9._/:@+-]{1,200}$/;
45
+ const FIELD = /^[a-z][a-z0-9_]{0,40}$/;
46
+
47
+ // ---------------------------------------------------------------- the Atlas jobs
48
+
49
+ // Every Atlas job of this package: door/jobs/<id>.yaml.
50
+ export function atlasJobs(dir = JOBS_DIR) {
51
+ const out = new Map();
52
+ if (!existsSync(dir)) return out;
53
+ for (const name of readdirSync(dir).sort()) {
54
+ if (!name.endsWith('.yaml')) continue;
55
+ const id = name.slice(0, -'.yaml'.length);
56
+ const job = parseYaml(readFileSync(join(dir, name), 'utf8')) ?? {};
57
+ out.set(id, { id, ...job, fields: Array.isArray(job.fields) ? job.fields.map(String).filter((f) => FIELD.test(f)) : [] });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // The Atlas jobs that the repo's atlas.yaml names, read from the checkout of the default branch.
63
+ export function namedJobs(root) {
64
+ const file = join(root, 'atlas.yaml');
65
+ if (!existsSync(file)) throw usage(`there is no atlas.yaml in ${root}`);
66
+ const facts = parseYaml(readFileSync(file, 'utf8')) ?? {};
67
+ const named = facts.jobs?.atlas;
68
+ if (named === undefined || named === null) return [];
69
+ if (!Array.isArray(named)) throw usage('jobs.atlas in atlas.yaml must be a list of job ids');
70
+ return named.map(String);
71
+ }
72
+
73
+ // ---------------------------------------------------------------- the check of red-main
74
+
75
+ // A red run: it ended and did not pass. A cancelled run is not red: a newer push cancels an older run.
76
+ const RED = new Set(['failure', 'timed_out', 'startup_failure']);
77
+
78
+ // The newest finished run of each workflow on the default branch, from a push to this repo. A run of a
79
+ // pull request, a schedule, or a fork is never read: its event is not push, or its head repo is another.
80
+ export function newestMainRuns(runs, { repo, branch }) {
81
+ const newest = new Map();
82
+ for (const run of runs) {
83
+ if (run?.event !== 'push' || run?.head_branch !== branch || run?.status !== 'completed') continue;
84
+ if (run?.head_repository?.full_name !== repo || run?.repository?.full_name !== repo) continue;
85
+ const path = String(run.path ?? '').replace(/@.*$/, '');
86
+ if (!path || !VALUE.test(path)) continue;
87
+ const have = newest.get(path);
88
+ if (!have || Date.parse(run.created_at) > Date.parse(have.created_at)) newest.set(path, run);
89
+ }
90
+ return newest;
91
+ }
92
+
93
+ export async function checkRedMain({ repo, branch, env }) {
94
+ const base = apiBase(env);
95
+ const key = env.GITHUB_TOKEN || '';
96
+ if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
97
+ const query = `branch=${encodeURIComponent(branch)}&event=push&status=completed&per_page=100`;
98
+ const r = await api(base, key, 'GET', `/repos/${repo}/actions/runs?${query}`);
99
+ if (!r.ok || !Array.isArray(r.data?.workflow_runs)) throw new Error(`GitHub answered ${r.status} when the runs of ${branch} were listed`);
100
+ const findings = [];
101
+ for (const [path, run] of [...newestMainRuns(r.data.workflow_runs, { repo, branch })].sort(([a], [b]) => (a < b ? -1 : 1))) {
102
+ if (!RED.has(run.conclusion)) continue;
103
+ findings.push({ job: 'red-main', key: path, workflow: path, run_id: Number(run.id), head_sha: String(run.head_sha), conclusion: String(run.conclusion) });
104
+ }
105
+ return findings;
106
+ }
107
+
108
+ // The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
109
+ // says so in the notes; it never reads `found` from nothing.
110
+ const CHECKS = { 'red-main': checkRedMain };
111
+
112
+ export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs() }) {
113
+ const named = namedJobs(root);
114
+ const notes = [];
115
+ const ran = [];
116
+ const findings = [];
117
+ let state = 'none';
118
+ let mainBranch = branch;
119
+ for (const id of named) {
120
+ const job = jobs.get(id);
121
+ if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
122
+ const check = CHECKS[job.when];
123
+ if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
124
+ try {
125
+ if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
126
+ const found = (await check({ repo, branch: mainBranch, env })).map((f) => ({ ...f, job: id }));
127
+ findings.push(...found);
128
+ ran.push({ job: id, state: found.length ? 'found' : 'none' });
129
+ } catch (error) {
130
+ if (error.usage) throw error;
131
+ // The message never holds the token: it is built from fixed words and statuses.
132
+ ran.push({ job: id, state: 'broken' });
133
+ notes.push(`${id}: broken: ${String(error.message).split(env.GITHUB_TOKEN || '\u0000').join('(token)')}`);
134
+ }
135
+ }
136
+ if (ran.some((one) => one.state === 'broken')) state = 'broken';
137
+ else if (findings.length) state = 'found';
138
+ return { jobs: ENVELOPE, repo, branch: mainBranch, state, ran, notes, findings };
139
+ }
140
+
141
+ async function defaultBranch({ repo, env }) {
142
+ const r = await api(apiBase(env), env.GITHUB_TOKEN || '', 'GET', `/repos/${repo}`);
143
+ const name = r.data?.default_branch;
144
+ if (!r.ok || typeof name !== 'string' || !VALUE.test(name)) throw new Error(`GitHub answered ${r.status} when the default branch was read`);
145
+ return name;
146
+ }
147
+
148
+ // ---------------------------------------------------------------- the file job
149
+
150
+ // Keep a finding only when its job is an Atlas job, its key is typed, and it has at least one field.
151
+ // Keep only the fields that the job lists, and only typed values. Count what is dropped.
152
+ export function typedFindings(envelope, jobs = atlasJobs()) {
153
+ if (!envelope || envelope.jobs !== ENVELOPE || !STATES.includes(envelope.state) || !Array.isArray(envelope.findings)) {
154
+ throw usage('the findings file is not the output of `atlas jobs check`');
155
+ }
156
+ const kept = [];
157
+ let dropped = 0;
158
+ for (const f of envelope.findings.slice(0, MAX_FINDINGS)) {
159
+ const job = jobs.get(String(f?.job));
160
+ if (!job || !KEY.test(String(f?.key ?? ''))) { dropped += 1; continue; }
161
+ const one = { job: job.id, key: String(f.key) };
162
+ for (const field of job.fields) {
163
+ const value = f[field];
164
+ if (typeof value === 'number' && Number.isFinite(value)) one[field] = value;
165
+ else if (typeof value === 'string' && VALUE.test(value)) one[field] = value;
166
+ }
167
+ kept.push(one);
168
+ }
169
+ dropped += Math.max(0, envelope.findings.length - MAX_FINDINGS);
170
+ return { findings: kept, dropped };
171
+ }
172
+
173
+ // An HTML comment may not hold two hyphens in a row, so the marker spells them another way.
174
+ export const jobMarker = (job, key) => `<!-- atlas-job:${job}:${String(key).replace(/--/g, '-~')} -->`;
175
+
176
+ // The issue of one finding (records part, "The atlas-job issue").
177
+ export function jobIssue(finding) {
178
+ const marker = jobMarker(finding.job, finding.key);
179
+ const body = [
180
+ `The Atlas watcher found this in the job \`${finding.job}\`. Atlas code wrote this issue. It holds typed fields only.`,
181
+ '',
182
+ '```json',
183
+ JSON.stringify(finding, null, 2),
184
+ '```',
185
+ '',
186
+ 'A runner acts on it later. The watcher never edits or closes this issue.',
187
+ '',
188
+ marker,
189
+ ].join('\n');
190
+ return { title: `atlas-job: ${finding.job} ${finding.key}`.slice(0, 200), body, labels: [JOBS_LABEL, `job:${finding.job}`], marker };
191
+ }
192
+
193
+ // A closed issue stops a new one only when the runner handed it over in the last 30 days.
194
+ export const handedOverStops = (now = Date.now()) => (issue) => (issue.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === HANDED_OVER)
195
+ && Number.isFinite(Date.parse(issue.closed_at)) && now - Date.parse(issue.closed_at) <= HANDED_OVER_DAYS * DAY;
196
+
197
+ export async function fileJobs({ envelope, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, now = Date.now(), jobs = atlasJobs() }) {
198
+ const { findings, dropped } = typedFindings(envelope, jobs);
199
+ if (dropped) line(`dropped ${dropped} finding(s) that are not typed, or that name no Atlas job`);
200
+ if (envelope.state === 'broken') line('the check reads broken, so a check found nothing it can trust. The run ends red.');
201
+ const list = findings.map((f) => ({ key: `${f.job}:${f.key}`, issue: jobIssue(f) }));
202
+ const result = list.length
203
+ ? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: handedOverStops(now) })
204
+ : (line('no finding: nothing to file.'), { opened: [], skipped: [], deferred: [], failed: [] });
205
+ return { ...result, broken: envelope.state === 'broken' };
206
+ }
207
+
208
+ // ---------------------------------------------------------------- the command
209
+
210
+ export async function jobsCommand({ what, root, repo, branch, out, findings, max, dryRun = false, bot, args = [] }, line, env = process.env) {
211
+ try {
212
+ if ([...args, findings, repo, out, branch].some((a) => typeof a === 'string' && TOKEN_SHAPE.test(a))) throw usage('a token must never be an argument; put it in GITHUB_TOKEN');
213
+ if (!repoOk(repo)) throw usage('give the repo as --repo <owner/name>');
214
+ if (what === 'check') {
215
+ if (branch !== undefined && (typeof branch !== 'string' || !VALUE.test(branch))) throw usage('--branch must name a branch');
216
+ const result = await jobsCheck({ root, repo, branch: branch ?? null, env });
217
+ const json = `${JSON.stringify(result, null, 2)}\n`;
218
+ if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(resolve(out), json); }
219
+ line(`jobs check: ${result.state}. ${result.ran.length} job(s) ran, ${result.findings.length} finding(s).${out ? ` Written to ${out}.` : ''}`);
220
+ for (const one of result.ran) line(` ${one.job}: ${one.state}`);
221
+ for (const note of result.notes) line(` note: ${note}`);
222
+ if (!out) line(json.trimEnd());
223
+ return result.state === 'broken' ? 1 : 0;
224
+ }
225
+ if (what === 'file') {
226
+ if (!findings) throw usage('give the check file: --findings <file>');
227
+ const limit = max === undefined ? DEFAULT_MAX : Number(max);
228
+ if (!Number.isInteger(limit) || limit < 0) throw usage('--max must be a whole number, 0 or more');
229
+ const file = resolve(findings);
230
+ if (!existsSync(file)) throw usage(`the findings file ${file} does not exist`);
231
+ let envelope;
232
+ try { envelope = JSON.parse(readFileSync(file, 'utf8')); } catch { throw usage('the findings file is not JSON'); }
233
+ const r = await fileJobs({ envelope, repo, dryRun: Boolean(dryRun), max: limit, bot: bot || DEFAULT_BOT, env, line });
234
+ return r.broken || r.deferred.length || r.failed.length ? 1 : 0;
235
+ }
236
+ throw usage(`there is no jobs verb "${what ?? ''}". Use check or file.`);
237
+ } catch (error) {
238
+ line(`jobs: ${String(error.message).split(env.GITHUB_TOKEN || '\u0000').join('(token)')}`);
239
+ return error.usage ? 2 : 1;
240
+ }
241
+ }
@@ -23,7 +23,7 @@ export const TOOLING_LABEL = 'atlas-helper';
23
23
 
24
24
  // The only files the tooling writer may write: the check, its workflow, and
25
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
+ export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
27
27
 
28
28
  export class ChangeError extends Error {}
29
29
 
@@ -24,6 +24,32 @@ import { PR_TEMPLATE_PATH } from './upkeep.mjs';
24
24
 
25
25
  export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
26
26
 
27
+ // The watcher of the routines design (roadmap R24). The writer puts it into each
28
+ // repo, with the version of this package as its pin. It refreshes a copy whose pin
29
+ // is older than this package, and keeps any other copy: a refresh never moves a
30
+ // repo backwards, and never replaces a hand edit at the same version.
31
+ export const JOBS_PATH = '.github/workflows/atlas-jobs.yml';
32
+ export const PACKAGE_JOBS_SOURCE = 'door/templates/atlas-jobs.yml';
33
+ const VERSION_SLOT = '__ATLAS_VERSION__';
34
+ export function packagedJobsWorkflow(packageRoot) {
35
+ const full = join(packageRoot, PACKAGE_JOBS_SOURCE);
36
+ return existsSync(full) ? readFileSync(full, 'utf8').split(VERSION_SLOT).join(packagePackageVersion(packageRoot)) : null;
37
+ }
38
+ const jobsPin = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
39
+ const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
40
+ // written, refreshed, unchanged or kept: what the writer does with the watcher.
41
+ function jobsState(root, packageRoot) {
42
+ const wanted = packagedJobsWorkflow(packageRoot);
43
+ if (wanted === null) return null;
44
+ const full = join(root, JOBS_PATH);
45
+ if (!existsSync(full)) return 'written';
46
+ const was = readFileSync(full, 'utf8');
47
+ if (was === wanted) return 'unchanged';
48
+ const have = jobsPin(was);
49
+ return have && older(have, jobsPin(wanted)) ? 'refreshed' : 'kept';
50
+ }
51
+ const jobsNeedWrite = (root, packageRoot) => ['written', 'refreshed'].includes(jobsState(root, packageRoot));
52
+
27
53
  // The pull request template (roadmap R8). The writer puts it into a repo that
28
54
  // has none, and never replaces a template the repo keeps.
29
55
  export const PACKAGE_TEMPLATE_SOURCE = 'door/templates/pull_request_template.md';
@@ -75,7 +101,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
75
101
  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.`);
76
102
  }
77
103
  const now = status({ root, packageRoot });
78
- if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot)) {
104
+ if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot)) {
79
105
  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 });
80
106
  }
81
107
  if (now.state === 'unknown' && !replaceUnknown && !own) {
@@ -101,6 +127,17 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
101
127
  }
102
128
  rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
103
129
  }
130
+ const jobs = workflowOnly ? null : jobsState(root, packageRoot);
131
+ if (jobs !== null) {
132
+ const contents = packagedJobsWorkflow(packageRoot);
133
+ const full = join(root, JOBS_PATH);
134
+ const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
135
+ if ((jobs === 'written' || jobs === 'refreshed') && !dryRun) {
136
+ mkdirSync(dirname(full), { recursive: true });
137
+ writeFileSync(full, contents);
138
+ }
139
+ rows.push({ path: JOBS_PATH, state: jobs, sha256: sha256(jobs === 'kept' ? was : contents), was: was === null ? null : sha256(was) });
140
+ }
104
141
  const template = workflowOnly ? null : packagedTemplate(packageRoot);
105
142
  if (template !== null) {
106
143
  const contents = template;
@@ -125,7 +162,7 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
125
162
  if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
126
163
  throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
127
164
  }
128
- const wanted = { ...packagedFiles(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
165
+ const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
129
166
  return rows
130
167
  .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
131
168
  .map((row) => ({
@@ -137,7 +174,9 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
137
174
  ? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
138
175
  : row.path === PR_TEMPLATE_PATH
139
176
  ? `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}`,
177
+ : row.path === JOBS_PATH
178
+ ? `written from ${PACKAGE_JOBS_SOURCE}, with its pin set to Atlas ${version}`
179
+ : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
141
180
  },
142
181
  }));
143
182
  }
@@ -0,0 +1,73 @@
1
+ name: atlas-jobs
2
+ # The Atlas watcher: plain code, no model and no secret. `atlas tooling` wrote
3
+ # this file from the Atlas package; do not edit it by hand.
4
+ #
5
+ # It runs once a day from the default branch, and never on a pull request, so
6
+ # a pull request cannot change what it runs. It has two jobs:
7
+ # check reads the repo and the CI runs of the default branch. It holds only
8
+ # read permissions and the run's own token, and no repo secret.
9
+ # file holds only issues write. It runs Atlas code on the JSON of the
10
+ # check job, and files one `atlas-job` issue for each new finding.
11
+ # A job does nothing until atlas.yaml names it under `jobs: { atlas: [...] }`.
12
+ on:
13
+ schedule:
14
+ - cron: "17 4 * * *"
15
+ workflow_dispatch:
16
+
17
+ permissions: {}
18
+
19
+ concurrency:
20
+ group: atlas-jobs
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ check:
25
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
26
+ runs-on: ubuntu-latest
27
+ timeout-minutes: 10
28
+ permissions:
29
+ contents: read
30
+ actions: read
31
+ outputs:
32
+ findings: ${{ steps.check.outputs.findings }}
33
+ steps:
34
+ # actions/checkout v7.0.1
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
36
+ with:
37
+ persist-credentials: false
38
+ # actions/setup-node v7.0.0
39
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
40
+ with:
41
+ node-version: "22.22.0"
42
+ - name: Run the check of each job that atlas.yaml names
43
+ id: check
44
+ env:
45
+ GITHUB_TOKEN: ${{ github.token }}
46
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
47
+ run: |
48
+ set -euo pipefail
49
+ # A broken check still hands its JSON on, so the file job reads it and ends the run red.
50
+ status=0
51
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ jobs check --root . --repo "$GITHUB_REPOSITORY" --branch "$DEFAULT_BRANCH" --out "$RUNNER_TEMP/jobs.json" | tee -a "$GITHUB_STEP_SUMMARY" || status=$?
52
+ test -s "$RUNNER_TEMP/jobs.json" || { echo "::error::the check wrote no JSON (exit $status)"; exit 1; }
53
+ echo "findings=$(base64 -w0 < "$RUNNER_TEMP/jobs.json")" >> "$GITHUB_OUTPUT"
54
+
55
+ file:
56
+ needs: check
57
+ runs-on: ubuntu-latest
58
+ timeout-minutes: 5
59
+ permissions:
60
+ issues: write
61
+ steps:
62
+ # actions/setup-node v7.0.0
63
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
64
+ with:
65
+ node-version: "22.22.0"
66
+ - name: File one issue for each new finding
67
+ env:
68
+ GITHUB_TOKEN: ${{ github.token }}
69
+ FINDINGS: ${{ needs.check.outputs.findings }}
70
+ run: |
71
+ set -euo pipefail
72
+ printf '%s' "$FINDINGS" | base64 -d > "$RUNNER_TEMP/jobs.json"
73
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ jobs file --findings "$RUNNER_TEMP/jobs.json" --repo "$GITHUB_REPOSITORY" | tee -a "$GITHUB_STEP_SUMMARY"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -104,6 +104,7 @@ set. A test keeps the two lists equal.
104
104
  | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed`, `before` and `after` run the test checks of the gate: changed tests, new cases fail on old code, old cases pass on new code. `sweep` and `issues` run the daily sweep |
105
105
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
106
106
  | `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 |
107
+ | `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` |
107
108
  | `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 |
108
109
  | `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 |
109
110
  | `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 |
@@ -148,7 +148,9 @@ A lock is the owner's approval of a short design.
148
148
  nothing, so this step is the gate (owner, 24 October 2026).
149
149
  1. Right before the merge, run `atlas gate` with the verb `ready`,
150
150
  `--repo` and `--pr`.
151
- 2. A pending check: wait for it. A red or missing check: fix it.
151
+ 2. A pending check: wait for it. A red or missing check: fix it. A
152
+ branch behind the main branch: merge the main branch in, wait for
153
+ green, and run `ready` again.
152
154
  3. Merge only when it prints READY. Name the head SHA that it read in
153
155
  the merge call, so a later push cannot slip in.
154
156
  4. Where the verb `apply` set the rulesets, the branch rule holds the
@@ -97,7 +97,9 @@ If the tracker cannot be reached, skip this half and say so.
97
97
  diff changes only index lines and link targets, run `atlas gate` with
98
98
  the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
99
99
  commit at the head SHA that ready read. The merge door hook refuses any
100
- other merge. Otherwise leave it open and say why in the report.
100
+ other merge. When the branch is behind the main branch, merge the main
101
+ branch in, wait for green, and run `ready` again. Otherwise leave it
102
+ open and say why in the report.
101
103
 
102
104
  ## The run line
103
105
 
package/tests/sweep.mjs CHANGED
@@ -10,7 +10,8 @@
10
10
  // 1 flaky each scenario/assertion with a `flaky` result, and on how many commits
11
11
  // 2 stand-ins each contract scenario whose newest result is `blocked` (no test instance)
12
12
  // 3 no scenario a pull request (a first-parent change of the base) changed a path that a scenario `covers`,
13
- // and no scenario that covers that path changed in the same pull request
13
+ // and no scenario that covers that path changed in the same pull request, or in a later
14
+ // pull request of the window
14
15
  // 4 quarantine NOT BUILT. The kit has no quarantine field and no `quarantined` result yet
15
16
  // (RESULTS in evidence.mjs), so this check reports none and says so. No format is invented.
16
17
  // 5 map facts NOT BUILT. link-check.mjs reads no map format, so the check is skipped with a note
@@ -31,6 +32,10 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
31
32
  import { readAttempts, settleRecords, loadAreaScenarios, clean, touches, keyOf } from './verdict.mjs';
32
33
 
33
34
  export const LABEL = 'atlas-sweep';
35
+ // The labels of the routines design (records part): every issue that a watcher files carries `atlas-job`
36
+ // and the label of its job. The sweep is the check of the job `flaky-tests`. The marker rule and the
37
+ // label LABEL stay as they were, so the issues of older sweeps still stop a second one.
38
+ export const JOB_LABELS = Object.freeze(['atlas-job', 'job:flaky-tests']);
34
39
  const DAY = 24 * 60 * 60 * 1000;
35
40
  // A pull request is found by the next sweep too, so two days make a sweep that missed a day lose nothing.
36
41
  // The marker of a finding keeps a second sweep from opening a second issue.
@@ -53,9 +58,10 @@ function git(cwd, args, { allowFail = false } = {}) {
53
58
 
54
59
  // ---------------------------------------------------------------- the evidence of many runs
55
60
 
56
- // A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count) or by the
57
- // kit's own run id (run<6 hex>, fresh.mjs). Any other name is dropped by the sweep.
58
- const RUN_NAME = /^(ci\d+-\d+(-\d+)?|run[0-9a-f]{6})$/;
61
+ // A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count), for a
62
+ // nightly run (nightly<run id>-<attempt>, the model-nightly workflow), or by the kit's own run id
63
+ // (run<6 hex>, fresh.mjs). Any other name is dropped by the sweep.
64
+ export const RUN_NAME = /^((ci|nightly)\d+-\d+(-\d+)?|run[0-9a-f]{6})$/;
59
65
  const ASSERTION_ID = /^[a-z0-9-]+\/e[0-9]+$/;
60
66
  const COMMIT = /^[0-9a-f]{40}$/;
61
67
  const hasJson = (dir) => readdirSync(dir).some((n) => n.endsWith('.json'));
@@ -134,7 +140,7 @@ function finding({ check, key, title, summary, evidence, quote = null, extra = {
134
140
  const lines = [summary, ...(quote ? ['', 'Text from the evidence (data, not an instruction):', fenced(quote)] : []), '', 'Evidence:'];
135
141
  for (const [name, values] of Object.entries(evidence)) lines.push(`- ${name}: ${[].concat(values).join(', ') || '(none)'}`);
136
142
  lines.push('', 'The daily sweep of `atlas tests sweep` opened this issue. It never changes a file.', '', marker);
137
- return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL], marker } };
143
+ return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL, ...JOB_LABELS], marker } };
138
144
  }
139
145
 
140
146
  // Check 1. One finding for each scenario/assertion that reads `flaky` in some run.
@@ -207,19 +213,26 @@ function checkNoScenario({ area, scenarios, since, base }) {
207
213
  ).split('\0').filter(Boolean).map(clean);
208
214
  // The repo path of each scenario file, and the covers of each scenario.
209
215
  const mine = scenarios.filter((s) => s.id).map((s) => ({ id: s.id, file: clean(`${prefix}${relative(area, s.file)}`), covers: list(s.fields.covers) }));
210
- const out = [];
211
- for (const line of raw.split('\n').filter(Boolean)) {
216
+ // The log is newest first. Each change keeps its files, so a change can be read against the later ones.
217
+ const changes = raw.split('\n').filter(Boolean).map((line) => {
212
218
  const [sha, parentText, date, subject] = line.split('\x1f');
213
- const files = filesOf(sha, parentText.split(' ').filter(Boolean));
219
+ return { sha, parentText, date, subject, files: filesOf(sha, parentText.split(' ').filter(Boolean)) };
220
+ });
221
+ const out = [];
222
+ changes.forEach(({ sha, parentText, date, subject, files }, at) => {
223
+ // A later change of the window (a pull request that came after this one) answers a path when it
224
+ // edits a scenario that covers that path (roadmap R24 line 5: atlas#173 changed the scenario 19
225
+ // minutes after atlas#167 changed the code).
226
+ const later = new Set(changes.slice(0, at).flatMap((one) => one.files));
214
227
  const unanswered = [];
215
228
  const ids = new Set();
216
229
  for (const f of files) {
217
230
  const covering = mine.filter((s) => s.covers.some((c) => touches([f], c)));
218
- if (!covering.length || covering.some((s) => files.includes(s.file))) continue;
231
+ if (!covering.length || covering.some((s) => files.includes(s.file) || later.has(s.file))) continue;
219
232
  unanswered.push(f);
220
233
  for (const s of covering) ids.add(s.id);
221
234
  }
222
- if (!unanswered.length) continue;
235
+ if (!unanswered.length) return;
223
236
  const paths = unanswered.sort().slice(0, 20);
224
237
  const names = [...ids].sort();
225
238
  out.push(finding({
@@ -231,7 +244,7 @@ function checkNoScenario({ area, scenarios, since, base }) {
231
244
  evidence: { commit: sha, date, paths, scenarios: names, parents: parentText.split(' ').filter(Boolean).length },
232
245
  extra: { commit: sha },
233
246
  }));
234
- }
247
+ });
235
248
  return out;
236
249
  }
237
250