@arjunkhera/atlas 0.4.15 → 0.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shape/check.mjs CHANGED
@@ -7,21 +7,25 @@
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
- // What it reports: one state per part of the seven files. Five states: filled,
13
- // empty, stale, not applicable, no extractor. It leads with the count of
14
- // required parts that are not filled.
14
+ // What it reports: one state per part of the six files. Four states: filled,
15
+ // empty, stale, not applicable. It leads with the count of required parts
16
+ // that are not filled.
15
17
  //
16
18
  // It carries the version of the Atlas package it came from, so a copy in a
17
19
  // repo says where it came from. It refuses a spec version it cannot read.
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 = 7;
38
42
 
39
43
  // ---------------------------------------------------------------------------
40
44
  // The spec. One object per version. The check reads a version it has here and
@@ -49,8 +53,7 @@ export const SPEC_1 = {
49
53
  "filled": "The part exists and matches the repo.",
50
54
  "empty": "The part is missing, or it holds nothing.",
51
55
  "stale": "The part exists but disagrees with the repo.",
52
- "not applicable": "This repo kind does not need the part.",
53
- "no extractor": "The part is generated, and no extractor exists for it yet."
56
+ "not applicable": "This repo kind does not need the part."
54
57
  },
55
58
  "kinds": {
56
59
  "service": "A running product with a deploy target, logs and metrics.",
@@ -316,109 +319,17 @@ export const SPEC_1 = {
316
319
  }
317
320
  ]
318
321
  },
319
- {
320
- "id": "docs-content",
321
- "number": 6,
322
- "title": "Docs content",
323
- "class": "docs",
324
- "written_by": "the upkeep task and the talk-through; merges on its own",
325
- "parts": [
326
- {
327
- "id": "pages",
328
- "path": "docs/site",
329
- "glob": "*.md",
330
- "holds": "The pages: home, features, flows with their step lists, concepts. Each page carries the commit it was built from.",
331
- "kind": {
332
- "service": "required",
333
- "library": "optional",
334
- "schema": "optional",
335
- "plugin": "optional",
336
- "cli": "optional"
337
- },
338
- "check": "docs-pages"
339
- },
340
- {
341
- "id": "features",
342
- "path": "docs/site/features.yaml",
343
- "holds": "The feature list the renderer reads.",
344
- "kind": {
345
- "service": "required",
346
- "library": "optional",
347
- "schema": "optional",
348
- "plugin": "optional",
349
- "cli": "optional"
350
- },
351
- "check": "features"
352
- },
353
- {
354
- "id": "notes",
355
- "path": "docs/site/notes.md",
356
- "class": "instruction",
357
- "holds": "The standing corrections. Each note names who said it and the session. A person merges it.",
358
- "kind": {
359
- "service": "optional",
360
- "library": "optional",
361
- "schema": "optional",
362
- "plugin": "optional",
363
- "cli": "optional"
364
- },
365
- "check": "notes"
366
- },
367
- {
368
- "id": "api",
369
- "path": "docs/site/reference/api.md",
370
- "holds": "Every route with its method, path, principal, handler file and line.",
371
- "kind": {
372
- "service": "required",
373
- "library": "not applicable",
374
- "schema": "not applicable",
375
- "plugin": "not applicable",
376
- "cli": "not applicable"
377
- },
378
- "check": "generated",
379
- "extractor": "routes"
380
- },
381
- {
382
- "id": "database",
383
- "path": "docs/site/reference/database.md",
384
- "holds": "The tables, their fields, their types and the purpose of each field.",
385
- "kind": {
386
- "service": "required",
387
- "library": "not applicable",
388
- "schema": "required",
389
- "plugin": "not applicable",
390
- "cli": "not applicable"
391
- },
392
- "check": "generated",
393
- "extractor": "migrations"
394
- },
395
- {
396
- "id": "mcp-tools",
397
- "path": "docs/site/reference/mcp-tools.md",
398
- "holds": "Every MCP tool, its inputs, and when to use it.",
399
- "kind": {
400
- "service": "optional",
401
- "library": "optional",
402
- "schema": "optional",
403
- "plugin": "optional",
404
- "cli": "optional"
405
- },
406
- "check": "generated",
407
- "extractor": "mcp"
408
- }
409
- ]
410
- },
411
322
  {
412
323
  "id": "tooling",
413
- "number": 7,
324
+ "number": 6,
414
325
  "title": "Tooling",
415
326
  "class": "instruction",
416
327
  "written_by": "the helper; a person merges",
417
328
  "parts": [
418
329
  {
419
330
  "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.",
331
+ "path": ".github/workflows/atlas-gate.yml",
332
+ "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
333
  "kind": {
423
334
  "service": "required",
424
335
  "library": "required",
@@ -463,11 +374,6 @@ export const SPEC_1 = {
463
374
  ]
464
375
  }
465
376
  ],
466
- "extractors": {
467
- "routes": "Slice L3 builds it.",
468
- "migrations": "Slice L3 builds it.",
469
- "mcp": "Slice L3 builds it."
470
- },
471
377
  "by_reference": {
472
378
  "rule": "A file under docs/ that an instruction file points at is instruction class by reference (D7 finding R1). The check computes that set and reports each member.",
473
379
  "scanned": [
@@ -476,8 +382,7 @@ export const SPEC_1 = {
476
382
  ".claude/rules/*.md",
477
383
  ".claude/skills/*/SKILL.md",
478
384
  "atlas.yaml",
479
- "atlas/*.yaml",
480
- "docs/site/notes.md"
385
+ "atlas/*.yaml"
481
386
  ]
482
387
  }
483
388
  };
@@ -494,7 +399,7 @@ function deepFreeze(value) {
494
399
  }
495
400
  deepFreeze(SPEC_1);
496
401
 
497
- // Spec 2 adds one part: the check file itself, scored inside file 7.
402
+ // Spec 2 adds one part: the check file itself, scored inside file 6.
498
403
  //
499
404
  // Why a new version and not a line in spec 1. Adding a scored part changes what
500
405
  // `spec: 1` means, and the spec version is the whole coupling between a
@@ -502,8 +407,9 @@ deepFreeze(SPEC_1);
502
407
  // P11). The check reads both versions, so a repository stays green while it is
503
408
  // still on spec 1, and it opts in by moving `atlas.yaml` to `spec: 2`.
504
409
  //
505
- // Nothing else moves. Spec 1 keeps its twenty-two parts, and spec 2 has
506
- // twenty-three.
410
+ // Nothing else moves. Spec 1 keeps its sixteen parts, and spec 2 has
411
+ // seventeen. Check version 7 took the six parts of the retired docs site out
412
+ // of both (roadmap R37).
507
413
  //
508
414
  // Check version 4 adds two kinds, plugin and cli, to both specs (issue #141).
509
415
  // No cell of an older kind moves, so a repo that names service, library or
@@ -640,64 +546,135 @@ export function canaryMisses() {
640
546
  }
641
547
 
642
548
  // ---------------------------------------------------------------------------
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).
549
+ // The workflow that runs this file: the gate (roadmap R7, design
550
+ // docs/design-docs/shape-check-from-base/ in the Atlas repo). The tooling
551
+ // writer puts it into each repo from here. It runs on pull_request_target, so
552
+ // GitHub takes it from the base branch, and the check that judges a pull
553
+ // request is the base copy. It reads the head as git objects only, it holds a
554
+ // read token, and it pins its one action by commit SHA (design review finding
555
+ // R11).
556
+ //
557
+ // Check version 6 moved the check here from the old atlas-check.yml, which ran
558
+ // on pull_request with the head's copy of this file. A pull request could edit
559
+ // the check to print GREEN, and the check only warned that a person merges it.
647
560
  // ---------------------------------------------------------------------------
648
561
 
649
- export const WORKFLOW_PATH = '.github/workflows/atlas-check.yml';
562
+ export const WORKFLOW_PATH = '.github/workflows/atlas-gate.yml';
563
+ // The workflow of check versions 1 to 5. The tooling writer removes it.
564
+ export const OLD_WORKFLOW_PATH = '.github/workflows/atlas-check.yml';
650
565
  export const CHECK_PATH = '.github/atlas/check.mjs';
651
566
 
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.
567
+ // Where a run by hand, or an old workflow, puts the base tree. `walk` and
568
+ // `symlinks` skip every entry whose name starts with a dot, and every other
569
+ // read names an exact path, so no scanner ever reaches into it.
655
570
  export const BASE_DIR = '.atlas-base';
656
571
 
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.
572
+ // The two trees of the gate, under the runner's temp folder, outside the
573
+ // checkout. Each is written from git objects, and loses every link.
574
+ export const GATE_TREES = Object.freeze({ base: 'atlas-base', head: 'atlas-head' });
575
+
576
+ // The job and the check name. The name says the job runs the shape rule
577
+ // only, so no reader takes it for the full gate, which keeps the name `gate`
578
+ // (roadmap R76). A repo may add jobs to this file; the shape job must stay as
579
+ // Atlas writes it, and `atlas status` compares that job.
580
+ export const SHAPE_JOB = 'shape';
581
+ export const SHAPE_CHECK = 'gate · shape';
582
+
583
+ export const WORKFLOW_HEADER = `# The Atlas gate workflow, in this repo's own CI. Its shape job is the repo
584
+ # shape check, judged from the base.
663
585
  #
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.
586
+ # It runs on pull_request_target, so GitHub runs this file as the base branch
587
+ # holds it. The job checks out the base and runs the base copy of
588
+ # ${CHECK_PATH}. It fetches the head as git objects only, and runs none of it.
666
589
  #
667
- # It reports in the docs lane. It never fails a code merge.
590
+ # The check writes two trees from git objects, the merge base and the head, in
591
+ # the runner's temp folder, and scores them side by side. No attribute of a
592
+ # tree applies, and no link is written. The no-loss rule says red when a part
593
+ # that the base had is taken away. It says owner when only the owner may
594
+ # approve the change, such as a change to the check or to this file.
668
595
  #
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.
596
+ # The token can only read. No checkout keeps a credential: the one fetch of
597
+ # the head takes the token for that command alone. The check step holds no
598
+ # token. The job writes two output keys, result and class.
675
599
  #
676
- # Design: the Atlas architecture record, sections 5.2, 6 and rule 7.
677
- name: atlas-check
600
+ # Atlas writes this file and ${CHECK_PATH}; do not edit the shape job by hand.
601
+ # Design: docs/design-docs/shape-check-from-base/ in the Atlas repo.
602
+ name: atlas-gate
603
+ run-name: "gate #\${{ github.event.pull_request.number }} at \${{ github.event.pull_request.head.sha }}"
678
604
  on:
679
- pull_request:
605
+ pull_request_target:
606
+ types: [opened, synchronize, reopened, edited, ready_for_review]
680
607
  permissions:
681
608
  contents: read
609
+ concurrency:
610
+ group: atlas-gate-\${{ github.event.pull_request.number }}
611
+ cancel-in-progress: true
682
612
  jobs:
683
- repo-shape:
684
- name: docs · repo shape
613
+ `;
614
+
615
+ export const SHAPE_JOB_TEXT = ` ${SHAPE_JOB}:
616
+ name: ${SHAPE_CHECK}
685
617
  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
618
+ # The check reads two trees and opens no network. It finishes in seconds
619
+ # on a small repo. The bound stops a runner being billed until someone
620
+ # kills it; a run that hits it is red.
621
+ timeout-minutes: 10
622
+ outputs:
623
+ result: \${{ steps.shape.outputs.result }}
624
+ class: \${{ steps.shape.outputs.class }}
689
625
  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
626
+ # actions/checkout v7.0.1
627
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
628
+ with:
629
+ ref: \${{ github.sha }}
630
+ fetch-depth: 0
631
+ persist-credentials: false
632
+ # actions/setup-node v7.0.0
633
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
695
634
  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 }}
635
+ node-version: "22.22.0"
636
+ - name: Fetch the head as objects only
637
+ env:
638
+ PR: \${{ github.event.pull_request.number }}
639
+ HEAD_SHA: \${{ github.event.pull_request.head.sha }}
640
+ TOKEN: \${{ github.token }}
641
+ run: |
642
+ set -euo pipefail
643
+ header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
644
+ git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
645
+ got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
646
+ if [ "$got" != "$HEAD_SHA" ]; then
647
+ echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
648
+ exit 1
649
+ fi
650
+ - name: Shape check from the base
651
+ id: shape
652
+ env:
653
+ HEAD_SHA: \${{ github.event.pull_request.head.sha }}
654
+ run: |
655
+ set -euo pipefail
656
+ if [ ! -f ${CHECK_PATH} ]; then
657
+ echo "OWNER: first-run: the base has no ${CHECK_PATH}, so the owner reads the first shape"
658
+ echo "result=owner" >> "$GITHUB_OUTPUT"
659
+ echo "class=first-run" >> "$GITHUB_OUTPUT"
660
+ exit 1
661
+ fi
662
+ node ${CHECK_PATH} --gate --repo "$GITHUB_REPOSITORY" --base-commit "$GITHUB_SHA" --head-commit "$HEAD_SHA"
699
663
  `;
700
664
 
665
+ export const WORKFLOW_TEMPLATE = `${WORKFLOW_HEADER}${SHAPE_JOB_TEXT}`;
666
+
667
+ // The shape job of a workflow text: from its key line to the next job key or
668
+ // the end. `atlas status` compares it with SHAPE_JOB_TEXT.
669
+ export function shapeJobOf(text) {
670
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
671
+ const start = lines.indexOf(` ${SHAPE_JOB}:`);
672
+ if (start < 0) return null;
673
+ let end = start + 1;
674
+ while (end < lines.length && !/^ {2}\S/.test(lines[end]) && !/^\S/.test(lines[end])) end += 1;
675
+ return `${lines.slice(start, end).join('\n').replace(/\n+$/, '')}\n`;
676
+ }
677
+
701
678
  // ---------------------------------------------------------------------------
702
679
  // The YAML reader. Atlas ships no dependency, so the check reads the subset of
703
680
  // YAML that atlas.yaml, the section files and a SKILL.md front matter block
@@ -890,8 +867,8 @@ export function frontMatter(source) {
890
867
  // The scanners. One per part of the spec.
891
868
  // ---------------------------------------------------------------------------
892
869
 
893
- export const STATES = Object.freeze(['filled', 'empty', 'stale', 'not applicable', 'no extractor']);
894
- export const NOT_FILLED = Object.freeze(['empty', 'stale', 'no extractor']);
870
+ export const STATES = Object.freeze(['filled', 'empty', 'stale', 'not applicable']);
871
+ export const NOT_FILLED = Object.freeze(['empty', 'stale']);
895
872
  export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]*$/;
896
873
  const PINNED = /^[0-9a-f]{40}$/;
897
874
  const MAX_LISTED = 12;
@@ -932,10 +909,10 @@ function symlinks(root, path, out = []) {
932
909
 
933
910
  // A symbolic link is a finding, never a state on its own: the part it sits
934
911
  // under says what state it makes.
935
- function symlinkFindings(root, path, keep = () => true) {
936
- const found = symlinks(root, path).filter(keep);
912
+ function symlinkFindings(root, path) {
913
+ const found = symlinks(root, path);
937
914
  return found.length
938
- ? [`${found.length} symbolic link(s) under ${path}/: ${found.slice(0, MAX_LISTED).join(', ')}; the check does not follow one, and a renderer that does would publish what it points at`]
915
+ ? [`${found.length} symbolic link(s) under ${path}/: ${found.slice(0, MAX_LISTED).join(', ')}; the check does not follow one`]
939
916
  : [];
940
917
  }
941
918
 
@@ -1013,21 +990,29 @@ function checkPathRules(context, part) {
1013
990
  if (!list.length) { bad.push(`${file} has no paths: key`); continue; }
1014
991
  for (const pattern of list) {
1015
992
  const base = String(pattern).split(/[*?[]/)[0].replace(/\/+$/, '');
993
+ if (base && !insideRepo(context.root, base)) { bad.push(`${file} names a path outside the repo`); continue; }
1016
994
  if (base && !existsSync(join(context.root, base))) bad.push(`${file} names ${pattern}, which does not exist`);
1017
995
  }
1018
996
  }
1019
997
  return bad.length ? result(part, 'stale', bad.slice(0, MAX_LISTED)) : result(part, 'filled', [`${files.length} rule file(s)`]);
1020
998
  }
1021
999
 
1000
+ const SKILL_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
1001
+
1022
1002
  function checkProcedure(context, part) {
1023
1003
  const named = context.facts?.procedures?.[part.procedure];
1024
1004
  if (named === undefined) return result(part, 'empty', [`atlas.yaml has no procedures entry for ${part.procedure}`]);
1025
1005
  if (named === null || named === '') return result(part, 'empty', [`atlas.yaml sets procedures.${part.procedure} to null: the repo has no skill for it yet`]);
1006
+ // A skill name is one folder name. A slash, a dot pair or a control
1007
+ // character would let atlas.yaml point the check outside the skills folder,
1008
+ // or outside the tree it scores (security review of roadmap R7).
1009
+ 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
1010
  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`]);
1011
+ const at = { ...part, path: skill };
1012
+ if (!isFile(context.root, skill)) return result(at, 'stale', [`atlas.yaml names skill "${named}", and ${skill} does not exist`]);
1028
1013
  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]);
1014
+ if (!front?.name || !front?.description) return result(at, 'stale', [`${skill} has no name or no description in its front matter`]);
1015
+ return result(at, 'filled', [skill]);
1031
1016
  }
1032
1017
 
1033
1018
  // `scan` reports a missing or unreadable atlas.yaml as an error and returns
@@ -1130,59 +1115,16 @@ function checkDocsIndex(context, part) {
1130
1115
  const text = readText(context.root, part.path);
1131
1116
  if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
1132
1117
  const listed = new Set(pointers(text).map((path) => (path.startsWith('docs/') ? path : posix.join('docs', path))));
1133
- const onDisk = walk(context.root, 'docs').filter((path) => path.endsWith('.md') && path !== part.path && !path.startsWith('docs/site/'));
1118
+ const onDisk = walk(context.root, 'docs').filter((path) => path.endsWith('.md') && path !== part.path);
1134
1119
  const unlisted = onDisk.filter((path) => !listed.has(path));
1135
1120
  const gone = [...listed].filter((path) => !existsSync(join(context.root, path)));
1136
1121
  const findings = [];
1137
1122
  if (unlisted.length) findings.push(`${unlisted.length} file(s) under docs/ are not listed: ${unlisted.slice(0, MAX_LISTED).join(', ')}`);
1138
1123
  if (gone.length) findings.push(`${gone.length} listed file(s) are gone: ${gone.slice(0, MAX_LISTED).join(', ')}`);
1139
- // The docs tree outside docs/site/. The pages part below owns docs/site/.
1140
- findings.push(...symlinkFindings(context.root, 'docs', (path) => !path.startsWith('docs/site/')));
1124
+ findings.push(...symlinkFindings(context.root, 'docs'));
1141
1125
  return findings.length ? result(part, 'stale', findings) : result(part, 'filled', [`${onDisk.length} file(s) listed`]);
1142
1126
  }
1143
1127
 
1144
- function checkDocsPages(context, part) {
1145
- const links = symlinkFindings(context.root, part.path);
1146
- if (!isDirectory(context.root, part.path)) return result(part, 'empty', [`${part.path}/ does not exist`]);
1147
- const pages = walk(context.root, part.path).filter((path) => path.endsWith('.md') && !path.endsWith('/notes.md'));
1148
- if (!pages.length) return result(part, links.length ? 'stale' : 'empty', [`${part.path}/ holds no page`, ...links]);
1149
- const without = pages.filter((path) => !frontMatter(readText(context.root, path) ?? '')?.built_from);
1150
- const findings = [...(without.length ? [`${without.length} page(s) do not name the commit they were built from: ${without.slice(0, MAX_LISTED).join(', ')}`] : []), ...links];
1151
- return findings.length ? result(part, 'stale', findings) : result(part, 'filled', [`${pages.length} page(s)`]);
1152
- }
1153
-
1154
- function checkFeatures(context, part) {
1155
- const text = readText(context.root, part.path);
1156
- if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
1157
- let data;
1158
- try { data = parseYaml(text); } catch (error) { return result(part, 'stale', [`${part.path} cannot be read: ${error.message}`]); }
1159
- const rows = data?.features;
1160
- if (emptyValue(rows)) return result(part, 'stale', [`${part.path} lists no feature`]);
1161
- const list = Array.isArray(rows) ? rows : Object.values(rows);
1162
- const dead = list.filter((row) => row?.page && !existsSync(join(context.root, String(row.page)))).map((row) => row.page);
1163
- return dead.length ? result(part, 'stale', [`${dead.length} feature page(s) are gone: ${dead.slice(0, MAX_LISTED).join(', ')}`]) : result(part, 'filled', [`${list.length} feature(s)`]);
1164
- }
1165
-
1166
- function checkNotes(context, part) {
1167
- const text = readText(context.root, part.path);
1168
- if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
1169
- // Finding R12 and the spec: each note names who said it and the session.
1170
- // A note that does not is a stale note, not a remark on a filled file.
1171
- const entries = text.split('\n').filter((line) => /^\s*[-*]\s+/.test(line));
1172
- if (!entries.length) return result(part, 'empty', [`${part.path} holds no note`]);
1173
- const without = entries.filter((line) => !/session/i.test(line));
1174
- if (without.length) return result(part, 'stale', [`${without.length} of ${entries.length} note(s) do not name the session they came from`]);
1175
- return result(part, 'filled', [`${entries.length} note(s)`]);
1176
- }
1177
-
1178
- function checkGenerated(context, part) {
1179
- const available = context.extractors.includes(part.extractor);
1180
- if (!available) return result(part, 'no extractor', [`no ${part.extractor} extractor exists yet; slice L3 builds it`]);
1181
- const text = readText(context.root, part.path);
1182
- if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
1183
- return result(part, 'filled');
1184
- }
1185
-
1186
1128
  // Every permission the workflow grants at anything other than read. It reads a
1187
1129
  // block map under `permissions:` and a flow map on the same line.
1188
1130
  function writeGrants(text) {
@@ -1221,6 +1163,9 @@ function writeGrants(text) {
1221
1163
  // A workflow this function cannot read is a finding, not a pass. That is the
1222
1164
  // safe direction: the check says it cannot confirm the job runs, instead of
1223
1165
  // saying the job is fine.
1166
+ // The step that runs the shape check, and the flags it must pass.
1167
+ const GATE_RUNNER = Object.freeze({ runs: `node ${CHECK_PATH} `, flags: ['--gate', '--base-commit', '--head-commit'] });
1168
+
1224
1169
  function workflowStructure(document) {
1225
1170
  const out = [];
1226
1171
  const jobs = document?.jobs;
@@ -1230,9 +1175,9 @@ function workflowStructure(document) {
1230
1175
  // the pull requests that change the files it guards, and the pull request
1231
1176
  // still reads green, because a job that never ran reports nothing (P13).
1232
1177
  const on = document.on;
1233
- const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.pull_request : null;
1178
+ const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.pull_request_target : null;
1234
1179
  if (trigger && typeof trigger === 'object' && !Array.isArray(trigger)) {
1235
- for (const key of ['paths', 'paths-ignore']) {
1180
+ for (const key of ['paths', 'paths-ignore', 'branches-ignore']) {
1236
1181
  if (trigger[key] !== undefined && trigger[key] !== null) {
1237
1182
  out.push(`the workflow narrows its trigger with ${key}; this check must run on every pull request`);
1238
1183
  }
@@ -1245,41 +1190,39 @@ function workflowStructure(document) {
1245
1190
  for (const [jobName, job] of Object.entries(jobs)) {
1246
1191
  for (const step of Array.isArray(job?.steps) ? job.steps : []) {
1247
1192
  const runs = typeof step?.run === 'string' ? step.run : '';
1248
- if (runs.includes(CHECK_PATH)) found = { jobName, job, step, runs };
1193
+ if (runs.includes(GATE_RUNNER.runs)) found = { jobName, job, step, runs, runner: GATE_RUNNER };
1249
1194
  }
1250
1195
  }
1251
1196
  if (!found) {
1252
- out.push(`no step in this workflow runs "node ${CHECK_PATH}", so the job reports nothing`);
1197
+ out.push(`no step in this workflow runs "node ${CHECK_PATH} --gate", so the job reports nothing`);
1253
1198
  return out;
1254
1199
  }
1255
1200
 
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']) {
1201
+ // The flags. Without them the check still runs and still goes green, and the
1202
+ // no-loss rule never runs at all, or it scores the wrong tree. Deleting a
1203
+ // flag is the cheapest way to silence the job, so the scanner reads the flags,
1204
+ // not just the path (finding S2-1).
1205
+ for (const flag of found.runner.flags) {
1265
1206
  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`);
1207
+ out.push(`the check step does not pass ${flag}, so the no-loss rule cannot judge the pull request from the base`);
1267
1208
  }
1268
1209
  }
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`);
1210
+ // Every checkout is of the base commit and keeps no credential. A checkout
1211
+ // of the head would run the head's copy of the check: the hole this
1212
+ // workflow exists to close.
1213
+ const checkouts = (Array.isArray(found.job?.steps) ? found.job.steps : [])
1214
+ .filter((step) => typeof step?.uses === 'string' && step.uses.startsWith('actions/checkout'));
1215
+ if (!checkouts.length) out.push('no step checks the base commit out, so there is no base copy of the check to run');
1216
+ for (const step of checkouts) {
1217
+ 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');
1218
+ if (step?.with?.['persist-credentials'] !== false) out.push('a checkout step keeps its credential; set persist-credentials: false');
1274
1219
  }
1275
1220
 
1276
1221
  // A condition switches the step, or its whole job, off. Same effect as a
1277
1222
  // narrowed trigger, by another route.
1278
1223
  if (found.step.if !== undefined) out.push('the workflow puts an if: on the check step; the step must always run');
1279
1224
  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).
1225
+ // Two more ways to make a red check look green (reviewer of batch 16, S2-1).
1283
1226
  if (found.step['continue-on-error'] === true || found.job['continue-on-error'] === true) {
1284
1227
  out.push('the workflow sets continue-on-error on the check; a failed check would not turn the job red');
1285
1228
  }
@@ -1293,8 +1236,11 @@ function checkWorkflow(context, part) {
1293
1236
  const text = readText(context.root, part.path);
1294
1237
  if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
1295
1238
  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');
1239
+ // pull_request_target takes this file from the base branch. On pull_request,
1240
+ // GitHub runs the copy the pull request carries, and the pull request can
1241
+ // change its own judge (roadmap R7).
1242
+ 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');
1243
+ 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
1244
  // The structure, from the parsed document. A workflow this check cannot read
1299
1245
  // is a finding: it can no longer say the job runs at all.
1300
1246
  try {
@@ -1372,10 +1318,6 @@ const CHECKS = {
1372
1318
  'facts-section': checkFactsSection,
1373
1319
  'facts-credentials': checkCredentials,
1374
1320
  'docs-index': checkDocsIndex,
1375
- 'docs-pages': checkDocsPages,
1376
- features: checkFeatures,
1377
- notes: checkNotes,
1378
- generated: checkGenerated,
1379
1321
  workflow: checkWorkflow,
1380
1322
  'check-file': checkCheckFile,
1381
1323
  linters: checkLinters,
@@ -1383,7 +1325,7 @@ const CHECKS = {
1383
1325
 
1384
1326
  // The one exemption in the by-reference set, and it is exactly one.
1385
1327
  //
1386
- // File 1, the entry map, must hold a pointer to each of the seven files, and
1328
+ // File 1, the entry map, must hold a pointer to each of the six files, and
1387
1329
  // `docs/index.md` is one of them. Rule R1 makes any `docs/` file an instruction
1388
1330
  // file points at instruction class by reference. Those two together make the
1389
1331
  // index instruction class, and the frozen L4 path limit lets the upkeep routine
@@ -1397,8 +1339,7 @@ const CHECKS = {
1397
1339
  // only this one path, only these two pointing files, and the row stays in the
1398
1340
  // report with `exempt: true` so nothing is hidden. Any other instruction file
1399
1341
  // that names the index still makes it instruction class, and the index's own
1400
- // row then carries that file. The docs lint of slice L3 bounds the rest, by
1401
- // treating the index as an agent-facing surface.
1342
+ // row then carries that file.
1402
1343
  export const EXEMPT_PATH = 'docs/index.md';
1403
1344
  export const EXEMPT_FROM = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
1404
1345
 
@@ -1484,7 +1425,7 @@ export function readFacts(root) {
1484
1425
  // `kind: library` would be two different spec tables, and the comparison would
1485
1426
  // mean nothing (architect finding P2). Nothing else about the scan changes: the
1486
1427
  // tree still speaks for itself about every part.
1487
- export function scan({ root, repoId = null, extractors = [], secretPatterns: patterns = secretPatterns(), scoreAs = null }) {
1428
+ export function scan({ root, repoId = null, secretPatterns: patterns = secretPatterns(), scoreAs = null }) {
1488
1429
  const { facts, error: factsError } = readFacts(root);
1489
1430
  const errors = [];
1490
1431
  // The credential-value check is the only thing that turns the job red at the
@@ -1511,7 +1452,12 @@ export function scan({ root, repoId = null, extractors = [], secretPatterns: pat
1511
1452
  if (spec && !spec.kinds[kind]) { errors.push(`atlas.yaml names kind "${kind}"; spec ${spec.spec} knows ${Object.keys(spec.kinds).join(', ')}`); spec = null; }
1512
1453
  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
1454
 
1514
- const context = { root, facts, factsError, repoId, extractors, secretPatterns: usable, errors };
1455
+ // The no-loss rule can score a tree at another tree's pointers: the paths
1456
+ // that atlas.yaml names for the procedures and the facts files. So a part
1457
+ // that a pull request moves is also scored where the base had it (roadmap R7
1458
+ // line 3).
1459
+ const pointed = scoreAs?.pointers ? { ...facts, files: scoreAs.pointers.files, procedures: scoreAs.pointers.procedures } : facts;
1460
+ const context = { root, facts: pointed, factsError, repoId, secretPatterns: usable, errors };
1515
1461
  // atlas.yaml's own values reach the report: the kind, the repo id, a
1516
1462
  // procedure's skill name, every pointer path. Scan it before any of them is
1517
1463
  // quoted (acceptance review of batch 16, note S3-2).
@@ -1534,7 +1480,7 @@ export function scan({ root, repoId = null, extractors = [], secretPatterns: pat
1534
1480
  }
1535
1481
 
1536
1482
  export function counts(files) {
1537
- const out = { filled: 0, empty: 0, stale: 0, 'not applicable': 0, 'no extractor': 0, required: 0, optional: 0, not_filled: 0, required_not_filled: 0, optional_not_filled: 0 };
1483
+ const out = { filled: 0, empty: 0, stale: 0, 'not applicable': 0, required: 0, optional: 0, not_filled: 0, required_not_filled: 0, optional_not_filled: 0 };
1538
1484
  for (const file of files) for (const part of file.parts) {
1539
1485
  out[part.state] += 1;
1540
1486
  if (part.required === 'required') out.required += 1;
@@ -1568,21 +1514,30 @@ export function counts(files) {
1568
1514
  // four required parts into "not applicable" and the count falls. Scoring per
1569
1515
  // part, with the base tree's `kind` and `spec`, closes both (findings P2, P20).
1570
1516
  //
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).
1517
+ // Which copy scores. The base's, from check version 6 (roadmap R7). The gate
1518
+ // job runs on pull_request_target, so it runs this file as the base holds it,
1519
+ // over two trees written from git objects. Before that, the head's copy judged
1520
+ // its own diff, and a pull request could edit the check to print GREEN. The
1521
+ // two report-only cases for the check file and its workflow covered that hole
1522
+ // with a warning that a person merges the file. Under merge on green, no
1523
+ // person reads that warning. Now a change to either gives "owner" and the rule
1524
+ // still gates.
1525
+ //
1526
+ // A run by hand, or an old workflow, still runs the head's copy. Its answer
1527
+ // is a report. The gate's answer is the one a merge waits on.
1577
1528
  //
1578
1529
  // Where it lives. Here. The repository still carries two files and only two.
1579
1530
  // ---------------------------------------------------------------------------
1580
1531
 
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']);
1532
+ // The cases that give "owner". Only the owner may approve such a change, and
1533
+ // the rule still gates: it still finds any part that went backwards. Before
1534
+ // check version 6 five cases switched the rule off and printed a warning that
1535
+ // a person merges the file. Nothing switches it off now. The base copy scores,
1536
+ // and the base kind and spec score both trees, so no case needs to.
1537
+ export const OWNER_CASES = Object.freeze(['check-file', 'workflow', 'kind', 'spec', 'moved', 'first-run']);
1584
1538
 
1585
1539
  const fileHash = (root, path) => { const text = readText(root, path); return text === null ? null : sha256(text); };
1540
+ const isEmptyDir = (root) => { try { return !readdirSync(root).length; } catch { return true; } };
1586
1541
 
1587
1542
  // The commit a checkout sits on, read from `.git/HEAD` as text. No git binary,
1588
1543
  // no network. `actions/checkout` leaves a detached HEAD, so the first line is
@@ -1610,45 +1565,48 @@ const partIndex = (report) => {
1610
1565
  return map;
1611
1566
  };
1612
1567
 
1613
- export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secretPatterns: patterns = secretPatterns() }) {
1568
+ // `baseCommit` is the commit the caller wrote the base tree from, when it
1569
+ // wrote the tree itself from git objects, as the gate does. Such a tree has
1570
+ // no `.git`, and the caller is the base code, so its word is the tree's name.
1571
+ export function noLoss({ headRoot, baseRoot, baseSha = null, baseCommit = null, repoId = null, secretPatterns: patterns = secretPatterns() }) {
1614
1572
  const out = {
1615
1573
  ran: true,
1616
- gates: true,
1617
1574
  green: true,
1575
+ broken: null,
1618
1576
  base_dir: baseRoot,
1619
1577
  base_sha: baseSha,
1620
1578
  base_head: null,
1621
1579
  base_facts: null,
1622
1580
  head_facts: null,
1623
1581
  parts_compared: 0,
1624
- report_only: [],
1582
+ owner: [],
1625
1583
  regressions: [],
1626
1584
  };
1627
- const excuse = (name, why) => { out.gates = false; out.report_only.push({ case: name, why }); };
1585
+ const owner = (name, why, extra = {}) => out.owner.push({ case: name, why, ...extra });
1586
+ // A missing or wrong base tree is red, never green: there is nothing to
1587
+ // compare, and a rule that cannot compare cannot say nothing was lost
1588
+ // (roadmap R7 line 4).
1589
+ const broken = (why) => { out.broken = why; out.green = false; return out; };
1628
1590
 
1629
1591
  // 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);
1592
+ if (!existsSync(baseRoot) || isEmptyDir(baseRoot)) return broken(`no base tree at ${baseRoot}: the base commit did not land, so there is nothing to compare`);
1593
+ out.base_head = baseCommit && PINNED.test(baseCommit) ? baseCommit : checkoutHead(baseRoot);
1640
1594
  // The pull request promised a base commit. If the tree cannot say which
1641
1595
  // commit it is, the promise is unchecked, and an unchecked promise is not a
1642
1596
  // 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`);
1597
+ // claim it cannot support.
1598
+ 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`);
1599
+ 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`);
1600
+ // A base with no atlas.yaml is the first run in a repo: the pull request that
1601
+ // adds Atlas. There is nothing to lose, and the owner reads the first shape.
1602
+ const baseRead = readFacts(baseRoot);
1603
+ if (!baseRead.facts && !baseRead.error) {
1604
+ owner('first-run', 'the base has no atlas.yaml, so there is nothing to lose; the owner reads the first shape');
1650
1605
  return out;
1651
1606
  }
1607
+ // A base atlas.yaml that does not read cannot name a kind or a spec, so
1608
+ // there is no yardstick. That is red. The owner merges the fix by hand.
1609
+ if (baseRead.error) return broken(`the base atlas.yaml does not read, so its kind and spec are unknown: ${baseRead.error}`);
1652
1610
 
1653
1611
  // 2. The base tree's kind and spec score both trees.
1654
1612
  const scoreAs = { kind: baseRead.facts.kind ?? null, spec: baseRead.facts.spec ?? null };
@@ -1657,28 +1615,30 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secr
1657
1615
  out.head_facts = { kind: headRead.facts?.kind ?? null, spec: headRead.facts?.spec ?? null };
1658
1616
 
1659
1617
  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`);
1618
+ 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
1619
  }
1662
1620
  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`);
1621
+ 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
1622
  }
1665
1623
 
1666
- // 3. Does the diff change the gate itself?
1667
- for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH]]) {
1624
+ // 3. Does the diff change the gate itself? The base copy judges, so the
1625
+ // change cannot score itself, but only the owner may approve it.
1626
+ for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH], ['workflow', OLD_WORKFLOW_PATH]]) {
1668
1627
  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`);
1628
+ owner(name, `this pull request changes ${path}, which is the gate itself; it is instruction class, so only the owner approves it`, { path });
1670
1629
  }
1671
1630
  }
1672
1631
 
1673
- // 4. Score both trees, by the same yardstick, and compare part by part.
1632
+ // 4. Score both trees, by the same yardstick, and compare part by part. The
1633
+ // head is scored twice: at its own pointers, and at the base's.
1674
1634
  const base = scan({ root: baseRoot, repoId, secretPatterns: patterns, scoreAs });
1675
1635
  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
- }
1636
+ const pointers = { files: baseRead.facts.files, procedures: baseRead.facts.procedures };
1637
+ const headAtBase = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs: { ...scoreAs, pointers } });
1638
+ if (!base.files.length) return broken(`the base tree at ${baseRoot} could not be scored: ${base.errors.join('; ') || 'no parts came back'}`);
1680
1639
 
1681
1640
  const headParts = partIndex(head);
1641
+ const atBaseParts = partIndex(headAtBase);
1682
1642
  for (const [key, { file, part }] of partIndex(base)) {
1683
1643
  if (file.class !== 'instruction' || part.state !== 'filled') continue;
1684
1644
  out.parts_compared += 1;
@@ -1689,18 +1649,57 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secr
1689
1649
  }
1690
1650
  if (now.part.state !== 'filled') {
1691
1651
  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` });
1652
+ continue;
1692
1653
  }
1654
+ // A moved part: the head points it somewhere else. The head is filled
1655
+ // at its new pointer, or the part would be a regression above. The owner
1656
+ // reads every move, and the answer says whether the base path still holds
1657
+ // the part in the head (roadmap R7 line 3).
1658
+ const from = part.path;
1659
+ const to = now.part.path;
1660
+ if (!from || !to || from === to) continue;
1661
+ const kept = atBaseParts.get(key)?.part.state === 'filled' ? `; ${from} still holds it` : `; ${from} no longer holds it`;
1662
+ owner('moved', `${label(part)} moved from ${from} to ${to}${kept}`, { path: to });
1693
1663
  }
1694
1664
  out.green = !out.regressions.length;
1695
1665
  return out;
1696
1666
  }
1697
1667
 
1668
+ // The answer of the shape rule for the gate: success, owner or red, with one
1669
+ // row for each finding. Red wins over owner. The gate job and `atlas gate
1670
+ // check` both take it from here, so the two cannot disagree.
1671
+ export const GATE_RESULTS = Object.freeze(['success', 'owner', 'red']);
1672
+ export function gateAnswer(report, state) {
1673
+ const rows = [];
1674
+ for (const error of report.errors) rows.push({ kind: 'red', class: 'shape', cause: `the check hit an error at the head: ${error}` });
1675
+ if (state?.broken) rows.push({ kind: 'red', class: 'shape', cause: state.broken });
1676
+ for (const row of state?.regressions ?? []) rows.push({ kind: 'red', class: 'shape', cause: row.why });
1677
+ for (const row of state?.owner ?? []) rows.push({ kind: 'owner', class: 'shape', cause: `${row.case}: ${row.why}`, ...(row.path ? { path: row.path } : {}) });
1678
+ if (!state) rows.push({ kind: 'red', class: 'shape', cause: 'the no-loss rule did not run: no base tree was named' });
1679
+ const result = rows.some((row) => row.kind === 'red') ? 'red' : rows.some((row) => row.kind === 'owner') ? 'owner' : 'success';
1680
+ return { result, class: result === 'success' ? null : 'shape', rows };
1681
+ }
1682
+
1683
+ // One line for each row, with a fixed prefix, and no control character, so a
1684
+ // path cannot start a workflow command.
1685
+ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
1686
+ export function gateLines(answer, state) {
1687
+ const base = state?.base_head ? state.base_head.slice(0, 12) : 'none';
1688
+ const yard = state?.base_facts ? `kind ${oneLine(state.base_facts.kind)}, spec ${oneLine(state.base_facts.spec)}` : 'no yardstick';
1689
+ const out = [`shape: base ${base}, ${yard}, ${state?.parts_compared ?? 0} instruction-class part(s) filled at the base`];
1690
+ for (const row of answer.rows) out.push(` ${row.kind.padEnd(6)} ${row.class.padEnd(6)} ${oneLine(row.cause)}`);
1691
+ if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
1692
+ else if (answer.result === 'owner') out.push("OWNER: shape. Clears with the owner's signed act: atlas approve <pr> (roadmap R72).");
1693
+ else out.push('SUCCESS: no instruction-class part went backwards.');
1694
+ return out;
1695
+ }
1696
+
1698
1697
  // ---------------------------------------------------------------------------
1699
1698
  // The report. Two shapes: plain text for the CI log, and GitHub markdown for
1700
1699
  // the job summary. Both list every part, its state and its findings.
1701
1700
  // ---------------------------------------------------------------------------
1702
1701
 
1703
- const MARK = { filled: '+', empty: '-', stale: '~', 'not applicable': '.', 'no extractor': '?' };
1702
+ const MARK = { filled: '+', empty: '-', stale: '~', 'not applicable': '.' };
1704
1703
  const label = (part) => part.procedure ? `procedure ${part.procedure}` : part.path ?? part.id;
1705
1704
 
1706
1705
  // The one number the owner sees is the count of REQUIRED files not filled.
@@ -1748,23 +1747,13 @@ export function noLossLines(state) {
1748
1747
  out.push(` base ${read} in ${state.base_dir}${promised}`);
1749
1748
  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
1749
  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}`);
1750
+ if (state.broken) out.push(` NO BASE TREE: ${state.broken}`);
1751
+ for (const row of state.owner ?? []) out.push(` OWNER (${row.case}): ${row.why}`);
1752
1752
  for (const row of state.regressions) {
1753
1753
  out.push(` BACKWARDS ${row.label}: ${row.was} at the base, ${row.now} at the head`);
1754
1754
  for (const finding of row.findings ?? []) out.push(` - ${finding}`);
1755
1755
  }
1756
1756
  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
1757
  return out;
1769
1758
  }
1770
1759
 
@@ -1808,15 +1797,14 @@ export function toMarkdown(report) {
1808
1797
  export function verdict(report, failOn) {
1809
1798
  if (report.errors.length) return { green: false, why: `the check hit ${report.errors.length} error(s)` };
1810
1799
  const state = report.noLoss;
1811
- if (state?.gates && state.regressions.length) {
1800
+ if (state?.broken) return { green: false, why: `the no-loss rule has no base tree to compare: ${state.broken}` };
1801
+ if (state?.regressions.length) {
1812
1802
  return { green: false, why: `${state.regressions.length} instruction-class part(s) went backwards against the base: ${state.regressions.map((row) => row.label).join(', ')}` };
1813
1803
  }
1814
1804
  if (failOn === 'required' && report.counts.required_not_filled) return { green: false, why: `${report.counts.required_not_filled} required file(s) are not filled` };
1815
1805
  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}`) : '';
1806
+ 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"` : '';
1807
+ const noLossWhy = state ? `; no instruction-class part went backwards${owned}` : '';
1820
1808
  return { green: true, why: `${report.counts.required_not_filled} required file(s) are not filled; fail-on is "${failOn}"${noLossWhy}` };
1821
1809
  }
1822
1810
 
@@ -1825,10 +1813,9 @@ export function verdict(report, failOn) {
1825
1813
  // ---------------------------------------------------------------------------
1826
1814
 
1827
1815
  export const FAIL_ON = Object.freeze(['error', 'required', 'any']);
1828
- export const EXTRACTORS = Object.freeze([]); // Slice L3 adds routes, migrations and mcp.
1829
1816
 
1830
1817
  export function parseArguments(argv) {
1831
- const out = { root: process.cwd(), failOn: 'error', json: null, summary: null, quiet: false, base: null, baseSha: null };
1818
+ 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
1819
  for (let index = 0; index < argv.length; index += 1) {
1833
1820
  const flag = argv[index];
1834
1821
  const next = () => { const value = argv[index + 1]; if (value === undefined) throw new Error(`${flag} needs a value`); index += 1; return value; };
@@ -1842,24 +1829,35 @@ export function parseArguments(argv) {
1842
1829
  // the check can say the tree it reads is the tree it was promised.
1843
1830
  else if (flag === '--base') out.base = resolve(next());
1844
1831
  else if (flag === '--base-sha') out.baseSha = next().trim() || null;
1832
+ // Gate mode (roadmap R7). The gate job wrote the base tree from git
1833
+ // objects, so it names the commit; the answer is success, owner or red.
1834
+ else if (flag === '--base-commit') out.baseCommit = next().trim() || null;
1835
+ else if (flag === '--head-commit') out.headCommit = next().trim() || null;
1836
+ else if (flag === '--git') out.git = resolve(next());
1837
+ else if (flag === '--gate') out.gate = true;
1845
1838
  else if (flag === '--repo') out.repo = next();
1846
1839
  else if (flag === '--version') out.version = true;
1847
1840
  else throw new Error(`unknown argument ${flag}`);
1848
1841
  }
1849
1842
  if (!FAIL_ON.includes(out.failOn)) throw new Error(`--fail-on must be one of ${FAIL_ON.join(', ')}`);
1843
+ for (const [flag, value] of [['--base-commit', out.baseCommit], ['--head-commit', out.headCommit]]) {
1844
+ if (value && !PINNED.test(value)) throw new Error(`${flag} must be a full 40-character commit SHA`);
1845
+ }
1846
+ if (out.gate && (!out.baseCommit || !out.headCommit)) throw new Error('--gate needs --base-commit and --head-commit');
1850
1847
  return out;
1851
1848
  }
1852
1849
 
1853
1850
  export function run(argv, env = process.env) {
1854
1851
  const options = parseArguments(argv);
1852
+ if (options.gate) return runGate({ options, env });
1855
1853
  const repoId = options.repo ?? env.GITHUB_REPOSITORY ?? null;
1856
1854
  const patterns = secretPatterns();
1857
- const report = scan({ root: options.root, repoId, extractors: EXTRACTORS, secretPatterns: patterns });
1855
+ const report = scan({ root: options.root, repoId, secretPatterns: patterns });
1858
1856
  // No `--base` means no pull request to measure: a push build, or a run by
1859
1857
  // hand. The no-loss rule then does not run at all, and the report says nothing
1860
1858
  // about it rather than claiming a pass.
1861
1859
  report.noLoss = options.base
1862
- ? noLoss({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, repoId, secretPatterns: patterns })
1860
+ ? noLoss({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, baseCommit: options.baseCommit, repoId, secretPatterns: patterns })
1863
1861
  : null;
1864
1862
  const decision = verdict(report, options.failOn);
1865
1863
  const text = toText(report);
@@ -1870,6 +1868,144 @@ export function run(argv, env = process.env) {
1870
1868
  return { report, decision, text, options };
1871
1869
  }
1872
1870
 
1871
+ // ---------------------------------------------------------------------------
1872
+ // The two trees of the gate (roadmap R7). Each one is written from git objects:
1873
+ // `git ls-tree` lists the entries and `git cat-file` gives each blob, so no
1874
+ // attribute of the tree applies. `git archive` would apply the head's own
1875
+ // .gitattributes (export-subst, export-ignore, a filter), and the check would
1876
+ // score bytes that do not merge (security review of R7, finding S1).
1877
+ //
1878
+ // Each blob is checked against its id. A path must be plain: no empty, "." or
1879
+ // ".." part, no ".git" part, no leading slash and no control character. A link
1880
+ // or a submodule is listed and never written, so the check never follows one.
1881
+ // ---------------------------------------------------------------------------
1882
+
1883
+ export class TreeError extends Error {}
1884
+
1885
+ export const TREE_LIMITS = Object.freeze({ files: 200_000, bytes: 1024 * 1024 * 1024 });
1886
+
1887
+ function gitRun(git, args, input = null) {
1888
+ const r = spawnSync('git', ['-C', git, ...args], { input, maxBuffer: TREE_LIMITS.bytes + 64 * 1024 * 1024 });
1889
+ if (r.error) throw new TreeError(`git cannot run: ${r.error.message}`);
1890
+ if (r.status !== 0) throw new TreeError(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
1891
+ return r.stdout;
1892
+ }
1893
+
1894
+ const PLAIN_PART = /^(?!\.\.?$)(?!\.git$)[^\u0000-\u001f\u007f/\\]+$/;
1895
+ export const plainPath = (path) => typeof path === 'string' && path.length > 0 && path.split('/').every((part) => PLAIN_PART.test(part));
1896
+
1897
+ // The entries of one commit's tree: blobs, links and submodules.
1898
+ export function treeEntries(git, commit) {
1899
+ const out = { blobs: [], links: [], modules: [] };
1900
+ const seen = new Set();
1901
+ for (const row of gitRun(git, ['ls-tree', '-r', '-z', '--full-tree', commit]).toString('utf8').split('\0')) {
1902
+ if (!row) continue;
1903
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64})\t([\s\S]+)$/.exec(row);
1904
+ if (!match) throw new TreeError('git ls-tree printed a row this check cannot read');
1905
+ const [, mode, type, id, path] = match;
1906
+ if (!plainPath(path)) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds a path that is not plain`);
1907
+ if (seen.has(path)) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds one path twice`);
1908
+ seen.add(path);
1909
+ if (mode === '120000') out.links.push({ path, id });
1910
+ else if (type === 'commit') out.modules.push({ path, id });
1911
+ else if (type === 'blob') out.blobs.push({ path, id });
1912
+ else throw new TreeError(`the tree of ${commit.slice(0, 12)} holds an entry of type ${type}`);
1913
+ }
1914
+ return out;
1915
+ }
1916
+
1917
+ const blobId = (bytes, length) => createHash(length === 64 ? 'sha256' : 'sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
1918
+
1919
+ // Write the blobs of one commit under `dest`. Returns the entries.
1920
+ export function writeTree({ git, commit, dest }) {
1921
+ const entries = treeEntries(git, commit);
1922
+ if (entries.blobs.length > TREE_LIMITS.files) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds more than ${TREE_LIMITS.files} files`);
1923
+ const output = entries.blobs.length ? gitRun(git, ['cat-file', '--batch'], `${entries.blobs.map((blob) => blob.id).join('\n')}\n`) : Buffer.alloc(0);
1924
+ let at = 0;
1925
+ let total = 0;
1926
+ for (const blob of entries.blobs) {
1927
+ const end = output.indexOf(0x0a, at);
1928
+ const header = output.subarray(at, end).toString('utf8');
1929
+ const match = /^([0-9a-f]{40,64}) blob (\d+)$/.exec(header);
1930
+ if (!match || match[1] !== blob.id) throw new TreeError(`git cat-file did not give the blob of ${blob.path}`);
1931
+ const size = Number(match[2]);
1932
+ total += size;
1933
+ if (total > TREE_LIMITS.bytes) throw new TreeError(`the tree of ${commit.slice(0, 12)} is over ${TREE_LIMITS.bytes} bytes`);
1934
+ const bytes = output.subarray(end + 1, end + 1 + size);
1935
+ if (blobId(bytes, blob.id.length) !== blob.id) throw new TreeError(`the bytes of ${blob.path} are not its blob`);
1936
+ const full = join(dest, ...blob.path.split('/'));
1937
+ mkdirSync(dirname(full), { recursive: true });
1938
+ writeFileSync(full, bytes, { flag: 'wx' });
1939
+ at = end + 1 + size + 1;
1940
+ }
1941
+ return entries;
1942
+ }
1943
+
1944
+ const commitOf = (git, ref) => {
1945
+ const r = spawnSync('git', ['-C', git, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' });
1946
+ return r.status === 0 ? r.stdout.trim() : null;
1947
+ };
1948
+
1949
+ // The shape rule over git data: the base commit is the tip of the base branch,
1950
+ // the head commit is the pull request's head. Both trees come from the merge
1951
+ // base and the head. Any step that fails is red.
1952
+ export function gateFromGit({ git, baseCommit, headCommit, repoId = null, trees = null, secretPatterns: patterns = secretPatterns() }) {
1953
+ const red = (why) => ({ report: { errors: [], files: [], counts: counts([]) }, state: { broken: why, owner: [], regressions: [], parts_compared: 0, base_head: null, base_facts: null } });
1954
+ const base = commitOf(git, baseCommit);
1955
+ const head = commitOf(git, headCommit);
1956
+ if (!base) return red(`the base commit ${String(baseCommit).slice(0, 12)} is not in this clone`);
1957
+ if (!head) return red(`the head commit ${String(headCommit).slice(0, 12)} is not in this clone`);
1958
+ let mergeBase;
1959
+ 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'); }
1960
+ const top = trees ?? mkdtempSync(join(tmpdir(), 'atlas-gate-'));
1961
+ const baseRoot = join(top, GATE_TREES.base);
1962
+ const headRoot = join(top, GATE_TREES.head);
1963
+ try {
1964
+ let baseEntries;
1965
+ let headEntries;
1966
+ try {
1967
+ baseEntries = writeTree({ git, commit: mergeBase, dest: baseRoot });
1968
+ headEntries = writeTree({ git, commit: head, dest: headRoot });
1969
+ } catch (error) {
1970
+ if (error instanceof TreeError) return red(`a tree could not be written: ${error.message}`);
1971
+ throw error;
1972
+ }
1973
+ mkdirSync(baseRoot, { recursive: true });
1974
+ mkdirSync(headRoot, { recursive: true });
1975
+ const report = scan({ root: headRoot, repoId, secretPatterns: patterns });
1976
+ const state = noLoss({ headRoot, baseRoot, baseCommit: mergeBase, repoId, secretPatterns: patterns });
1977
+ // A link or a submodule that the head adds or changes is never read, so the
1978
+ // check cannot judge it. The owner reads it.
1979
+ const had = new Map([...baseEntries.links, ...baseEntries.modules].map((entry) => [entry.path, entry.id]));
1980
+ for (const entry of [...headEntries.links, ...headEntries.modules]) {
1981
+ 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 });
1982
+ }
1983
+ return { report, state, merge_base: mergeBase, head };
1984
+ } finally {
1985
+ if (!trees) rmSync(top, { recursive: true, force: true });
1986
+ }
1987
+ }
1988
+
1989
+ // Gate mode. The job summary holds the full report and the answer. The output
1990
+ // file gets two keys only, each one word of a fixed set, so no path can add a
1991
+ // key. Exit 0 on success, 1 on owner or red.
1992
+ function runGate({ options, env }) {
1993
+ const { report, state } = gateFromGit({ git: options.git, baseCommit: options.baseCommit, headCommit: options.headCommit, repoId: options.repo ?? env.GITHUB_REPOSITORY ?? null });
1994
+ const answer = gateAnswer(report, state);
1995
+ // Only the answer lines leave this process. Each one has a fixed prefix and
1996
+ // no control character or backtick, so a head string cannot start a
1997
+ // workflow command or close the fence of the summary (security review of
1998
+ // R7). The full report stays in the JSON file, for a run by hand.
1999
+ const lines = gateLines(answer, state).map((line) => line.replaceAll('`', "'"));
2000
+ if (options.json) writeFileSync(options.json, `${JSON.stringify({ ...report, noLoss: state, gate: answer }, null, 2)}\n`);
2001
+ const summary = `## Atlas repo shape, from the base\n\n${['```text', ...lines, '```'].join('\n')}\n`;
2002
+ if (options.summary) writeFileSync(options.summary, summary);
2003
+ if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
2004
+ if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `result=${answer.result}\nclass=${answer.class ?? 'none'}\n`);
2005
+ const decision = { green: answer.result === 'success', why: lines.at(-1) };
2006
+ return { report, decision, text: `${lines.join('\n')}\n`, lines, options, answer, gate: true };
2007
+ }
2008
+
1873
2009
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1874
2010
  try {
1875
2011
  // --version is parsed like every other flag, so an unknown flag beside it
@@ -1878,9 +2014,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
1878
2014
  if (asked.version) {
1879
2015
  process.stdout.write(`atlas repo-shape check, check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}\n`);
1880
2016
  } 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`);
2017
+ const { decision, text, options, gate } = run(process.argv.slice(2));
2018
+ if (!options.quiet || gate) process.stdout.write(text);
2019
+ // In gate mode the last line of the text already says SUCCESS, OWNER or RED.
2020
+ if (!gate) process.stdout.write(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}\n`);
1884
2021
  process.exitCode = decision.green ? 0 : 1;
1885
2022
  }
1886
2023
  } catch (error) {