@arjunkhera/atlas 0.4.15 → 0.4.16

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.15",
3
+ "version": "0.4.16",
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
@@ -267,7 +267,8 @@ function toolingCommand(chosen) {
267
267
  if (what !== 'write') throw new Error(`there is no tooling verb "${what}". Use write or pr.`);
268
268
  const result = writeTooling(root, PACKAGE_ROOT, { dryRun: Boolean(chosen['dry-run']), replaceUnknown, workflowOnly });
269
269
  line(`Atlas ${result.version}, check version ${result.check_version}${result.status ? `; the copy was ${result.status}` : ''}`);
270
- for (const row of result.rows) line(` ${row.state.padEnd(10)} ${row.path} sha256 ${row.sha256.slice(0, 12)}…`);
270
+ // A removed file has no bytes, so its row has no sha256 (roadmap R7).
271
+ for (const row of result.rows) line(` ${row.state.padEnd(10)} ${row.path}${row.sha256 ? ` sha256 ${row.sha256.slice(0, 12)}…` : ''}`);
271
272
  line('');
272
273
  line(result.changed
273
274
  ? 'The files come from the package. No hand step, and no edit by hand afterwards.'
@@ -11,9 +11,9 @@
11
11
  // git and GitHub calls sit below it, in one place.
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { childEnvironment } from '../../work/lib/child-env.mjs';
14
- import { mkdirSync, writeFileSync } from 'node:fs';
14
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
15
15
  import { dirname, join, posix } from 'node:path';
16
- import { CHECK_PATH, WORKFLOW_PATH } from '../../shape/check.mjs';
16
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH } from '../../shape/check.mjs';
17
17
 
18
18
  // Every change names where it came from, and the set is closed.
19
19
  export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
@@ -24,6 +24,9 @@ export const TOOLING_LABEL = 'atlas-helper';
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
26
  export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
27
+ // The only files the tooling writer may remove: the workflow of check versions
28
+ // 1 to 5, which the gate workflow replaces (roadmap R7).
29
+ export const TOOLING_REMOVALS = Object.freeze([OLD_WORKFLOW_PATH]);
27
30
 
28
31
  export class ChangeError extends Error {}
29
32
 
@@ -38,8 +41,12 @@ export function checkChanges(changes) {
38
41
  for (const change of changes) {
39
42
  const path = posix.normalize(String(change?.path ?? '')).replace(/^\.\//, '');
40
43
  if (!path || path === '.') throw new ChangeError('a change has no path');
41
- if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
42
- if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
44
+ if (change.remove === true) {
45
+ if (!TOOLING_REMOVALS.includes(path)) throw new ChangeError(`the tooling writer removes only ${TOOLING_REMOVALS.join(' and ')}; it refuses to remove "${path}"`);
46
+ } else {
47
+ if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
48
+ if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
49
+ }
43
50
  const source = change?.provenance?.source;
44
51
  if (!PROVENANCE_SOURCES.includes(source)) throw new ChangeError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')}`);
45
52
  if (!String(change?.provenance?.detail ?? '').trim()) throw new ChangeError(`the change to "${path}" names the source "${source}" and says nothing about it`);
@@ -82,8 +89,11 @@ export function applyChanges(root, changes) {
82
89
  const written = [];
83
90
  for (const change of changes) {
84
91
  const path = join(root, change.path);
85
- mkdirSync(dirname(path), { recursive: true });
86
- writeFileSync(path, change.contents);
92
+ if (change.remove === true) rmSync(path, { force: true });
93
+ else {
94
+ mkdirSync(dirname(path), { recursive: true });
95
+ writeFileSync(path, change.contents);
96
+ }
87
97
  written.push(change.path);
88
98
  }
89
99
  return { written };
@@ -105,7 +115,8 @@ export function open({ root, changes, why, branch, title, base = null, label = T
105
115
  if (dryRun) return { repo, branch, title, body, base: onto, written, opened: false };
106
116
 
107
117
  exec('git', ['checkout', '-b', branch], root);
108
- exec('git', ['add', '--', ...written], root);
118
+ // `git add -A` stages a removed file as well as a written one.
119
+ exec('git', ['add', '-A', '--', ...written], root);
109
120
  exec('git', ['commit', '-m', title, '-m', body], root);
110
121
  exec('git', ['push', '-u', 'origin', branch], root);
111
122
  // A label that does not exist yet makes `gh pr create` fail, so it is made
@@ -15,7 +15,7 @@
15
15
  // package's list. This runs in a session, with the package on disk.
16
16
  import { readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
17
17
  import { join, relative, sep } from 'node:path';
18
- import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, checkStamps, sha256 } from '../../shape/check.mjs';
18
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, checkStamps, sha256 } from '../../shape/check.mjs';
19
19
  import { packagedCheck, placeOf, packagePackageVersion } from './releases.mjs';
20
20
  import { currentKitHash, KIT_RELEASES_FILE, KIT_TARGET, MANIFEST } from './tests.mjs';
21
21
  import { kitHashOf } from '../../tests/link-check.mjs';
@@ -57,8 +57,9 @@ export function statusMany(roots, packageRoot) {
57
57
  // R19, slice 0 of the routines design). The check is one copy of four types:
58
58
  //
59
59
  // check .github/atlas/check.mjs, by the release list of the check
60
- // workflow .github/workflows/atlas-check.yml, by the workflow fingerprint
61
- // in each row of that list
60
+ // workflow .github/workflows/atlas-gate.yml, by the workflow fingerprint
61
+ // in each row of that list, or by its shape job; before check
62
+ // version 6, .github/workflows/atlas-check.yml
62
63
  // kit each <area>/test/atlas/, by its manifest and the kit list
63
64
  // pin an Atlas version written in a workflow, a script or package.json
64
65
  //
@@ -92,23 +93,37 @@ function checkRow(root, packageRoot) {
92
93
 
93
94
  // The workflow is written with the check, from the WORKFLOW_TEMPLATE of that
94
95
  // check. Each row of the release list keeps the fingerprint of its template.
96
+ // From check version 6 the workflow is the gate workflow, and a repo may add
97
+ // its own jobs to it, as the Atlas repo does: then its shape job must be the
98
+ // one the package writes. A repo that still has only the old atlas-check.yml
99
+ // is behind (roadmap R7 line 5).
95
100
  function workflowRow(root, packageRoot) {
96
101
  const pkg = packagedCheck(packageRoot);
97
102
  const version = checkStamps(pkg.text).check_version;
98
103
  const want = `workflow of check version ${version}`;
99
104
  const full = join(root, WORKFLOW_PATH);
100
105
  if (linkAt(full)) return linked('workflow', WORKFLOW_PATH, want);
101
- if (!existsSync(full)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
106
+ const known = (text) => pkg.releases.filter((one) => one.workflow_sha256 === sha256(text));
107
+ if (!existsSync(full)) {
108
+ const old = join(root, OLD_WORKFLOW_PATH);
109
+ if (linkAt(old)) return linked('workflow', OLD_WORKFLOW_PATH, want);
110
+ if (!existsSync(old)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
111
+ let text;
112
+ try { text = readFileSync(old, 'utf8'); } catch (error) { return broken('workflow', OLD_WORKFLOW_PATH, want, error); }
113
+ const rows = known(text);
114
+ if (rows.length) return row('workflow', OLD_WORKFLOW_PATH, 'behind', `workflow of check version ${rows.at(-1).check_version}`, want, `${OLD_WORKFLOW_PATH} runs the head's copy of the check, so a pull request can change its own judge. "atlas tooling pr" writes ${WORKFLOW_PATH} and removes it.`);
115
+ return row('workflow', OLD_WORKFLOW_PATH, 'unknown', `sha256 ${sha256(text).slice(0, 12)}…`, want, `${OLD_WORKFLOW_PATH} is in no release, and ${WORKFLOW_PATH} does not exist. "atlas tooling pr" shows the difference.`);
116
+ }
102
117
  let text;
103
118
  try { text = readFileSync(full, 'utf8'); } catch (error) { return broken('workflow', WORKFLOW_PATH, want, error); }
104
- const fingerprint = sha256(text);
105
- if (fingerprint === sha256(WORKFLOW_TEMPLATE)) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
106
- const rows = pkg.releases.filter((one) => one.workflow_sha256 === fingerprint);
119
+ if (text === WORKFLOW_TEMPLATE) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
120
+ if (shapeJobOf(text) === SHAPE_JOB_TEXT) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'its shape job is the one this package writes; the file holds other jobs of this repo too.');
121
+ const rows = known(text);
107
122
  if (rows.length) {
108
123
  const newest = rows.at(-1);
109
- return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package}. "atlas tooling pr --workflow" refreshes it.`);
124
+ return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package ?? `check version ${rows[0].check_version}`}. "atlas tooling pr --workflow" refreshes it.`);
110
125
  }
111
- return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${fingerprint.slice(0, 12)}…`, want, `the workflow is in no release. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
126
+ return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${sha256(text).slice(0, 12)}…`, want, `the workflow is in no release, and its shape job is not the one this package writes. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
112
127
  }
113
128
 
114
129
  // A walk never follows a link and never enters node_modules or .git. The
@@ -14,15 +14,27 @@
14
14
  // is missing or behind, or unknown with --replace-unknown, and only a check
15
15
  // that is the newest row of the release list. The Atlas repo itself may take
16
16
  // its own unreleased check, because that is how a new check is made.
17
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
17
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
18
18
  import { execFileSync } from 'node:child_process';
19
19
  import { dirname, join } from 'node:path';
20
- import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
20
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
21
21
  import { packagedCheck, packagePackageVersion, PACKAGE_CHECK_SOURCE } from './releases.mjs';
22
22
  import { status } from './status.mjs';
23
23
  import { PR_TEMPLATE_PATH } from './upkeep.mjs';
24
24
 
25
- export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
25
+ export { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
26
+
27
+ // The gate workflow (roadmap R7 line 5). A repo may keep its own jobs in it, as
28
+ // the Atlas repo does. When its shape job is the one the package writes, the
29
+ // writer keeps the file. The workflow of check versions 1 to 5,
30
+ // atlas-check.yml, ran the head's copy of the check; the writer removes it.
31
+ const workflowKept = (root) => {
32
+ const full = join(root, WORKFLOW_PATH);
33
+ if (!existsSync(full)) return false;
34
+ const text = readFileSync(full, 'utf8');
35
+ return text !== WORKFLOW_TEMPLATE && shapeJobOf(text) === SHAPE_JOB_TEXT;
36
+ };
37
+ const oldWorkflow = (root) => existsSync(join(root, OLD_WORKFLOW_PATH));
26
38
 
27
39
  // The watcher of the routines design (roadmap R24). The writer puts it into each
28
40
  // repo, with the version of this package as its pin. It refreshes a copy whose pin
@@ -101,8 +113,8 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
101
113
  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.`);
102
114
  }
103
115
  const now = status({ root, packageRoot });
104
- if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot)) {
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 });
116
+ if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
117
+ throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE || workflowKept(root) ? '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 });
106
118
  }
107
119
  if (now.state === 'unknown' && !replaceUnknown && !own) {
108
120
  throw new ToolingRefusal(`${now.why}`);
@@ -120,6 +132,7 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
120
132
  for (const [path, contents] of Object.entries(wanted)) {
121
133
  const full = join(root, path);
122
134
  const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
135
+ if (path === WORKFLOW_PATH && workflowKept(root)) { rows.push({ path, state: 'kept', sha256: sha256(was), was: sha256(was) }); continue; }
123
136
  const state = was === null ? 'written' : was === contents ? 'unchanged' : 'refreshed';
124
137
  if (state !== 'unchanged' && !dryRun) {
125
138
  mkdirSync(dirname(full), { recursive: true });
@@ -127,6 +140,11 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
127
140
  }
128
141
  rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
129
142
  }
143
+ if (oldWorkflow(root)) {
144
+ const was = readFileSync(join(root, OLD_WORKFLOW_PATH), 'utf8');
145
+ if (!dryRun) rmSync(join(root, OLD_WORKFLOW_PATH));
146
+ rows.push({ path: OLD_WORKFLOW_PATH, state: 'removed', sha256: null, was: sha256(was) });
147
+ }
130
148
  const jobs = workflowOnly ? null : jobsState(root, packageRoot);
131
149
  if (jobs !== null) {
132
150
  const contents = packagedJobsWorkflow(packageRoot);
@@ -165,7 +183,11 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
165
183
  const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
166
184
  return rows
167
185
  .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
168
- .map((row) => ({
186
+ .map((row) => (row.state === 'removed' ? {
187
+ path: row.path,
188
+ remove: true,
189
+ provenance: { source: 'repo read', detail: `the workflow of check versions 1 to 5, which runs the head's copy of the check; ${WORKFLOW_PATH} replaces it from check version ${CHECK_VERSION}` },
190
+ } : {
169
191
  path: row.path,
170
192
  contents: wanted[row.path],
171
193
  provenance: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
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",
package/shape/check.mjs CHANGED
@@ -7,7 +7,9 @@
7
7
  // reader, the secret patterns and the report. It imports Node builtins only.
8
8
  //
9
9
  // What it reads: the checkout it is pointed at, and nothing else. It opens no
10
- // network. It holds no token. It writes nothing back into the repo.
10
+ // network. It holds no token. It writes nothing back into the repo. In gate
11
+ // mode only, it reads git objects with the git binary, and writes two trees
12
+ // into a temp folder outside the checkout.
11
13
  //
12
14
  // What it reports: one state per part of the seven files. Five states: filled,
13
15
  // empty, stale, not applicable, no extractor. It leads with the count of
@@ -18,10 +20,12 @@
18
20
  //
19
21
  // Authority: the Atlas M2 study, design page D1 with its
20
22
  // D7 amendment; the Atlas architecture review, build order B1.
21
- import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, appendFileSync } from 'node:fs';
22
- import { join, resolve, relative, posix } from 'node:path';
23
+ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, appendFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
24
+ import { join, resolve, relative, posix, dirname } from 'node:path';
23
25
  import { fileURLToPath } from 'node:url';
24
26
  import { createHash } from 'node:crypto';
27
+ import { spawnSync } from 'node:child_process';
28
+ import { tmpdir } from 'node:os';
25
29
 
26
30
  // node:crypto is a builtin, so the check still imports nothing but builtins.
27
31
  export const sha256 = (text) => createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex');
@@ -34,7 +38,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
34
38
  // Whether a copy is current or behind is not decided here. CI has no package
35
39
  // and no network. The session verb decides it, from the release list that
36
40
  // ships beside this file in the package, shape/releases.json.
37
- export const CHECK_VERSION = 5;
41
+ export const CHECK_VERSION = 6;
38
42
 
39
43
  // ---------------------------------------------------------------------------
40
44
  // The spec. One object per version. The check reads a version it has here and
@@ -417,8 +421,8 @@ export const SPEC_1 = {
417
421
  "parts": [
418
422
  {
419
423
  "id": "atlas-check",
420
- "path": ".github/workflows/atlas-check.yml",
421
- "holds": "The one workflow that runs .github/atlas/check.mjs, the check file in this repo.",
424
+ "path": ".github/workflows/atlas-gate.yml",
425
+ "holds": "The gate workflow. Its shape job runs the base copy of .github/atlas/check.mjs over the merge base and the head of each pull request.",
422
426
  "kind": {
423
427
  "service": "required",
424
428
  "library": "required",
@@ -640,64 +644,135 @@ export function canaryMisses() {
640
644
  }
641
645
 
642
646
  // ---------------------------------------------------------------------------
643
- // The workflow that runs this file. A person copies it into a repo today. The
644
- // tooling helper of slice L2 writes it from here. It runs on pull_request, it
645
- // reads the contents, it takes no secret, and it pins its one action by commit
646
- // SHA (design review finding R11).
647
+ // The workflow that runs this file: the gate (roadmap R7, design
648
+ // docs/design-docs/shape-check-from-base/ in the Atlas repo). The tooling
649
+ // writer puts it into each repo from here. It runs on pull_request_target, so
650
+ // GitHub takes it from the base branch, and the check that judges a pull
651
+ // request is the base copy. It reads the head as git objects only, it holds a
652
+ // read token, and it pins its one action by commit SHA (design review finding
653
+ // R11).
654
+ //
655
+ // Check version 6 moved the check here from the old atlas-check.yml, which ran
656
+ // on pull_request with the head's copy of this file. A pull request could edit
657
+ // the check to print GREEN, and the check only warned that a person merges it.
647
658
  // ---------------------------------------------------------------------------
648
659
 
649
- export const WORKFLOW_PATH = '.github/workflows/atlas-check.yml';
660
+ export const WORKFLOW_PATH = '.github/workflows/atlas-gate.yml';
661
+ // The workflow of check versions 1 to 5. The tooling writer removes it.
662
+ export const OLD_WORKFLOW_PATH = '.github/workflows/atlas-check.yml';
650
663
  export const CHECK_PATH = '.github/atlas/check.mjs';
651
664
 
652
- // Where the workflow puts the base tree. `walk` and `symlinks` skip every entry
653
- // whose name starts with a dot, and every other read names an exact path, so no
654
- // scanner ever reaches into it.
665
+ // Where a run by hand, or an old workflow, puts the base tree. `walk` and
666
+ // `symlinks` skip every entry whose name starts with a dot, and every other
667
+ // read names an exact path, so no scanner ever reaches into it.
655
668
  export const BASE_DIR = '.atlas-base';
656
669
 
657
- export const WORKFLOW_TEMPLATE = `# The Atlas repo shape check, in this repo's own CI.
658
- #
659
- # It reads the seven files this repo carries, named in atlas.yaml and under
660
- # atlas/, and reports one state per part: filled, empty, stale, not applicable,
661
- # or no extractor. It reads this repo only. It takes no secret and opens no
662
- # network, so it runs on a pull request with read access to the contents.
670
+ // The two trees of the gate, under the runner's temp folder, outside the
671
+ // checkout. Each is written from git objects, and loses every link.
672
+ export const GATE_TREES = Object.freeze({ base: 'atlas-base', head: 'atlas-head' });
673
+
674
+ // The job and the check name. The name says the job runs the shape rule
675
+ // only, so no reader takes it for the full gate, which keeps the name `gate`
676
+ // (roadmap R76). A repo may add jobs to this file; the shape job must stay as
677
+ // Atlas writes it, and `atlas status` compares that job.
678
+ export const SHAPE_JOB = 'shape';
679
+ export const SHAPE_CHECK = 'gate · shape';
680
+
681
+ export const WORKFLOW_HEADER = `# The Atlas gate workflow, in this repo's own CI. Its shape job is the repo
682
+ # shape check, judged from the base.
663
683
  #
664
- # The check is ${CHECK_PATH} in this repo. It needs no package and no
665
- # install. Atlas writes both files; do not edit them by hand.
684
+ # It runs on pull_request_target, so GitHub runs this file as the base branch
685
+ # holds it. The job checks out the base and runs the base copy of
686
+ # ${CHECK_PATH}. It fetches the head as git objects only, and runs none of it.
666
687
  #
667
- # It reports in the docs lane. It never fails a code merge.
688
+ # The check writes two trees from git objects, the merge base and the head, in
689
+ # the runner's temp folder, and scores them side by side. No attribute of a
690
+ # tree applies, and no link is written. The no-loss rule says red when a part
691
+ # that the base had is taken away. It says owner when only the owner may
692
+ # approve the change, such as a change to the check or to this file.
668
693
  #
669
- # The second checkout is the no-loss rule's base tree. The check scores the base
670
- # commit and the head commit side by side, and it goes red when a part the base
671
- # already had is taken away. The base tree lands in ${BASE_DIR}, whose name
672
- # starts with a dot, so no scanner walks into it. If that checkout fails there
673
- # is nothing to compare, so the step may fail and the check says so and does not
674
- # gate.
694
+ # The token can only read. No checkout keeps a credential: the one fetch of
695
+ # the head takes the token for that command alone. The check step holds no
696
+ # token. The job writes two output keys, result and class.
675
697
  #
676
- # Design: the Atlas architecture record, sections 5.2, 6 and rule 7.
677
- name: atlas-check
698
+ # Atlas writes this file and ${CHECK_PATH}; do not edit the shape job by hand.
699
+ # Design: docs/design-docs/shape-check-from-base/ in the Atlas repo.
700
+ name: atlas-gate
701
+ run-name: "gate #\${{ github.event.pull_request.number }} at \${{ github.event.pull_request.head.sha }}"
678
702
  on:
679
- pull_request:
703
+ pull_request_target:
704
+ types: [opened, synchronize, reopened, edited, ready_for_review]
680
705
  permissions:
681
706
  contents: read
707
+ concurrency:
708
+ group: atlas-gate-\${{ github.event.pull_request.number }}
709
+ cancel-in-progress: true
682
710
  jobs:
683
- repo-shape:
684
- name: docs · repo shape
711
+ `;
712
+
713
+ export const SHAPE_JOB_TEXT = ` ${SHAPE_JOB}:
714
+ name: ${SHAPE_CHECK}
685
715
  runs-on: ubuntu-latest
686
- # The check reads two directory trees and opens no network. It finishes in
687
- # seconds. The bound stops a runner being billed until someone kills it.
688
- timeout-minutes: 5
716
+ # The check reads two trees and opens no network. It finishes in seconds
717
+ # on a small repo. The bound stops a runner being billed until someone
718
+ # kills it; a run that hits it is red.
719
+ timeout-minutes: 10
720
+ outputs:
721
+ result: \${{ steps.shape.outputs.result }}
722
+ class: \${{ steps.shape.outputs.class }}
689
723
  steps:
690
- # actions/checkout v4.3.1
691
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
692
- - name: the base commit, for the no-loss rule
693
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
694
- continue-on-error: true
724
+ # actions/checkout v7.0.1
725
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
726
+ with:
727
+ ref: \${{ github.sha }}
728
+ fetch-depth: 0
729
+ persist-credentials: false
730
+ # actions/setup-node v7.0.0
731
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
695
732
  with:
696
- ref: \${{ github.event.pull_request.base.sha }}
697
- path: ${BASE_DIR}
698
- - run: node ${CHECK_PATH} --base ${BASE_DIR} --base-sha \${{ github.event.pull_request.base.sha }}
733
+ node-version: "22.22.0"
734
+ - name: Fetch the head as objects only
735
+ env:
736
+ PR: \${{ github.event.pull_request.number }}
737
+ HEAD_SHA: \${{ github.event.pull_request.head.sha }}
738
+ TOKEN: \${{ github.token }}
739
+ run: |
740
+ set -euo pipefail
741
+ header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
742
+ git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
743
+ got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
744
+ if [ "$got" != "$HEAD_SHA" ]; then
745
+ echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
746
+ exit 1
747
+ fi
748
+ - name: Shape check from the base
749
+ id: shape
750
+ env:
751
+ HEAD_SHA: \${{ github.event.pull_request.head.sha }}
752
+ run: |
753
+ set -euo pipefail
754
+ if [ ! -f ${CHECK_PATH} ]; then
755
+ echo "OWNER: first-run: the base has no ${CHECK_PATH}, so the owner reads the first shape"
756
+ echo "result=owner" >> "$GITHUB_OUTPUT"
757
+ echo "class=first-run" >> "$GITHUB_OUTPUT"
758
+ exit 1
759
+ fi
760
+ node ${CHECK_PATH} --gate --repo "$GITHUB_REPOSITORY" --base-commit "$GITHUB_SHA" --head-commit "$HEAD_SHA"
699
761
  `;
700
762
 
763
+ export const WORKFLOW_TEMPLATE = `${WORKFLOW_HEADER}${SHAPE_JOB_TEXT}`;
764
+
765
+ // The shape job of a workflow text: from its key line to the next job key or
766
+ // the end. `atlas status` compares it with SHAPE_JOB_TEXT.
767
+ export function shapeJobOf(text) {
768
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
769
+ const start = lines.indexOf(` ${SHAPE_JOB}:`);
770
+ if (start < 0) return null;
771
+ let end = start + 1;
772
+ while (end < lines.length && !/^ {2}\S/.test(lines[end]) && !/^\S/.test(lines[end])) end += 1;
773
+ return `${lines.slice(start, end).join('\n').replace(/\n+$/, '')}\n`;
774
+ }
775
+
701
776
  // ---------------------------------------------------------------------------
702
777
  // The YAML reader. Atlas ships no dependency, so the check reads the subset of
703
778
  // YAML that atlas.yaml, the section files and a SKILL.md front matter block
@@ -1013,21 +1088,29 @@ function checkPathRules(context, part) {
1013
1088
  if (!list.length) { bad.push(`${file} has no paths: key`); continue; }
1014
1089
  for (const pattern of list) {
1015
1090
  const base = String(pattern).split(/[*?[]/)[0].replace(/\/+$/, '');
1091
+ if (base && !insideRepo(context.root, base)) { bad.push(`${file} names a path outside the repo`); continue; }
1016
1092
  if (base && !existsSync(join(context.root, base))) bad.push(`${file} names ${pattern}, which does not exist`);
1017
1093
  }
1018
1094
  }
1019
1095
  return bad.length ? result(part, 'stale', bad.slice(0, MAX_LISTED)) : result(part, 'filled', [`${files.length} rule file(s)`]);
1020
1096
  }
1021
1097
 
1098
+ const SKILL_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
1099
+
1022
1100
  function checkProcedure(context, part) {
1023
1101
  const named = context.facts?.procedures?.[part.procedure];
1024
1102
  if (named === undefined) return result(part, 'empty', [`atlas.yaml has no procedures entry for ${part.procedure}`]);
1025
1103
  if (named === null || named === '') return result(part, 'empty', [`atlas.yaml sets procedures.${part.procedure} to null: the repo has no skill for it yet`]);
1104
+ // A skill name is one folder name. A slash, a dot pair or a control
1105
+ // character would let atlas.yaml point the check outside the skills folder,
1106
+ // or outside the tree it scores (security review of roadmap R7).
1107
+ if (!SKILL_NAME.test(String(named))) return result(part, 'stale', [`atlas.yaml names a skill for ${part.procedure} that is not one folder name`]);
1026
1108
  const skill = posix.join('.claude/skills', String(named), 'SKILL.md');
1027
- if (!isFile(context.root, skill)) return result(part, 'stale', [`atlas.yaml names skill "${named}", and ${skill} does not exist`]);
1109
+ const at = { ...part, path: skill };
1110
+ if (!isFile(context.root, skill)) return result(at, 'stale', [`atlas.yaml names skill "${named}", and ${skill} does not exist`]);
1028
1111
  const front = frontMatter(readText(context.root, skill) ?? '');
1029
- if (!front?.name || !front?.description) return result(part, 'stale', [`${skill} has no name or no description in its front matter`]);
1030
- return result(part, 'filled', [skill]);
1112
+ if (!front?.name || !front?.description) return result(at, 'stale', [`${skill} has no name or no description in its front matter`]);
1113
+ return result(at, 'filled', [skill]);
1031
1114
  }
1032
1115
 
1033
1116
  // `scan` reports a missing or unreadable atlas.yaml as an error and returns
@@ -1221,6 +1304,9 @@ function writeGrants(text) {
1221
1304
  // A workflow this function cannot read is a finding, not a pass. That is the
1222
1305
  // safe direction: the check says it cannot confirm the job runs, instead of
1223
1306
  // saying the job is fine.
1307
+ // The step that runs the shape check, and the flags it must pass.
1308
+ const GATE_RUNNER = Object.freeze({ runs: `node ${CHECK_PATH} `, flags: ['--gate', '--base-commit', '--head-commit'] });
1309
+
1224
1310
  function workflowStructure(document) {
1225
1311
  const out = [];
1226
1312
  const jobs = document?.jobs;
@@ -1230,9 +1316,9 @@ function workflowStructure(document) {
1230
1316
  // the pull requests that change the files it guards, and the pull request
1231
1317
  // still reads green, because a job that never ran reports nothing (P13).
1232
1318
  const on = document.on;
1233
- const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.pull_request : null;
1319
+ const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.pull_request_target : null;
1234
1320
  if (trigger && typeof trigger === 'object' && !Array.isArray(trigger)) {
1235
- for (const key of ['paths', 'paths-ignore']) {
1321
+ for (const key of ['paths', 'paths-ignore', 'branches-ignore']) {
1236
1322
  if (trigger[key] !== undefined && trigger[key] !== null) {
1237
1323
  out.push(`the workflow narrows its trigger with ${key}; this check must run on every pull request`);
1238
1324
  }
@@ -1245,41 +1331,39 @@ function workflowStructure(document) {
1245
1331
  for (const [jobName, job] of Object.entries(jobs)) {
1246
1332
  for (const step of Array.isArray(job?.steps) ? job.steps : []) {
1247
1333
  const runs = typeof step?.run === 'string' ? step.run : '';
1248
- if (runs.includes(CHECK_PATH)) found = { jobName, job, step, runs };
1334
+ if (runs.includes(GATE_RUNNER.runs)) found = { jobName, job, step, runs, runner: GATE_RUNNER };
1249
1335
  }
1250
1336
  }
1251
1337
  if (!found) {
1252
- out.push(`no step in this workflow runs "node ${CHECK_PATH}", so the job reports nothing`);
1338
+ out.push(`no step in this workflow runs "node ${CHECK_PATH} --gate", so the job reports nothing`);
1253
1339
  return out;
1254
1340
  }
1255
1341
 
1256
- // The no-loss rule's own two inputs, from slice L2 stage two.
1257
- //
1258
- // Without them the check still runs, still prints a report, and still goes
1259
- // green — and the no-loss rule never runs at all. That is the same class of hole
1260
- // the batch 16 reviewer found nine ways (finding S2-1): a workflow that
1261
- // silences the job while the part still reads "filled". Deleting two flags
1262
- // is the cheapest version of it, so the scanner reads the flags, not just
1263
- // the path.
1264
- for (const flag of ['--base', '--base-sha']) {
1342
+ // The flags. Without them the check still runs and still goes green, and the
1343
+ // no-loss rule never runs at all, or it scores the wrong tree. Deleting a
1344
+ // flag is the cheapest way to silence the job, so the scanner reads the flags,
1345
+ // not just the path (finding S2-1).
1346
+ for (const flag of found.runner.flags) {
1265
1347
  if (!new RegExp(`${flag}(?:[ \t=]|$)`, 'm').test(found.runs)) {
1266
- out.push(`the check step does not pass ${flag}, so the no-loss rule never runs and a pull request can take a part away with the job still green`);
1348
+ out.push(`the check step does not pass ${flag}, so the no-loss rule cannot judge the pull request from the base`);
1267
1349
  }
1268
1350
  }
1269
- // And the tree it compares against has to be fetched by something.
1270
- const fetchesBase = Object.values(jobs).some((job) => (Array.isArray(job?.steps) ? job.steps : [])
1271
- .some((step) => typeof step?.uses === 'string' && step.uses.startsWith('actions/checkout') && step?.with?.path === BASE_DIR));
1272
- if (!fetchesBase) {
1273
- out.push(`no step checks the base commit out into ${BASE_DIR}, so the no-loss rule has no tree to compare against`);
1351
+ // Every checkout is of the base commit and keeps no credential. A checkout
1352
+ // of the head would run the head's copy of the check: the hole this
1353
+ // workflow exists to close.
1354
+ const checkouts = (Array.isArray(found.job?.steps) ? found.job.steps : [])
1355
+ .filter((step) => typeof step?.uses === 'string' && step.uses.startsWith('actions/checkout'));
1356
+ if (!checkouts.length) out.push('no step checks the base commit out, so there is no base copy of the check to run');
1357
+ for (const step of checkouts) {
1358
+ if (String(step?.with?.ref ?? '').replace(/\s+/g, '') !== '${{github.sha}}') out.push('a checkout step does not check out github.sha, the base commit; the check must run from the base');
1359
+ if (step?.with?.['persist-credentials'] !== false) out.push('a checkout step keeps its credential; set persist-credentials: false');
1274
1360
  }
1275
1361
 
1276
1362
  // A condition switches the step, or its whole job, off. Same effect as a
1277
1363
  // narrowed trigger, by another route.
1278
1364
  if (found.step.if !== undefined) out.push('the workflow puts an if: on the check step; the step must always run');
1279
1365
  if (found.job.if !== undefined) out.push("the workflow puts an if: on the check's job; the job must always run");
1280
- // Two more ways to make a red check look green. Neither is in the frozen
1281
- // list. Both defeat the same purpose, and stage two's no-loss rule needs a red job
1282
- // to mean something, so the check names them (reviewer of batch 16, S2-1).
1366
+ // Two more ways to make a red check look green (reviewer of batch 16, S2-1).
1283
1367
  if (found.step['continue-on-error'] === true || found.job['continue-on-error'] === true) {
1284
1368
  out.push('the workflow sets continue-on-error on the check; a failed check would not turn the job red');
1285
1369
  }
@@ -1293,8 +1377,11 @@ function checkWorkflow(context, part) {
1293
1377
  const text = readText(context.root, part.path);
1294
1378
  if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
1295
1379
  const findings = [];
1296
- if (/^\s*pull_request_target\s*:/m.test(text)) findings.push('the workflow runs on pull_request_target; it must run on pull_request');
1297
- if (!/^\s*pull_request\s*:/m.test(text)) findings.push('the workflow does not run on pull_request');
1380
+ // pull_request_target takes this file from the base branch. On pull_request,
1381
+ // GitHub runs the copy the pull request carries, and the pull request can
1382
+ // change its own judge (roadmap R7).
1383
+ if (!/^\s*pull_request_target\s*:/m.test(text)) findings.push('the workflow does not run on pull_request_target; it must take its file and the check from the base');
1384
+ if (/^\s*pull_request\s*:/m.test(text)) findings.push('the workflow also runs on pull_request, which runs the head copy of this file');
1298
1385
  // The structure, from the parsed document. A workflow this check cannot read
1299
1386
  // is a finding: it can no longer say the job runs at all.
1300
1387
  try {
@@ -1511,7 +1598,12 @@ export function scan({ root, repoId = null, extractors = [], secretPatterns: pat
1511
1598
  if (spec && !spec.kinds[kind]) { errors.push(`atlas.yaml names kind "${kind}"; spec ${spec.spec} knows ${Object.keys(spec.kinds).join(', ')}`); spec = null; }
1512
1599
  if (!spec) return { ok: false, check_version: CHECK_VERSION, repo: repoId, kind, spec: wantedSpec ?? null, scored_as: scoreAs, product: facts?.product ?? null, files: [], by_reference: [], counts: counts([]), errors };
1513
1600
 
1514
- const context = { root, facts, factsError, repoId, extractors, secretPatterns: usable, errors };
1601
+ // The no-loss rule can score a tree at another tree's pointers: the paths
1602
+ // that atlas.yaml names for the procedures and the facts files. So a part
1603
+ // that a pull request moves is also scored where the base had it (roadmap R7
1604
+ // line 3).
1605
+ const pointed = scoreAs?.pointers ? { ...facts, files: scoreAs.pointers.files, procedures: scoreAs.pointers.procedures } : facts;
1606
+ const context = { root, facts: pointed, factsError, repoId, extractors, secretPatterns: usable, errors };
1515
1607
  // atlas.yaml's own values reach the report: the kind, the repo id, a
1516
1608
  // procedure's skill name, every pointer path. Scan it before any of them is
1517
1609
  // quoted (acceptance review of batch 16, note S3-2).
@@ -1568,21 +1660,30 @@ export function counts(files) {
1568
1660
  // four required parts into "not applicable" and the count falls. Scoring per
1569
1661
  // part, with the base tree's `kind` and `spec`, closes both (findings P2, P20).
1570
1662
  //
1571
- // Which copy scores. The head's. That is this file. A pull request that
1572
- // improves the check would otherwise turn red under the old scanners, and a
1573
- // pull request that weakens one would turn green under them. The head's copy
1574
- // judging its own diff would be a hole, so the two cases where the diff touches
1575
- // the gate are the first two report-only cases below: a person merges those
1576
- // files, and the check says plainly that it did not gate (finding P1).
1663
+ // Which copy scores. The base's, from check version 6 (roadmap R7). The gate
1664
+ // job runs on pull_request_target, so it runs this file as the base holds it,
1665
+ // over two trees written from git objects. Before that, the head's copy judged
1666
+ // its own diff, and a pull request could edit the check to print GREEN. The
1667
+ // two report-only cases for the check file and its workflow covered that hole
1668
+ // with a warning that a person merges the file. Under merge on green, no
1669
+ // person reads that warning. Now a change to either gives "owner" and the rule
1670
+ // still gates.
1671
+ //
1672
+ // A run by hand, or an old workflow, still runs the head's copy. Its answer
1673
+ // is a report. The gate's answer is the one a merge waits on.
1577
1674
  //
1578
1675
  // Where it lives. Here. The repository still carries two files and only two.
1579
1676
  // ---------------------------------------------------------------------------
1580
1677
 
1581
- // The five cases where the no-loss rule reports and does not gate. Each is named in
1582
- // the report, so a reader never has to guess why the job stayed green.
1583
- export const NO_LOSS_CASES = Object.freeze(['check-file', 'workflow', 'kind', 'spec', 'base-tree']);
1678
+ // The cases that give "owner". Only the owner may approve such a change, and
1679
+ // the rule still gates: it still finds any part that went backwards. Before
1680
+ // check version 6 five cases switched the rule off and printed a warning that
1681
+ // a person merges the file. Nothing switches it off now. The base copy scores,
1682
+ // and the base kind and spec score both trees, so no case needs to.
1683
+ export const OWNER_CASES = Object.freeze(['check-file', 'workflow', 'kind', 'spec', 'moved', 'first-run']);
1584
1684
 
1585
1685
  const fileHash = (root, path) => { const text = readText(root, path); return text === null ? null : sha256(text); };
1686
+ const isEmptyDir = (root) => { try { return !readdirSync(root).length; } catch { return true; } };
1586
1687
 
1587
1688
  // The commit a checkout sits on, read from `.git/HEAD` as text. No git binary,
1588
1689
  // no network. `actions/checkout` leaves a detached HEAD, so the first line is
@@ -1610,45 +1711,48 @@ const partIndex = (report) => {
1610
1711
  return map;
1611
1712
  };
1612
1713
 
1613
- export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secretPatterns: patterns = secretPatterns() }) {
1714
+ // `baseCommit` is the commit the caller wrote the base tree from, when it
1715
+ // wrote the tree itself from git objects, as the gate does. Such a tree has
1716
+ // no `.git`, and the caller is the base code, so its word is the tree's name.
1717
+ export function noLoss({ headRoot, baseRoot, baseSha = null, baseCommit = null, repoId = null, secretPatterns: patterns = secretPatterns() }) {
1614
1718
  const out = {
1615
1719
  ran: true,
1616
- gates: true,
1617
1720
  green: true,
1721
+ broken: null,
1618
1722
  base_dir: baseRoot,
1619
1723
  base_sha: baseSha,
1620
1724
  base_head: null,
1621
1725
  base_facts: null,
1622
1726
  head_facts: null,
1623
1727
  parts_compared: 0,
1624
- report_only: [],
1728
+ owner: [],
1625
1729
  regressions: [],
1626
1730
  };
1627
- const excuse = (name, why) => { out.gates = false; out.report_only.push({ case: name, why }); };
1731
+ const owner = (name, why, extra = {}) => out.owner.push({ case: name, why, ...extra });
1732
+ // A missing or wrong base tree is red, never green: there is nothing to
1733
+ // compare, and a rule that cannot compare cannot say nothing was lost
1734
+ // (roadmap R7 line 4).
1735
+ const broken = (why) => { out.broken = why; out.green = false; return out; };
1628
1736
 
1629
1737
  // 1. Is there a base tree at all, and is it the pull request's base?
1630
- const baseRead = readFacts(baseRoot);
1631
- if (!existsSync(join(baseRoot, 'atlas.yaml'))) {
1632
- excuse('base-tree', `no base tree at ${baseRoot}: the checkout of the base commit did not land, so there is nothing to compare`);
1633
- return out;
1634
- }
1635
- if (baseRead.error || !baseRead.facts) {
1636
- excuse('base-tree', `the base tree at ${baseRoot} has no readable atlas.yaml${baseRead.error ? `: ${baseRead.error}` : ''}, so its kind and spec cannot be read`);
1637
- return out;
1638
- }
1639
- out.base_head = checkoutHead(baseRoot);
1738
+ if (!existsSync(baseRoot) || isEmptyDir(baseRoot)) return broken(`no base tree at ${baseRoot}: the base commit did not land, so there is nothing to compare`);
1739
+ out.base_head = baseCommit && PINNED.test(baseCommit) ? baseCommit : checkoutHead(baseRoot);
1640
1740
  // The pull request promised a base commit. If the tree cannot say which
1641
1741
  // commit it is, the promise is unchecked, and an unchecked promise is not a
1642
1742
  // base tree. Reporting a sha this check was handed but never read would be a
1643
- // claim it cannot support, so it takes the base-tree case instead.
1644
- if (baseSha && !out.base_head) {
1645
- excuse('base-tree', `the base tree at ${baseRoot} does not say which commit it is, so this check cannot confirm it is ${baseSha.slice(0, 12)}, the base this pull request names`);
1646
- return out;
1647
- }
1648
- if (baseSha && out.base_head !== baseSha) {
1649
- excuse('base-tree', `the base tree at ${baseRoot} is at ${out.base_head.slice(0, 12)}, and this pull request's base is ${baseSha.slice(0, 12)}; the comparison would be against the wrong commit`);
1743
+ // claim it cannot support.
1744
+ if (baseSha && !out.base_head) return broken(`the base tree at ${baseRoot} does not say which commit it is, so this check cannot confirm it is ${baseSha.slice(0, 12)}, the base this pull request names`);
1745
+ if (baseSha && out.base_head !== baseSha) return broken(`the base tree at ${baseRoot} is at ${out.base_head.slice(0, 12)}, and this pull request's base is ${baseSha.slice(0, 12)}; the comparison would be against the wrong commit`);
1746
+ // A base with no atlas.yaml is the first run in a repo: the pull request that
1747
+ // adds Atlas. There is nothing to lose, and the owner reads the first shape.
1748
+ const baseRead = readFacts(baseRoot);
1749
+ if (!baseRead.facts && !baseRead.error) {
1750
+ owner('first-run', 'the base has no atlas.yaml, so there is nothing to lose; the owner reads the first shape');
1650
1751
  return out;
1651
1752
  }
1753
+ // A base atlas.yaml that does not read cannot name a kind or a spec, so
1754
+ // there is no yardstick. That is red. The owner merges the fix by hand.
1755
+ if (baseRead.error) return broken(`the base atlas.yaml does not read, so its kind and spec are unknown: ${baseRead.error}`);
1652
1756
 
1653
1757
  // 2. The base tree's kind and spec score both trees.
1654
1758
  const scoreAs = { kind: baseRead.facts.kind ?? null, spec: baseRead.facts.spec ?? null };
@@ -1657,28 +1761,30 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secr
1657
1761
  out.head_facts = { kind: headRead.facts?.kind ?? null, spec: headRead.facts?.spec ?? null };
1658
1762
 
1659
1763
  if (String(out.head_facts.kind) !== String(scoreAs.kind)) {
1660
- excuse('kind', `atlas.yaml names kind "${scoreAs.kind}" at the base and "${out.head_facts.kind}" at the head; this pull request changes which parts the repo needs`);
1764
+ owner('kind', `atlas.yaml names kind "${scoreAs.kind}" at the base and "${out.head_facts.kind}" at the head; this pull request changes which parts the repo needs`);
1661
1765
  }
1662
1766
  if (String(out.head_facts.spec) !== String(scoreAs.spec)) {
1663
- excuse('spec', `atlas.yaml names spec "${scoreAs.spec}" at the base and "${out.head_facts.spec}" at the head; this pull request changes what the spec version means`);
1767
+ owner('spec', `atlas.yaml names spec "${scoreAs.spec}" at the base and "${out.head_facts.spec}" at the head; this pull request changes what the spec version means`);
1664
1768
  }
1665
1769
 
1666
- // 3. Does the diff change the gate itself?
1667
- for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH]]) {
1770
+ // 3. Does the diff change the gate itself? The base copy judges, so the
1771
+ // change cannot score itself, but only the owner may approve it.
1772
+ for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH], ['workflow', OLD_WORKFLOW_PATH]]) {
1668
1773
  if (fileHash(baseRoot, path) !== fileHash(headRoot, path)) {
1669
- excuse(name, `this pull request changes ${path}, which is the gate itself; it is instruction class, so a person merges it`);
1774
+ owner(name, `this pull request changes ${path}, which is the gate itself; it is instruction class, so only the owner approves it`, { path });
1670
1775
  }
1671
1776
  }
1672
1777
 
1673
- // 4. Score both trees, by the same yardstick, and compare part by part.
1778
+ // 4. Score both trees, by the same yardstick, and compare part by part. The
1779
+ // head is scored twice: at its own pointers, and at the base's.
1674
1780
  const base = scan({ root: baseRoot, repoId, secretPatterns: patterns, scoreAs });
1675
1781
  const head = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs });
1676
- if (!base.files.length) {
1677
- excuse('base-tree', `the base tree at ${baseRoot} could not be scored: ${base.errors.join('; ') || 'no parts came back'}`);
1678
- return out;
1679
- }
1782
+ const pointers = { files: baseRead.facts.files, procedures: baseRead.facts.procedures };
1783
+ const headAtBase = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs: { ...scoreAs, pointers } });
1784
+ if (!base.files.length) return broken(`the base tree at ${baseRoot} could not be scored: ${base.errors.join('; ') || 'no parts came back'}`);
1680
1785
 
1681
1786
  const headParts = partIndex(head);
1787
+ const atBaseParts = partIndex(headAtBase);
1682
1788
  for (const [key, { file, part }] of partIndex(base)) {
1683
1789
  if (file.class !== 'instruction' || part.state !== 'filled') continue;
1684
1790
  out.parts_compared += 1;
@@ -1689,12 +1795,51 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secr
1689
1795
  }
1690
1796
  if (now.part.state !== 'filled') {
1691
1797
  out.regressions.push({ part: key, label: label(now.part), was: 'filled', now: now.part.state, findings: now.part.findings, why: `${label(now.part)} was filled at the base and is ${now.part.state} at the head` });
1798
+ continue;
1692
1799
  }
1800
+ // A moved part: the head points it somewhere else. The head is filled
1801
+ // at its new pointer, or the part would be a regression above. The owner
1802
+ // reads every move, and the answer says whether the base path still holds
1803
+ // the part in the head (roadmap R7 line 3).
1804
+ const from = part.path;
1805
+ const to = now.part.path;
1806
+ if (!from || !to || from === to) continue;
1807
+ const kept = atBaseParts.get(key)?.part.state === 'filled' ? `; ${from} still holds it` : `; ${from} no longer holds it`;
1808
+ owner('moved', `${label(part)} moved from ${from} to ${to}${kept}`, { path: to });
1693
1809
  }
1694
1810
  out.green = !out.regressions.length;
1695
1811
  return out;
1696
1812
  }
1697
1813
 
1814
+ // The answer of the shape rule for the gate: success, owner or red, with one
1815
+ // row for each finding. Red wins over owner. The gate job and `atlas gate
1816
+ // check` both take it from here, so the two cannot disagree.
1817
+ export const GATE_RESULTS = Object.freeze(['success', 'owner', 'red']);
1818
+ export function gateAnswer(report, state) {
1819
+ const rows = [];
1820
+ for (const error of report.errors) rows.push({ kind: 'red', class: 'shape', cause: `the check hit an error at the head: ${error}` });
1821
+ if (state?.broken) rows.push({ kind: 'red', class: 'shape', cause: state.broken });
1822
+ for (const row of state?.regressions ?? []) rows.push({ kind: 'red', class: 'shape', cause: row.why });
1823
+ for (const row of state?.owner ?? []) rows.push({ kind: 'owner', class: 'shape', cause: `${row.case}: ${row.why}`, ...(row.path ? { path: row.path } : {}) });
1824
+ if (!state) rows.push({ kind: 'red', class: 'shape', cause: 'the no-loss rule did not run: no base tree was named' });
1825
+ const result = rows.some((row) => row.kind === 'red') ? 'red' : rows.some((row) => row.kind === 'owner') ? 'owner' : 'success';
1826
+ return { result, class: result === 'success' ? null : 'shape', rows };
1827
+ }
1828
+
1829
+ // One line for each row, with a fixed prefix, and no control character, so a
1830
+ // path cannot start a workflow command.
1831
+ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
1832
+ export function gateLines(answer, state) {
1833
+ const base = state?.base_head ? state.base_head.slice(0, 12) : 'none';
1834
+ const yard = state?.base_facts ? `kind ${oneLine(state.base_facts.kind)}, spec ${oneLine(state.base_facts.spec)}` : 'no yardstick';
1835
+ const out = [`shape: base ${base}, ${yard}, ${state?.parts_compared ?? 0} instruction-class part(s) filled at the base`];
1836
+ for (const row of answer.rows) out.push(` ${row.kind.padEnd(6)} ${row.class.padEnd(6)} ${oneLine(row.cause)}`);
1837
+ if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
1838
+ else if (answer.result === 'owner') out.push("OWNER: shape. Clears with the owner's signed act: atlas approve <pr> (roadmap R72).");
1839
+ else out.push('SUCCESS: no instruction-class part went backwards.');
1840
+ return out;
1841
+ }
1842
+
1698
1843
  // ---------------------------------------------------------------------------
1699
1844
  // The report. Two shapes: plain text for the CI log, and GitHub markdown for
1700
1845
  // the job summary. Both list every part, its state and its findings.
@@ -1748,23 +1893,13 @@ export function noLossLines(state) {
1748
1893
  out.push(` base ${read} in ${state.base_dir}${promised}`);
1749
1894
  if (state.base_facts) out.push(` scored both trees as kind ${state.base_facts.kind}, spec ${state.base_facts.spec}, from the base tree`);
1750
1895
  if (state.parts_compared) out.push(` ${state.parts_compared} instruction-class part(s) were filled at the base`);
1751
- for (const row of state.report_only) out.push(` REPORT ONLY (${row.case}): ${row.why}`);
1896
+ if (state.broken) out.push(` NO BASE TREE: ${state.broken}`);
1897
+ for (const row of state.owner ?? []) out.push(` OWNER (${row.case}): ${row.why}`);
1752
1898
  for (const row of state.regressions) {
1753
1899
  out.push(` BACKWARDS ${row.label}: ${row.was} at the base, ${row.now} at the head`);
1754
1900
  for (const finding of row.findings ?? []) out.push(` - ${finding}`);
1755
1901
  }
1756
1902
  if (!state.regressions.length && state.base_facts) out.push(' no instruction-class part went backwards');
1757
- if (!state.gates) {
1758
- out.push(' this no-loss rule reports and does not gate on this pull request');
1759
- // The loudest line in the report, and the one a reader must not miss. The
1760
- // frozen text excuses these five cases because a person merges every file
1761
- // that causes one. That is a compensating control, not an absence of
1762
- // findings, so the findings are said out loud beside the green.
1763
- if (state.regressions.length) {
1764
- out.push(` WARNING: ${state.regressions.length} part(s) above went backwards and this job is NOT red for them.`);
1765
- out.push(' A person merges this pull request. Read the BACKWARDS lines before you do.');
1766
- }
1767
- }
1768
1903
  return out;
1769
1904
  }
1770
1905
 
@@ -1808,15 +1943,14 @@ export function toMarkdown(report) {
1808
1943
  export function verdict(report, failOn) {
1809
1944
  if (report.errors.length) return { green: false, why: `the check hit ${report.errors.length} error(s)` };
1810
1945
  const state = report.noLoss;
1811
- if (state?.gates && state.regressions.length) {
1946
+ if (state?.broken) return { green: false, why: `the no-loss rule has no base tree to compare: ${state.broken}` };
1947
+ if (state?.regressions.length) {
1812
1948
  return { green: false, why: `${state.regressions.length} instruction-class part(s) went backwards against the base: ${state.regressions.map((row) => row.label).join(', ')}` };
1813
1949
  }
1814
1950
  if (failOn === 'required' && report.counts.required_not_filled) return { green: false, why: `${report.counts.required_not_filled} required file(s) are not filled` };
1815
1951
  if (failOn === 'any' && report.counts.not_filled) return { green: false, why: `${report.counts.not_filled} file(s) are not filled` };
1816
- const excused = state && !state.gates && state.regressions.length
1817
- ? ` — WARNING: ${state.regressions.length} part(s) went backwards and were NOT gated; a person merges this one`
1818
- : '';
1819
- const noLossWhy = state ? (state.gates ? '; no instruction-class part went backwards' : `; the no-loss rule reported and did not gate (${state.report_only.map((row) => row.case).join(', ')})${excused}`) : '';
1952
+ const owned = state?.owner?.length ? `; only the owner approves this change (${[...new Set(state.owner.map((row) => row.case))].join(', ')}), so the gate says "owner"` : '';
1953
+ const noLossWhy = state ? `; no instruction-class part went backwards${owned}` : '';
1820
1954
  return { green: true, why: `${report.counts.required_not_filled} required file(s) are not filled; fail-on is "${failOn}"${noLossWhy}` };
1821
1955
  }
1822
1956
 
@@ -1828,7 +1962,7 @@ export const FAIL_ON = Object.freeze(['error', 'required', 'any']);
1828
1962
  export const EXTRACTORS = Object.freeze([]); // Slice L3 adds routes, migrations and mcp.
1829
1963
 
1830
1964
  export function parseArguments(argv) {
1831
- const out = { root: process.cwd(), failOn: 'error', json: null, summary: null, quiet: false, base: null, baseSha: null };
1965
+ const out = { root: process.cwd(), failOn: 'error', json: null, summary: null, quiet: false, base: null, baseSha: null, baseCommit: null, headCommit: null, git: process.cwd(), gate: false };
1832
1966
  for (let index = 0; index < argv.length; index += 1) {
1833
1967
  const flag = argv[index];
1834
1968
  const next = () => { const value = argv[index + 1]; if (value === undefined) throw new Error(`${flag} needs a value`); index += 1; return value; };
@@ -1842,16 +1976,27 @@ export function parseArguments(argv) {
1842
1976
  // the check can say the tree it reads is the tree it was promised.
1843
1977
  else if (flag === '--base') out.base = resolve(next());
1844
1978
  else if (flag === '--base-sha') out.baseSha = next().trim() || null;
1979
+ // Gate mode (roadmap R7). The gate job wrote the base tree from git
1980
+ // objects, so it names the commit; the answer is success, owner or red.
1981
+ else if (flag === '--base-commit') out.baseCommit = next().trim() || null;
1982
+ else if (flag === '--head-commit') out.headCommit = next().trim() || null;
1983
+ else if (flag === '--git') out.git = resolve(next());
1984
+ else if (flag === '--gate') out.gate = true;
1845
1985
  else if (flag === '--repo') out.repo = next();
1846
1986
  else if (flag === '--version') out.version = true;
1847
1987
  else throw new Error(`unknown argument ${flag}`);
1848
1988
  }
1849
1989
  if (!FAIL_ON.includes(out.failOn)) throw new Error(`--fail-on must be one of ${FAIL_ON.join(', ')}`);
1990
+ for (const [flag, value] of [['--base-commit', out.baseCommit], ['--head-commit', out.headCommit]]) {
1991
+ if (value && !PINNED.test(value)) throw new Error(`${flag} must be a full 40-character commit SHA`);
1992
+ }
1993
+ if (out.gate && (!out.baseCommit || !out.headCommit)) throw new Error('--gate needs --base-commit and --head-commit');
1850
1994
  return out;
1851
1995
  }
1852
1996
 
1853
1997
  export function run(argv, env = process.env) {
1854
1998
  const options = parseArguments(argv);
1999
+ if (options.gate) return runGate({ options, env });
1855
2000
  const repoId = options.repo ?? env.GITHUB_REPOSITORY ?? null;
1856
2001
  const patterns = secretPatterns();
1857
2002
  const report = scan({ root: options.root, repoId, extractors: EXTRACTORS, secretPatterns: patterns });
@@ -1859,7 +2004,7 @@ export function run(argv, env = process.env) {
1859
2004
  // hand. The no-loss rule then does not run at all, and the report says nothing
1860
2005
  // about it rather than claiming a pass.
1861
2006
  report.noLoss = options.base
1862
- ? noLoss({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, repoId, secretPatterns: patterns })
2007
+ ? noLoss({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, baseCommit: options.baseCommit, repoId, secretPatterns: patterns })
1863
2008
  : null;
1864
2009
  const decision = verdict(report, options.failOn);
1865
2010
  const text = toText(report);
@@ -1870,6 +2015,144 @@ export function run(argv, env = process.env) {
1870
2015
  return { report, decision, text, options };
1871
2016
  }
1872
2017
 
2018
+ // ---------------------------------------------------------------------------
2019
+ // The two trees of the gate (roadmap R7). Each one is written from git objects:
2020
+ // `git ls-tree` lists the entries and `git cat-file` gives each blob, so no
2021
+ // attribute of the tree applies. `git archive` would apply the head's own
2022
+ // .gitattributes (export-subst, export-ignore, a filter), and the check would
2023
+ // score bytes that do not merge (security review of R7, finding S1).
2024
+ //
2025
+ // Each blob is checked against its id. A path must be plain: no empty, "." or
2026
+ // ".." part, no ".git" part, no leading slash and no control character. A link
2027
+ // or a submodule is listed and never written, so the check never follows one.
2028
+ // ---------------------------------------------------------------------------
2029
+
2030
+ export class TreeError extends Error {}
2031
+
2032
+ export const TREE_LIMITS = Object.freeze({ files: 200_000, bytes: 1024 * 1024 * 1024 });
2033
+
2034
+ function gitRun(git, args, input = null) {
2035
+ const r = spawnSync('git', ['-C', git, ...args], { input, maxBuffer: TREE_LIMITS.bytes + 64 * 1024 * 1024 });
2036
+ if (r.error) throw new TreeError(`git cannot run: ${r.error.message}`);
2037
+ if (r.status !== 0) throw new TreeError(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
2038
+ return r.stdout;
2039
+ }
2040
+
2041
+ const PLAIN_PART = /^(?!\.\.?$)(?!\.git$)[^\u0000-\u001f\u007f/\\]+$/;
2042
+ export const plainPath = (path) => typeof path === 'string' && path.length > 0 && path.split('/').every((part) => PLAIN_PART.test(part));
2043
+
2044
+ // The entries of one commit's tree: blobs, links and submodules.
2045
+ export function treeEntries(git, commit) {
2046
+ const out = { blobs: [], links: [], modules: [] };
2047
+ const seen = new Set();
2048
+ for (const row of gitRun(git, ['ls-tree', '-r', '-z', '--full-tree', commit]).toString('utf8').split('\0')) {
2049
+ if (!row) continue;
2050
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64})\t([\s\S]+)$/.exec(row);
2051
+ if (!match) throw new TreeError('git ls-tree printed a row this check cannot read');
2052
+ const [, mode, type, id, path] = match;
2053
+ if (!plainPath(path)) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds a path that is not plain`);
2054
+ if (seen.has(path)) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds one path twice`);
2055
+ seen.add(path);
2056
+ if (mode === '120000') out.links.push({ path, id });
2057
+ else if (type === 'commit') out.modules.push({ path, id });
2058
+ else if (type === 'blob') out.blobs.push({ path, id });
2059
+ else throw new TreeError(`the tree of ${commit.slice(0, 12)} holds an entry of type ${type}`);
2060
+ }
2061
+ return out;
2062
+ }
2063
+
2064
+ const blobId = (bytes, length) => createHash(length === 64 ? 'sha256' : 'sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
2065
+
2066
+ // Write the blobs of one commit under `dest`. Returns the entries.
2067
+ export function writeTree({ git, commit, dest }) {
2068
+ const entries = treeEntries(git, commit);
2069
+ if (entries.blobs.length > TREE_LIMITS.files) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds more than ${TREE_LIMITS.files} files`);
2070
+ const output = entries.blobs.length ? gitRun(git, ['cat-file', '--batch'], `${entries.blobs.map((blob) => blob.id).join('\n')}\n`) : Buffer.alloc(0);
2071
+ let at = 0;
2072
+ let total = 0;
2073
+ for (const blob of entries.blobs) {
2074
+ const end = output.indexOf(0x0a, at);
2075
+ const header = output.subarray(at, end).toString('utf8');
2076
+ const match = /^([0-9a-f]{40,64}) blob (\d+)$/.exec(header);
2077
+ if (!match || match[1] !== blob.id) throw new TreeError(`git cat-file did not give the blob of ${blob.path}`);
2078
+ const size = Number(match[2]);
2079
+ total += size;
2080
+ if (total > TREE_LIMITS.bytes) throw new TreeError(`the tree of ${commit.slice(0, 12)} is over ${TREE_LIMITS.bytes} bytes`);
2081
+ const bytes = output.subarray(end + 1, end + 1 + size);
2082
+ if (blobId(bytes, blob.id.length) !== blob.id) throw new TreeError(`the bytes of ${blob.path} are not its blob`);
2083
+ const full = join(dest, ...blob.path.split('/'));
2084
+ mkdirSync(dirname(full), { recursive: true });
2085
+ writeFileSync(full, bytes, { flag: 'wx' });
2086
+ at = end + 1 + size + 1;
2087
+ }
2088
+ return entries;
2089
+ }
2090
+
2091
+ const commitOf = (git, ref) => {
2092
+ const r = spawnSync('git', ['-C', git, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' });
2093
+ return r.status === 0 ? r.stdout.trim() : null;
2094
+ };
2095
+
2096
+ // The shape rule over git data: the base commit is the tip of the base branch,
2097
+ // the head commit is the pull request's head. Both trees come from the merge
2098
+ // base and the head. Any step that fails is red.
2099
+ export function gateFromGit({ git, baseCommit, headCommit, repoId = null, trees = null, secretPatterns: patterns = secretPatterns() }) {
2100
+ const red = (why) => ({ report: { errors: [], files: [], counts: counts([]) }, state: { broken: why, owner: [], regressions: [], parts_compared: 0, base_head: null, base_facts: null } });
2101
+ const base = commitOf(git, baseCommit);
2102
+ const head = commitOf(git, headCommit);
2103
+ if (!base) return red(`the base commit ${String(baseCommit).slice(0, 12)} is not in this clone`);
2104
+ if (!head) return red(`the head commit ${String(headCommit).slice(0, 12)} is not in this clone`);
2105
+ let mergeBase;
2106
+ try { mergeBase = gitRun(git, ['merge-base', '--end-of-options', base, head]).toString('utf8').trim(); } catch { return red('the base and the head share no commit in this clone'); }
2107
+ const top = trees ?? mkdtempSync(join(tmpdir(), 'atlas-gate-'));
2108
+ const baseRoot = join(top, GATE_TREES.base);
2109
+ const headRoot = join(top, GATE_TREES.head);
2110
+ try {
2111
+ let baseEntries;
2112
+ let headEntries;
2113
+ try {
2114
+ baseEntries = writeTree({ git, commit: mergeBase, dest: baseRoot });
2115
+ headEntries = writeTree({ git, commit: head, dest: headRoot });
2116
+ } catch (error) {
2117
+ if (error instanceof TreeError) return red(`a tree could not be written: ${error.message}`);
2118
+ throw error;
2119
+ }
2120
+ mkdirSync(baseRoot, { recursive: true });
2121
+ mkdirSync(headRoot, { recursive: true });
2122
+ const report = scan({ root: headRoot, repoId, secretPatterns: patterns });
2123
+ const state = noLoss({ headRoot, baseRoot, baseCommit: mergeBase, repoId, secretPatterns: patterns });
2124
+ // A link or a submodule that the head adds or changes is never read, so the
2125
+ // check cannot judge it. The owner reads it.
2126
+ const had = new Map([...baseEntries.links, ...baseEntries.modules].map((entry) => [entry.path, entry.id]));
2127
+ for (const entry of [...headEntries.links, ...headEntries.modules]) {
2128
+ if (had.get(entry.path) !== entry.id) state.owner.push({ case: 'link', why: `${entry.path} is a link or a submodule that this pull request adds or changes; the check never follows one`, path: entry.path });
2129
+ }
2130
+ return { report, state, merge_base: mergeBase, head };
2131
+ } finally {
2132
+ if (!trees) rmSync(top, { recursive: true, force: true });
2133
+ }
2134
+ }
2135
+
2136
+ // Gate mode. The job summary holds the full report and the answer. The output
2137
+ // file gets two keys only, each one word of a fixed set, so no path can add a
2138
+ // key. Exit 0 on success, 1 on owner or red.
2139
+ function runGate({ options, env }) {
2140
+ const { report, state } = gateFromGit({ git: options.git, baseCommit: options.baseCommit, headCommit: options.headCommit, repoId: options.repo ?? env.GITHUB_REPOSITORY ?? null });
2141
+ const answer = gateAnswer(report, state);
2142
+ // Only the answer lines leave this process. Each one has a fixed prefix and
2143
+ // no control character or backtick, so a head string cannot start a
2144
+ // workflow command or close the fence of the summary (security review of
2145
+ // R7). The full report stays in the JSON file, for a run by hand.
2146
+ const lines = gateLines(answer, state).map((line) => line.replaceAll('`', "'"));
2147
+ if (options.json) writeFileSync(options.json, `${JSON.stringify({ ...report, noLoss: state, gate: answer }, null, 2)}\n`);
2148
+ const summary = `## Atlas repo shape, from the base\n\n${['```text', ...lines, '```'].join('\n')}\n`;
2149
+ if (options.summary) writeFileSync(options.summary, summary);
2150
+ if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
2151
+ if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `result=${answer.result}\nclass=${answer.class ?? 'none'}\n`);
2152
+ const decision = { green: answer.result === 'success', why: lines.at(-1) };
2153
+ return { report, decision, text: `${lines.join('\n')}\n`, lines, options, answer, gate: true };
2154
+ }
2155
+
1873
2156
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1874
2157
  try {
1875
2158
  // --version is parsed like every other flag, so an unknown flag beside it
@@ -1878,9 +2161,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
1878
2161
  if (asked.version) {
1879
2162
  process.stdout.write(`atlas repo-shape check, check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}\n`);
1880
2163
  } else {
1881
- const { decision, text, options } = run(process.argv.slice(2));
1882
- if (!options.quiet) process.stdout.write(text);
1883
- process.stdout.write(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}\n`);
2164
+ const { decision, text, options, gate } = run(process.argv.slice(2));
2165
+ if (!options.quiet || gate) process.stdout.write(text);
2166
+ // In gate mode the last line of the text already says SUCCESS, OWNER or RED.
2167
+ if (!gate) process.stdout.write(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}\n`);
1884
2168
  process.exitCode = decision.green ? 0 : 1;
1885
2169
  }
1886
2170
  } catch (error) {
@@ -30,6 +30,11 @@
30
30
  "sha256": "8f36633fe693ebf7a7cc0f7237d49eb4720f67d371d4758d556bab51965ec334",
31
31
  "first_package": "0.3.48",
32
32
  "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
33
+ },
34
+ {
35
+ "check_version": 6,
36
+ "sha256": "bad1d05853e173c034971282c5762a1628dfd9f198c8276178bd17280afdf6e7",
37
+ "workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
33
38
  }
34
39
  ]
35
40
  }