@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/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +2 -1
- package/door/lib/doc-sources.mjs +0 -1
- package/door/lib/kind-drift.mjs +1 -1
- package/door/lib/pull-request.mjs +18 -7
- package/door/lib/status.mjs +24 -9
- package/door/lib/tooling.mjs +28 -6
- package/door/lib/upkeep.mjs +1 -1
- package/package.json +1 -1
- package/shape/check.mjs +442 -305
- package/shape/releases.json +11 -0
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
|
|
13
|
-
// empty, stale, not applicable
|
|
14
|
-
//
|
|
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 =
|
|
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":
|
|
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-
|
|
421
|
-
"holds": "The
|
|
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
|
|
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
|
|
506
|
-
//
|
|
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
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
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-
|
|
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
|
|
653
|
-
// whose name starts with a dot, and every other
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
#
|
|
665
|
-
#
|
|
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
|
-
#
|
|
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
|
|
670
|
-
#
|
|
671
|
-
#
|
|
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
|
-
#
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
|
687
|
-
#
|
|
688
|
-
|
|
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
|
|
691
|
-
- uses: actions/checkout@
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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'
|
|
894
|
-
export const NOT_FILLED = Object.freeze(['empty', 'stale'
|
|
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
|
|
936
|
-
const found = symlinks(root, path)
|
|
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
|
|
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
|
-
|
|
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(
|
|
1030
|
-
return result(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
|
|
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
|
|
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
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
|
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
|
-
|
|
1297
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
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
|
|
1582
|
-
// the
|
|
1583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1582
|
+
owner: [],
|
|
1625
1583
|
regressions: [],
|
|
1626
1584
|
};
|
|
1627
|
-
const
|
|
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
|
-
|
|
1631
|
-
|
|
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
|
|
1644
|
-
if (baseSha && !out.base_head) {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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': '.'
|
|
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
|
-
|
|
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?.
|
|
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
|
|
1817
|
-
|
|
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,
|
|
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
|
-
|
|
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) {
|