@arjunkhera/atlas 0.0.0 → 0.2.1
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 +18 -0
- package/.mcp.json +18 -0
- package/README.md +32 -1
- package/agents/artifact-format/example.html +71 -0
- package/agents/artifact-format/system.css +273 -0
- package/agents/artifact-renderer.md +132 -0
- package/agents/capability-reader.md +100 -0
- package/agents/code-explorer.md +108 -0
- package/agents/reviewer-architect.md +78 -0
- package/agents/reviewer-pm.md +81 -0
- package/agents/reviewer-security.md +82 -0
- package/bin/atlas +5 -0
- package/door/cli.mjs +280 -0
- package/door/lib/helpers.mjs +139 -0
- package/door/lib/install.mjs +260 -0
- package/door/lib/kind-drift.mjs +76 -0
- package/door/lib/onboarding.mjs +130 -0
- package/door/lib/pull-request.mjs +119 -0
- package/door/lib/releases.mjs +42 -0
- package/door/lib/status.mjs +47 -0
- package/door/lib/tooling.mjs +119 -0
- package/package.json +24 -3
- package/shape/check.mjs +1797 -0
- package/shape/releases.json +15 -0
- package/skills/sdlc-task/SKILL.md +174 -0
- package/skills/sdlc-task/lifecycle.yaml +27 -0
- package/skills/sdlc-task/templates/design-doc.md +118 -0
- package/skills/sdlc-task/templates/resume-anchor.md +25 -0
- package/work/code-read.mjs +65 -0
- package/work/lib/action-store.mjs +84 -0
- package/work/lib/capability.mjs +487 -0
- package/work/lib/child-env.mjs +11 -0
- package/work/lib/client.mjs +214 -0
- package/work/lib/code-read.mjs +238 -0
- package/work/lib/delivery.mjs +624 -0
- package/work/lib/mcp-stdio.mjs +68 -0
- package/work/lib/questions.mjs +278 -0
- package/work/lib/sweep.mjs +189 -0
- package/work/lib/verb-fields.mjs +53 -0
- package/work/lib/verbs.mjs +511 -0
- package/work/manifest-0.5.0.json +609 -0
- package/work/mcp.mjs +216 -0
- package/work/protected-paths.json +38 -0
package/shape/check.mjs
ADDED
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Atlas repo-shape check. Milestone M2, slice L1, design pages D1 and D4.
|
|
3
|
+
//
|
|
4
|
+
// This is the whole check, in one file. The package writes this file into a
|
|
5
|
+
// repo at .github/atlas/check.mjs, and a workflow there runs it on every pull
|
|
6
|
+
// request. Nothing else is needed. It holds the spec, the scanners, the YAML
|
|
7
|
+
// reader, the secret patterns and the report. It imports Node builtins only.
|
|
8
|
+
//
|
|
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.
|
|
11
|
+
//
|
|
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.
|
|
15
|
+
//
|
|
16
|
+
// It carries the version of the Atlas package it came from, so a copy in a
|
|
17
|
+
// repo says where it came from. It refuses a spec version it cannot read.
|
|
18
|
+
//
|
|
19
|
+
// Authority: the Atlas M2 study, design page D1 with its
|
|
20
|
+
// 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 { fileURLToPath } from 'node:url';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
// node:crypto is a builtin, so the check still imports nothing but builtins.
|
|
27
|
+
export const sha256 = (text) => createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex');
|
|
28
|
+
|
|
29
|
+
// The version of this check, as a whole number. It moves only when this file's
|
|
30
|
+
// own code changes, never with the Atlas package version (row F11, amendment
|
|
31
|
+
// A1.9). A release of Atlas that leaves the check alone leaves every repo's copy
|
|
32
|
+
// current, so the ratchet's check-file case does not fire on every release.
|
|
33
|
+
//
|
|
34
|
+
// Whether a copy is current or behind is not decided here. CI has no package
|
|
35
|
+
// and no network. The session verb decides it, from the release list that
|
|
36
|
+
// ships beside this file in the package, shape/releases.json.
|
|
37
|
+
export const CHECK_VERSION = 2;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// The spec. One object per version. The check reads a version it has here and
|
|
41
|
+
// refuses any other.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export const SPEC_1 = {
|
|
45
|
+
"spec": 1,
|
|
46
|
+
"title": "The files every repo carries",
|
|
47
|
+
"authority": "the Atlas M2 study, design page D1 with its D7 amendment, and the lock block, slice L1.",
|
|
48
|
+
"states": {
|
|
49
|
+
"filled": "The part exists and matches the repo.",
|
|
50
|
+
"empty": "The part is missing, or it holds nothing.",
|
|
51
|
+
"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."
|
|
54
|
+
},
|
|
55
|
+
"kinds": {
|
|
56
|
+
"service": "A running product with a deploy target, logs and metrics.",
|
|
57
|
+
"library": "Code another repo takes as a dependency. It is not deployed.",
|
|
58
|
+
"schema": "Migrations and their release. It has no service and no library build."
|
|
59
|
+
},
|
|
60
|
+
"files": [
|
|
61
|
+
{
|
|
62
|
+
"id": "entry-map",
|
|
63
|
+
"number": 1,
|
|
64
|
+
"title": "Entry map",
|
|
65
|
+
"class": "instruction",
|
|
66
|
+
"written_by": "the helper drafts; a person merges",
|
|
67
|
+
"parts": [
|
|
68
|
+
{
|
|
69
|
+
"id": "claude-md",
|
|
70
|
+
"path": "CLAUDE.md",
|
|
71
|
+
"holds": "What the repo is, how to build it, the layout, the modules, and one pointer to each file below. Facts only.",
|
|
72
|
+
"kind": {
|
|
73
|
+
"service": "required",
|
|
74
|
+
"library": "required",
|
|
75
|
+
"schema": "required"
|
|
76
|
+
},
|
|
77
|
+
"check": "markdown",
|
|
78
|
+
"max_lines": 200
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "agents-md",
|
|
82
|
+
"path": "AGENTS.md",
|
|
83
|
+
"holds": "Two lines that point at CLAUDE.md.",
|
|
84
|
+
"kind": {
|
|
85
|
+
"service": "required",
|
|
86
|
+
"library": "required",
|
|
87
|
+
"schema": "required"
|
|
88
|
+
},
|
|
89
|
+
"check": "markdown",
|
|
90
|
+
"max_lines": 200
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "path-rules",
|
|
96
|
+
"number": 2,
|
|
97
|
+
"title": "Path rules",
|
|
98
|
+
"class": "instruction",
|
|
99
|
+
"written_by": "the helper; a person merges",
|
|
100
|
+
"parts": [
|
|
101
|
+
{
|
|
102
|
+
"id": "rules",
|
|
103
|
+
"path": ".claude/rules",
|
|
104
|
+
"glob": "*.md",
|
|
105
|
+
"holds": "One constraint that holds under one path only, such as \"migrations are append-only\".",
|
|
106
|
+
"kind": {
|
|
107
|
+
"service": "optional",
|
|
108
|
+
"library": "optional",
|
|
109
|
+
"schema": "optional"
|
|
110
|
+
},
|
|
111
|
+
"check": "path-rules"
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"id": "procedures",
|
|
117
|
+
"number": 3,
|
|
118
|
+
"title": "Procedures",
|
|
119
|
+
"class": "instruction",
|
|
120
|
+
"written_by": "the helper; a person merges",
|
|
121
|
+
"parts": [
|
|
122
|
+
{
|
|
123
|
+
"id": "run-locally",
|
|
124
|
+
"procedure": "run-locally",
|
|
125
|
+
"holds": "One skill that brings the stack up on the owner's machine.",
|
|
126
|
+
"kind": {
|
|
127
|
+
"service": "required",
|
|
128
|
+
"library": "not applicable",
|
|
129
|
+
"schema": "not applicable"
|
|
130
|
+
},
|
|
131
|
+
"check": "procedure"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"id": "test",
|
|
135
|
+
"procedure": "test",
|
|
136
|
+
"holds": "One skill that names the suites and runs them.",
|
|
137
|
+
"kind": {
|
|
138
|
+
"service": "required",
|
|
139
|
+
"library": "required",
|
|
140
|
+
"schema": "not applicable"
|
|
141
|
+
},
|
|
142
|
+
"check": "procedure"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "deploy",
|
|
146
|
+
"procedure": "deploy",
|
|
147
|
+
"holds": "One skill that deploys the service.",
|
|
148
|
+
"kind": {
|
|
149
|
+
"service": "required",
|
|
150
|
+
"library": "not applicable",
|
|
151
|
+
"schema": "not applicable"
|
|
152
|
+
},
|
|
153
|
+
"check": "procedure"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"id": "release",
|
|
157
|
+
"procedure": "release",
|
|
158
|
+
"holds": "One skill that cuts a release.",
|
|
159
|
+
"kind": {
|
|
160
|
+
"service": "required",
|
|
161
|
+
"library": "required",
|
|
162
|
+
"schema": "required"
|
|
163
|
+
},
|
|
164
|
+
"check": "procedure"
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "repo-facts",
|
|
170
|
+
"number": 4,
|
|
171
|
+
"title": "Repo facts",
|
|
172
|
+
"class": "instruction",
|
|
173
|
+
"written_by": "an agent proposes; the owner merges",
|
|
174
|
+
"parts": [
|
|
175
|
+
{
|
|
176
|
+
"id": "root",
|
|
177
|
+
"path": "atlas.yaml",
|
|
178
|
+
"holds": "The spec version, the kind, the product id, the repo id, the procedure map, and one pointer per section.",
|
|
179
|
+
"kind": {
|
|
180
|
+
"service": "required",
|
|
181
|
+
"library": "required",
|
|
182
|
+
"schema": "required"
|
|
183
|
+
},
|
|
184
|
+
"check": "facts-root",
|
|
185
|
+
"keys": [
|
|
186
|
+
"spec",
|
|
187
|
+
"kind",
|
|
188
|
+
"product",
|
|
189
|
+
"repo"
|
|
190
|
+
]
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"id": "deploy",
|
|
194
|
+
"section": "deploy",
|
|
195
|
+
"default_path": "atlas/deploy.yaml",
|
|
196
|
+
"holds": "One entry per environment: the platform, the target and the health endpoint.",
|
|
197
|
+
"kind": {
|
|
198
|
+
"service": "required",
|
|
199
|
+
"library": "not applicable",
|
|
200
|
+
"schema": "not applicable"
|
|
201
|
+
},
|
|
202
|
+
"check": "facts-section",
|
|
203
|
+
"keys": [
|
|
204
|
+
"environments"
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"id": "observability",
|
|
209
|
+
"section": "observability",
|
|
210
|
+
"default_path": "atlas/observability.yaml",
|
|
211
|
+
"holds": "Where the logs, the metrics, the traces and the dashboards are, per environment.",
|
|
212
|
+
"kind": {
|
|
213
|
+
"service": "required",
|
|
214
|
+
"library": "not applicable",
|
|
215
|
+
"schema": "not applicable"
|
|
216
|
+
},
|
|
217
|
+
"check": "facts-section",
|
|
218
|
+
"keys": [
|
|
219
|
+
"environments"
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"id": "credentials",
|
|
224
|
+
"section": "credentials",
|
|
225
|
+
"default_path": "atlas/credentials.yaml",
|
|
226
|
+
"holds": "Each credential by name, with the environment it belongs to. Names only, never a value.",
|
|
227
|
+
"kind": {
|
|
228
|
+
"service": "required",
|
|
229
|
+
"library": "not applicable",
|
|
230
|
+
"schema": "required"
|
|
231
|
+
},
|
|
232
|
+
"check": "facts-credentials",
|
|
233
|
+
"keys": [
|
|
234
|
+
"credentials"
|
|
235
|
+
]
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"id": "dependencies",
|
|
239
|
+
"section": "dependencies",
|
|
240
|
+
"default_path": "atlas/dependencies.yaml",
|
|
241
|
+
"holds": "The other repos this one needs, by repo id, and what it takes from each.",
|
|
242
|
+
"kind": {
|
|
243
|
+
"service": "required",
|
|
244
|
+
"library": "optional",
|
|
245
|
+
"schema": "optional"
|
|
246
|
+
},
|
|
247
|
+
"check": "facts-section",
|
|
248
|
+
"keys_present": [
|
|
249
|
+
"repos"
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"id": "upkeep",
|
|
254
|
+
"section": "upkeep",
|
|
255
|
+
"default_path": "atlas/upkeep.yaml",
|
|
256
|
+
"holds": "The budget and the kill switch for the upkeep task. Slice L4 fills it.",
|
|
257
|
+
"kind": {
|
|
258
|
+
"service": "optional",
|
|
259
|
+
"library": "optional",
|
|
260
|
+
"schema": "optional"
|
|
261
|
+
},
|
|
262
|
+
"check": "facts-section",
|
|
263
|
+
"keys": [
|
|
264
|
+
"budget"
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"id": "docs-index",
|
|
271
|
+
"number": 5,
|
|
272
|
+
"title": "Docs index",
|
|
273
|
+
"class": "docs",
|
|
274
|
+
"written_by": "the upkeep task; merges on its own",
|
|
275
|
+
"parts": [
|
|
276
|
+
{
|
|
277
|
+
"id": "index",
|
|
278
|
+
"path": "docs/index.md",
|
|
279
|
+
"holds": "One line per file under docs/, with a status.",
|
|
280
|
+
"kind": {
|
|
281
|
+
"service": "required",
|
|
282
|
+
"library": "required",
|
|
283
|
+
"schema": "required"
|
|
284
|
+
},
|
|
285
|
+
"check": "docs-index"
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"id": "docs-content",
|
|
291
|
+
"number": 6,
|
|
292
|
+
"title": "Docs content",
|
|
293
|
+
"class": "docs",
|
|
294
|
+
"written_by": "the upkeep task and the talk-through; merges on its own",
|
|
295
|
+
"parts": [
|
|
296
|
+
{
|
|
297
|
+
"id": "pages",
|
|
298
|
+
"path": "docs/site",
|
|
299
|
+
"glob": "*.md",
|
|
300
|
+
"holds": "The pages: home, features, flows with their step lists, concepts. Each page carries the commit it was built from.",
|
|
301
|
+
"kind": {
|
|
302
|
+
"service": "required",
|
|
303
|
+
"library": "optional",
|
|
304
|
+
"schema": "optional"
|
|
305
|
+
},
|
|
306
|
+
"check": "docs-pages"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"id": "features",
|
|
310
|
+
"path": "docs/site/features.yaml",
|
|
311
|
+
"holds": "The feature list the renderer reads.",
|
|
312
|
+
"kind": {
|
|
313
|
+
"service": "required",
|
|
314
|
+
"library": "optional",
|
|
315
|
+
"schema": "optional"
|
|
316
|
+
},
|
|
317
|
+
"check": "features"
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
"id": "notes",
|
|
321
|
+
"path": "docs/site/notes.md",
|
|
322
|
+
"class": "instruction",
|
|
323
|
+
"holds": "The standing corrections. Each note names who said it and the session. A person merges it.",
|
|
324
|
+
"kind": {
|
|
325
|
+
"service": "optional",
|
|
326
|
+
"library": "optional",
|
|
327
|
+
"schema": "optional"
|
|
328
|
+
},
|
|
329
|
+
"check": "notes"
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"id": "api",
|
|
333
|
+
"path": "docs/site/reference/api.md",
|
|
334
|
+
"holds": "Every route with its method, path, principal, handler file and line.",
|
|
335
|
+
"kind": {
|
|
336
|
+
"service": "required",
|
|
337
|
+
"library": "not applicable",
|
|
338
|
+
"schema": "not applicable"
|
|
339
|
+
},
|
|
340
|
+
"check": "generated",
|
|
341
|
+
"extractor": "routes"
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
"id": "database",
|
|
345
|
+
"path": "docs/site/reference/database.md",
|
|
346
|
+
"holds": "The tables, their fields, their types and the purpose of each field.",
|
|
347
|
+
"kind": {
|
|
348
|
+
"service": "required",
|
|
349
|
+
"library": "not applicable",
|
|
350
|
+
"schema": "required"
|
|
351
|
+
},
|
|
352
|
+
"check": "generated",
|
|
353
|
+
"extractor": "migrations"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
"id": "mcp-tools",
|
|
357
|
+
"path": "docs/site/reference/mcp-tools.md",
|
|
358
|
+
"holds": "Every MCP tool, its inputs, and when to use it.",
|
|
359
|
+
"kind": {
|
|
360
|
+
"service": "optional",
|
|
361
|
+
"library": "optional",
|
|
362
|
+
"schema": "optional"
|
|
363
|
+
},
|
|
364
|
+
"check": "generated",
|
|
365
|
+
"extractor": "mcp"
|
|
366
|
+
}
|
|
367
|
+
]
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"id": "tooling",
|
|
371
|
+
"number": 7,
|
|
372
|
+
"title": "Tooling",
|
|
373
|
+
"class": "instruction",
|
|
374
|
+
"written_by": "the helper; a person merges",
|
|
375
|
+
"parts": [
|
|
376
|
+
{
|
|
377
|
+
"id": "atlas-check",
|
|
378
|
+
"path": ".github/workflows/atlas-check.yml",
|
|
379
|
+
"holds": "The one workflow that runs .github/atlas/check.mjs, the check file in this repo.",
|
|
380
|
+
"kind": {
|
|
381
|
+
"service": "required",
|
|
382
|
+
"library": "required",
|
|
383
|
+
"schema": "required"
|
|
384
|
+
},
|
|
385
|
+
"check": "workflow"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"id": "linters",
|
|
389
|
+
"holds": "The repo's own linters and formatters.",
|
|
390
|
+
"kind": {
|
|
391
|
+
"service": "optional",
|
|
392
|
+
"library": "optional",
|
|
393
|
+
"schema": "optional"
|
|
394
|
+
},
|
|
395
|
+
"check": "linters",
|
|
396
|
+
"candidates": [
|
|
397
|
+
"eslint.config.js",
|
|
398
|
+
"eslint.config.mjs",
|
|
399
|
+
".eslintrc",
|
|
400
|
+
".eslintrc.json",
|
|
401
|
+
".eslintrc.cjs",
|
|
402
|
+
".prettierrc",
|
|
403
|
+
".prettierrc.json",
|
|
404
|
+
"prettier.config.js",
|
|
405
|
+
"biome.json",
|
|
406
|
+
"ruff.toml",
|
|
407
|
+
".ruff.toml",
|
|
408
|
+
"setup.cfg",
|
|
409
|
+
"tox.ini",
|
|
410
|
+
".flake8",
|
|
411
|
+
"rustfmt.toml",
|
|
412
|
+
".golangci.yml",
|
|
413
|
+
".golangci.yaml",
|
|
414
|
+
".editorconfig"
|
|
415
|
+
]
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
}
|
|
419
|
+
],
|
|
420
|
+
"extractors": {
|
|
421
|
+
"routes": "Slice L3 builds it.",
|
|
422
|
+
"migrations": "Slice L3 builds it.",
|
|
423
|
+
"mcp": "Slice L3 builds it."
|
|
424
|
+
},
|
|
425
|
+
"by_reference": {
|
|
426
|
+
"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.",
|
|
427
|
+
"scanned": [
|
|
428
|
+
"CLAUDE.md",
|
|
429
|
+
"AGENTS.md",
|
|
430
|
+
".claude/rules/*.md",
|
|
431
|
+
".claude/skills/*/SKILL.md",
|
|
432
|
+
"atlas.yaml",
|
|
433
|
+
"atlas/*.yaml",
|
|
434
|
+
"docs/site/notes.md"
|
|
435
|
+
]
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Deep frozen. `loadSpec` hands every caller the same object, so one caller
|
|
440
|
+
// must not be able to change what the next one reads (acceptance review of
|
|
441
|
+
// batch 14b, finding 9).
|
|
442
|
+
function deepFreeze(value) {
|
|
443
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
444
|
+
Object.freeze(value);
|
|
445
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
deepFreeze(SPEC_1);
|
|
450
|
+
|
|
451
|
+
// Spec 2 adds one part: the check file itself, scored inside file 7.
|
|
452
|
+
//
|
|
453
|
+
// Why a new version and not a line in spec 1. Adding a scored part changes what
|
|
454
|
+
// `spec: 1` means, and the spec version is the whole coupling between a
|
|
455
|
+
// repository's facts and this file (architecture rule 6; architect finding
|
|
456
|
+
// P11). The check reads both versions, so a repository stays green while it is
|
|
457
|
+
// still on spec 1, and it opts in by moving `atlas.yaml` to `spec: 2`.
|
|
458
|
+
//
|
|
459
|
+
// Nothing else moves. Spec 1 keeps its twenty-two parts, and spec 2 has
|
|
460
|
+
// twenty-three.
|
|
461
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
462
|
+
|
|
463
|
+
export const SPEC_2 = (() => {
|
|
464
|
+
const spec = clone(SPEC_1);
|
|
465
|
+
spec.spec = 2;
|
|
466
|
+
spec.authority = 'Atlas amendment A1.6, section 5, slice L2 stage one, and section 4.6.';
|
|
467
|
+
const tooling = spec.files.find((file) => file.id === 'tooling');
|
|
468
|
+
tooling.parts.unshift({
|
|
469
|
+
id: 'check-file',
|
|
470
|
+
// The literal, not the constant: this object is data, and the constant is
|
|
471
|
+
// declared below it. A test keeps the two equal.
|
|
472
|
+
path: '.github/atlas/check.mjs',
|
|
473
|
+
holds: 'The check itself, the copy this repo runs. One file, no dependency, carrying the Atlas version and the package commit it came from.',
|
|
474
|
+
kind: { service: 'required', library: 'required', schema: 'required' },
|
|
475
|
+
check: 'check-file',
|
|
476
|
+
});
|
|
477
|
+
return deepFreeze(spec);
|
|
478
|
+
})();
|
|
479
|
+
|
|
480
|
+
export const SPECS = Object.freeze({ 1: SPEC_1, 2: SPEC_2 });
|
|
481
|
+
|
|
482
|
+
export class SpecError extends Error {}
|
|
483
|
+
|
|
484
|
+
export function loadSpec(version) {
|
|
485
|
+
// Number() turns true into 1 and "0x1" into 1, so a boolean or a hex string
|
|
486
|
+
// would silently read spec 1 (acceptance review of batch 14b, finding 8).
|
|
487
|
+
// Take a whole number, or a string of decimal digits, and nothing else.
|
|
488
|
+
const decimal = typeof version === 'number' || (typeof version === 'string' && /^\s*\d+\s*$/.test(version));
|
|
489
|
+
const wanted = decimal ? Number(version) : NaN;
|
|
490
|
+
if (!Number.isInteger(wanted) || wanted < 1) throw new SpecError(`atlas.yaml names spec "${version}"; the spec version must be a whole number`);
|
|
491
|
+
const spec = SPECS[wanted];
|
|
492
|
+
if (!spec) throw new SpecError(`spec ${wanted} is unknown to this copy of the check, check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}. Refresh .github/atlas/check.mjs, or set spec to a version this copy reads`);
|
|
493
|
+
if (spec.spec !== wanted) throw new SpecError(`the spec object for version ${wanted} names version ${spec.spec}`);
|
|
494
|
+
return spec;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// The secret patterns. A credentials file carries names only. A value in one is
|
|
499
|
+
// an error, not a finding. The list is here, in this file, so it can never go
|
|
500
|
+
// missing and disarm the only content check (review R-6). A test keeps it equal
|
|
501
|
+
// to work/protected-paths.json in the Atlas package, which is the authority for the same list in the
|
|
502
|
+
// M1 verbs.
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
// Each pattern carries a canary: a string that pattern must catch. The check
|
|
506
|
+
// tests every pattern against its own canary on every run, and raises an error
|
|
507
|
+
// when one misses.
|
|
508
|
+
//
|
|
509
|
+
// Why. The guard used to count the patterns, not test them. A pull request
|
|
510
|
+
// could replace all five with patterns that match nothing, and `scan` saw five
|
|
511
|
+
// usable regular expressions and stayed green. The only content check in the
|
|
512
|
+
// whole check was then silent for every later run, with no sign of it
|
|
513
|
+
// (security persona, finding S2-5).
|
|
514
|
+
//
|
|
515
|
+
// Every canary is joined from pieces at run time. Written whole, a canary
|
|
516
|
+
// would look like a credential to a secret scanner, and this file is committed
|
|
517
|
+
// and copied into other repositories. The same rule already governs the test
|
|
518
|
+
// fixtures (acceptance review of batch 14b, finding 1).
|
|
519
|
+
const canary = (...pieces) => pieces.join('');
|
|
520
|
+
|
|
521
|
+
export const SECRET_PATTERNS = Object.freeze([
|
|
522
|
+
{
|
|
523
|
+
id: 'named-value',
|
|
524
|
+
holds: 'A named credential field with a value after it.',
|
|
525
|
+
source: '(?i)(api[_-]?key|secret|token|password|passwd|authorization)\\s*[:=]\\s*\\S{8,}',
|
|
526
|
+
canary: canary('api', '_key: ', 'N', '0'.repeat(11)),
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: 'json-web-token',
|
|
530
|
+
holds: 'A JSON web token, three base64url parts.',
|
|
531
|
+
source: '\\b(?:eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})\\b',
|
|
532
|
+
canary: canary('ey', 'J', 'A'.repeat(12), '.', 'B'.repeat(12), '.', 'C'.repeat(12)),
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
id: 'github-token',
|
|
536
|
+
holds: 'A GitHub personal access token.',
|
|
537
|
+
source: '\\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\\b',
|
|
538
|
+
canary: canary('gh', 'p', '_', 'A'.repeat(24)),
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
id: 'vendor-key',
|
|
542
|
+
holds: 'A model-vendor key or an AWS access key id.',
|
|
543
|
+
source: '\\b(?:sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16})\\b',
|
|
544
|
+
canary: canary('sk', '-', 'A'.repeat(24)),
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
id: 'private-key',
|
|
548
|
+
holds: 'The header line of a private key block.',
|
|
549
|
+
source: '-----BEGIN [A-Z ]*PRIVATE KEY-----',
|
|
550
|
+
canary: canary('-----', 'BEGIN ', 'RSA ', 'PRIVATE KEY', '-----'),
|
|
551
|
+
},
|
|
552
|
+
// The sixth. A connection string is the likely shape of a leaked credential
|
|
553
|
+
// here, and none of the five above matches one (security persona, finding
|
|
554
|
+
// S3-11). It needs a user and a password before the host, so an ordinary URL
|
|
555
|
+
// never matches.
|
|
556
|
+
{
|
|
557
|
+
id: 'url-credential',
|
|
558
|
+
holds: 'A credential in URL form: a scheme, then a user name and a password, then the host.',
|
|
559
|
+
source: '\\b[a-z][a-z0-9+.-]*:\\/\\/[^\\s:@/]+:[^\\s:@/]+@[^\\s/]+',
|
|
560
|
+
canary: canary('postgres', '://', 'demo', ':', 'pa55', 'word', '@', 'db.example/atlas'),
|
|
561
|
+
},
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
// The authority for this same list in the M1 verbs is the package's work/protected-paths.json,
|
|
565
|
+
// and a test keeps the two equal.
|
|
566
|
+
export const SECRET_PATTERN_SOURCES = Object.freeze(SECRET_PATTERNS.map((pattern) => pattern.source));
|
|
567
|
+
|
|
568
|
+
// The sources are written with a leading (?i), which JavaScript spells as a flag.
|
|
569
|
+
const compile = (source) => {
|
|
570
|
+
const insensitive = source.startsWith('(?i)');
|
|
571
|
+
return new RegExp(insensitive ? source.slice(4) : source, insensitive ? 'i' : '');
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
export function secretPatterns() {
|
|
575
|
+
return SECRET_PATTERN_SOURCES.map(compile);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Every pattern this file declares, tested against its own canary. Returns the
|
|
579
|
+
// ids that missed. An empty list means the patterns still bite.
|
|
580
|
+
export function canaryMisses() {
|
|
581
|
+
const missed = [];
|
|
582
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
583
|
+
let caught = false;
|
|
584
|
+
try { caught = compile(pattern.source).test(pattern.canary); } catch { caught = false; }
|
|
585
|
+
if (!caught) missed.push(pattern.id);
|
|
586
|
+
}
|
|
587
|
+
return missed;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// The workflow that runs this file. A person copies it into a repo today. The
|
|
592
|
+
// tooling helper of slice L2 writes it from here. It runs on pull_request, it
|
|
593
|
+
// reads the contents, it takes no secret, and it pins its one action by commit
|
|
594
|
+
// SHA (design review finding R11).
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
export const WORKFLOW_PATH = '.github/workflows/atlas-check.yml';
|
|
598
|
+
export const CHECK_PATH = '.github/atlas/check.mjs';
|
|
599
|
+
|
|
600
|
+
// Where the workflow puts the base tree. `walk` and `symlinks` skip every entry
|
|
601
|
+
// whose name starts with a dot, and every other read names an exact path, so no
|
|
602
|
+
// scanner ever reaches into it.
|
|
603
|
+
export const BASE_DIR = '.atlas-base';
|
|
604
|
+
|
|
605
|
+
export const WORKFLOW_TEMPLATE = `# The Atlas repo shape check, in this repo's own CI.
|
|
606
|
+
#
|
|
607
|
+
# It reads the seven files this repo carries, named in atlas.yaml and under
|
|
608
|
+
# atlas/, and reports one state per part: filled, empty, stale, not applicable,
|
|
609
|
+
# or no extractor. It reads this repo only. It takes no secret and opens no
|
|
610
|
+
# network, so it runs on a pull request with read access to the contents.
|
|
611
|
+
#
|
|
612
|
+
# The check is ${CHECK_PATH} in this repo. It needs no package and no
|
|
613
|
+
# install. Atlas writes both files; do not edit them by hand.
|
|
614
|
+
#
|
|
615
|
+
# It reports in the docs lane. It never fails a code merge.
|
|
616
|
+
#
|
|
617
|
+
# The second checkout is the ratchet's base tree. The check scores the base
|
|
618
|
+
# commit and the head commit side by side, and it goes red when a part the base
|
|
619
|
+
# already had is taken away. The base tree lands in ${BASE_DIR}, whose name
|
|
620
|
+
# starts with a dot, so no scanner walks into it. If that checkout fails there
|
|
621
|
+
# is nothing to compare, so the step may fail and the check says so and does not
|
|
622
|
+
# gate.
|
|
623
|
+
#
|
|
624
|
+
# Design: the Atlas architecture record, sections 5.2, 6 and rule 7.
|
|
625
|
+
name: atlas-check
|
|
626
|
+
on:
|
|
627
|
+
pull_request:
|
|
628
|
+
permissions:
|
|
629
|
+
contents: read
|
|
630
|
+
jobs:
|
|
631
|
+
repo-shape:
|
|
632
|
+
name: docs · repo shape
|
|
633
|
+
runs-on: ubuntu-latest
|
|
634
|
+
# The check reads two directory trees and opens no network. It finishes in
|
|
635
|
+
# seconds. The bound stops a runner being billed until someone kills it.
|
|
636
|
+
timeout-minutes: 5
|
|
637
|
+
steps:
|
|
638
|
+
# actions/checkout v4.3.1
|
|
639
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
|
640
|
+
- name: the base commit, for the ratchet
|
|
641
|
+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
|
642
|
+
continue-on-error: true
|
|
643
|
+
with:
|
|
644
|
+
ref: \${{ github.event.pull_request.base.sha }}
|
|
645
|
+
path: ${BASE_DIR}
|
|
646
|
+
- run: node ${CHECK_PATH} --base ${BASE_DIR} --base-sha \${{ github.event.pull_request.base.sha }}
|
|
647
|
+
`;
|
|
648
|
+
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// The YAML reader. Atlas ships no dependency, so the check reads the subset of
|
|
651
|
+
// YAML that atlas.yaml, the section files and a SKILL.md front matter block
|
|
652
|
+
// use: comments, nested maps by indentation, lists of scalars and of maps,
|
|
653
|
+
// quoted and plain scalars, null, booleans, numbers, and the block scalars
|
|
654
|
+
// | and >. Anything outside that subset raises, so a file is never half
|
|
655
|
+
// understood.
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
// A parse error names where it is. It never carries what is there.
|
|
659
|
+
//
|
|
660
|
+
// It used to put up to sixty characters of the offending line into the
|
|
661
|
+
// message, and `checkCredentials` returned that message before it ran the
|
|
662
|
+
// secret patterns. A credential written with bad indentation therefore reached
|
|
663
|
+
// the CI log and the job summary, and the scan that would have caught it never
|
|
664
|
+
// ran (security persona, finding S2-6; amendment A1.6 section 4.9). The line
|
|
665
|
+
// and the column say where to look. The reader opens the file.
|
|
666
|
+
export class YamlError extends Error {
|
|
667
|
+
constructor(message, line, column) {
|
|
668
|
+
const at = line === undefined ? '' : column === undefined
|
|
669
|
+
? ` (line ${line + 1})`
|
|
670
|
+
: ` (line ${line + 1}, column ${column + 1})`;
|
|
671
|
+
super(`${message}${at}`);
|
|
672
|
+
this.name = 'YamlError';
|
|
673
|
+
this.line = line;
|
|
674
|
+
this.column = column;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const KEY = /^(?:"((?:[^"\\]|\\.)*)"|'((?:[^']|'')*)'|([^:#]+?))\s*:(?:\s+(.*))?$/;
|
|
679
|
+
|
|
680
|
+
// One level of flow style, for scalars only: [a, b] and { a: 1, b: two }.
|
|
681
|
+
// A nested bracket raises, so a file is never half understood.
|
|
682
|
+
function flow(text, lineNumber) {
|
|
683
|
+
const open = text[0], close = open === '[' ? ']' : '}';
|
|
684
|
+
if (!text.endsWith(close)) throw new YamlError(`a flow ${open === '[' ? 'list' : 'map'} does not close`, lineNumber);
|
|
685
|
+
const body = text.slice(1, -1);
|
|
686
|
+
if (/[[\]{}]/.test(body)) throw new YamlError('a nested flow value is not read by this check; use a block list or map', lineNumber);
|
|
687
|
+
const parts = body.split(',').map((part) => part.trim()).filter((part) => part !== '');
|
|
688
|
+
if (open === '[') return parts.map((part) => scalar(part, lineNumber));
|
|
689
|
+
const out = {};
|
|
690
|
+
for (const part of parts) {
|
|
691
|
+
const at = part.indexOf(':');
|
|
692
|
+
if (at === -1) throw new YamlError('a flow map entry has no colon', lineNumber);
|
|
693
|
+
out[part.slice(0, at).trim().replace(/^["']|["']$/g, '')] = scalar(part.slice(at + 1), lineNumber);
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function scalar(raw, lineNumber) {
|
|
699
|
+
const text = raw === undefined ? '' : raw.trim();
|
|
700
|
+
if (!text || text === '~' || text === 'null' || text === 'Null' || text === 'NULL') return null;
|
|
701
|
+
if (text === 'true' || text === 'True' || text === 'TRUE') return true;
|
|
702
|
+
if (text === 'false' || text === 'False' || text === 'FALSE') return false;
|
|
703
|
+
if (text === '[]') return [];
|
|
704
|
+
if (text === '{}') return ({});
|
|
705
|
+
if (text.startsWith('"')) {
|
|
706
|
+
if (!text.endsWith('"') || text.length < 2) throw new YamlError('a double quoted value does not close', lineNumber);
|
|
707
|
+
return text.slice(1, -1).replace(/\\(.)/g, (_, character) => ({ n: '\n', t: '\t', r: '\r' })[character] ?? character);
|
|
708
|
+
}
|
|
709
|
+
if (text.startsWith("'")) {
|
|
710
|
+
if (!text.endsWith("'") || text.length < 2) throw new YamlError('a single quoted value does not close', lineNumber);
|
|
711
|
+
return text.slice(1, -1).replaceAll("''", "'");
|
|
712
|
+
}
|
|
713
|
+
if (text.startsWith('[') || text.startsWith('{')) return flow(text, lineNumber);
|
|
714
|
+
const plain = text.replace(/\s+#.*$/, '').trim();
|
|
715
|
+
if (/^-?\d+$/.test(plain)) return Number(plain);
|
|
716
|
+
if (/^-?\d+\.\d+$/.test(plain)) return Number(plain);
|
|
717
|
+
return plain;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function indentOf(line) {
|
|
721
|
+
if (/^\t/.test(line)) throw new YamlError('a tab starts a line; YAML indents with spaces');
|
|
722
|
+
return line.length - line.trimStart().length;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function parseYaml(source) {
|
|
726
|
+
const raw = String(source ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
727
|
+
const lines = [];
|
|
728
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
729
|
+
const line = raw[index];
|
|
730
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
731
|
+
// A document marker is allowed only before the first content line. After
|
|
732
|
+
// that it starts a second document, and merging two documents into one map
|
|
733
|
+
// lets a later key silently override an earlier one (review R-4). `kind`
|
|
734
|
+
// and `spec` decide which files a repo needs, so a silent override there is
|
|
735
|
+
// a silent wrong answer.
|
|
736
|
+
if (/^(---|\.\.\.)\s*$/.test(line)) {
|
|
737
|
+
if (lines.length) throw new YamlError('a second document starts here; this check reads one document per file', index, 0);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (/^\t/.test(line)) throw new YamlError('a tab starts a line; YAML indents with spaces', index, 0);
|
|
741
|
+
lines.push({ text: line, number: index, indent: indentOf(line) });
|
|
742
|
+
}
|
|
743
|
+
if (!lines.length) return null;
|
|
744
|
+
const state = { at: 0 };
|
|
745
|
+
|
|
746
|
+
function blockScalar(header, indent) {
|
|
747
|
+
const fold = header.startsWith('>');
|
|
748
|
+
const chomp = header.includes('-') ? 'strip' : header.includes('+') ? 'keep' : 'clip';
|
|
749
|
+
const parts = [];
|
|
750
|
+
let inner = null;
|
|
751
|
+
while (state.at < lines.length && lines[state.at].indent > indent) {
|
|
752
|
+
const line = lines[state.at];
|
|
753
|
+
if (inner === null) inner = line.indent;
|
|
754
|
+
parts.push(line.text.slice(Math.min(inner, line.indent)));
|
|
755
|
+
state.at += 1;
|
|
756
|
+
}
|
|
757
|
+
let text = fold ? parts.join(' ') : parts.join('\n');
|
|
758
|
+
if (chomp !== 'strip') text += '\n';
|
|
759
|
+
return text;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function value(indent) {
|
|
763
|
+
if (state.at >= lines.length || lines[state.at].indent < indent) return null;
|
|
764
|
+
return lines[state.at].text.trimStart().startsWith('- ') || lines[state.at].text.trim() === '-'
|
|
765
|
+
? list(lines[state.at].indent)
|
|
766
|
+
: map(lines[state.at].indent);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function list(indent) {
|
|
770
|
+
const out = [];
|
|
771
|
+
while (state.at < lines.length && lines[state.at].indent === indent) {
|
|
772
|
+
const line = lines[state.at];
|
|
773
|
+
const body = line.text.trimStart();
|
|
774
|
+
if (!body.startsWith('- ') && body !== '-') break;
|
|
775
|
+
const rest = body === '-' ? '' : body.slice(2);
|
|
776
|
+
state.at += 1;
|
|
777
|
+
if (!rest.trim()) { out.push(value(indent + 1)); continue; }
|
|
778
|
+
const match = KEY.exec(rest);
|
|
779
|
+
if (match) {
|
|
780
|
+
// A list entry that starts a map: re-read it at the column the key sits in.
|
|
781
|
+
const keyColumn = indent + (body === '-' ? 1 : 2);
|
|
782
|
+
state.at -= 1;
|
|
783
|
+
lines[state.at] = { text: ' '.repeat(keyColumn) + rest, number: line.number, indent: keyColumn };
|
|
784
|
+
out.push(map(keyColumn));
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
out.push(scalar(rest, line.number));
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function map(indent) {
|
|
793
|
+
const out = {};
|
|
794
|
+
while (state.at < lines.length && lines[state.at].indent === indent) {
|
|
795
|
+
const line = lines[state.at];
|
|
796
|
+
const body = line.text.trimStart();
|
|
797
|
+
if (body.startsWith('- ')) break;
|
|
798
|
+
const match = KEY.exec(body);
|
|
799
|
+
if (!match) throw new YamlError('this line is neither a key nor a list entry', line.number, line.indent);
|
|
800
|
+
const key = match[1] !== undefined ? match[1] : match[2] !== undefined ? match[2].replaceAll("''", "'") : match[3].trim();
|
|
801
|
+
const rest = (match[4] ?? '').trim();
|
|
802
|
+
state.at += 1;
|
|
803
|
+
if (/^[|>][-+]?\d*$/.test(rest)) { out[key] = blockScalar(rest, indent); continue; }
|
|
804
|
+
if (rest && !rest.startsWith('#')) { out[key] = scalar(rest, line.number); continue; }
|
|
805
|
+
out[key] = value(indent + 1);
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const result = value(lines[0].indent);
|
|
811
|
+
if (state.at < lines.length) throw new YamlError('the indentation goes back out to a column no parent uses', lines[state.at].number, lines[state.at].indent);
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// The front matter block of a SKILL.md or a rule file: the text between the
|
|
816
|
+
// first two `---` lines. Returns null when the file has no front matter.
|
|
817
|
+
export function frontMatter(source) {
|
|
818
|
+
const text = String(source ?? '').replace(/\r\n?/g, '\n');
|
|
819
|
+
if (!text.startsWith('---\n')) return null;
|
|
820
|
+
const end = text.indexOf('\n---', 3);
|
|
821
|
+
if (end === -1) return null;
|
|
822
|
+
const block = text.slice(4, end + 1);
|
|
823
|
+
const parsed = parseYaml(block);
|
|
824
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// The scanners. One per part of the spec.
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
export const STATES = Object.freeze(['filled', 'empty', 'stale', 'not applicable', 'no extractor']);
|
|
832
|
+
export const NOT_FILLED = Object.freeze(['empty', 'stale', 'no extractor']);
|
|
833
|
+
export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
834
|
+
const PINNED = /^[0-9a-f]{40}$/;
|
|
835
|
+
const MAX_LISTED = 12;
|
|
836
|
+
|
|
837
|
+
const readText = (root, path) => { try { return readFileSync(join(root, path), 'utf8'); } catch { return null; } };
|
|
838
|
+
const isDirectory = (root, path) => { try { return statSync(join(root, path)).isDirectory(); } catch { return false; } };
|
|
839
|
+
const isFile = (root, path) => { try { return statSync(join(root, path)).isFile(); } catch { return false; } };
|
|
840
|
+
|
|
841
|
+
function walk(root, path, out = []) {
|
|
842
|
+
let entries;
|
|
843
|
+
try { entries = readdirSync(join(root, path), { withFileTypes: true }); } catch { return out; }
|
|
844
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
845
|
+
if (entry.name.startsWith('.')) continue;
|
|
846
|
+
const child = posix.join(path, entry.name);
|
|
847
|
+
if (entry.isDirectory()) walk(root, child, out);
|
|
848
|
+
else if (entry.isFile()) out.push(child);
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Every symbolic link under a path. `walk` pushes only `entry.isFile()`, and a
|
|
854
|
+
// symbolic link is neither a file nor a directory, so it was invisible: a page
|
|
855
|
+
// under docs/ could be a link to any path in the repository, the check scored
|
|
856
|
+
// nothing, and a renderer that follows the link publishes what it points at
|
|
857
|
+
// (security persona, finding S3-10). This does not follow a link, so a loop is
|
|
858
|
+
// not reachable.
|
|
859
|
+
function symlinks(root, path, out = []) {
|
|
860
|
+
let entries;
|
|
861
|
+
try { entries = readdirSync(join(root, path), { withFileTypes: true }); } catch { return out; }
|
|
862
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
863
|
+
if (entry.name.startsWith('.')) continue;
|
|
864
|
+
const child = posix.join(path, entry.name);
|
|
865
|
+
if (entry.isSymbolicLink()) out.push(child);
|
|
866
|
+
else if (entry.isDirectory()) symlinks(root, child, out);
|
|
867
|
+
}
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// A symbolic link is a finding, never a state on its own: the part it sits
|
|
872
|
+
// under says what state it makes.
|
|
873
|
+
function symlinkFindings(root, path, keep = () => true) {
|
|
874
|
+
const found = symlinks(root, path).filter(keep);
|
|
875
|
+
return found.length
|
|
876
|
+
? [`${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`]
|
|
877
|
+
: [];
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Relative link targets a markdown file points at. Placeholders and parent
|
|
881
|
+
// paths are left out: they are not this repo's files.
|
|
882
|
+
export function pointers(text) {
|
|
883
|
+
const out = new Set();
|
|
884
|
+
for (const [, target] of String(text ?? '').matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
|
885
|
+
const path = target.split('#')[0].trim();
|
|
886
|
+
if (!path || /^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith('/') || path.startsWith('..') || path.startsWith('#')) continue;
|
|
887
|
+
if (/[<>*{}$\\]/.test(path) || path.includes('...')) continue;
|
|
888
|
+
out.add(path.replace(/^\.\//, ''));
|
|
889
|
+
}
|
|
890
|
+
return [...out];
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Every docs/ path an instruction file names, as a link, in backticks, or bare.
|
|
894
|
+
// One spelling per file. `docs/./index.md` and `docs//index.md` name the file
|
|
895
|
+
// `docs/index.md` names, and every consumer compares these paths as strings:
|
|
896
|
+
// the exemption below, and the merge actor of slice L4, which takes its
|
|
897
|
+
// excluded set from this list. Two characters of spelling would otherwise make
|
|
898
|
+
// a second row and leave the first one reading "nothing else names it", which
|
|
899
|
+
// is a false sentence in a report a gate reads.
|
|
900
|
+
export function normalizeDocsPath(path) {
|
|
901
|
+
const parts = [];
|
|
902
|
+
for (const piece of String(path).split('/')) {
|
|
903
|
+
if (piece === '' || piece === '.') continue;
|
|
904
|
+
if (piece === '..') { parts.pop(); continue; }
|
|
905
|
+
parts.push(piece);
|
|
906
|
+
}
|
|
907
|
+
return parts.join('/');
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export function docsReferences(text) {
|
|
911
|
+
const out = new Set();
|
|
912
|
+
for (const [, path] of String(text ?? '').matchAll(/(?:^|[\s`(\[<"'])(docs\/[A-Za-z0-9._/-]+)/g)) {
|
|
913
|
+
const clean = normalizeDocsPath(path.replace(/[.,;:]+$/, ''));
|
|
914
|
+
if (!clean.startsWith('docs/') || clean.endsWith('/') || /[<>*{}$]/.test(clean)) continue;
|
|
915
|
+
out.add(clean);
|
|
916
|
+
}
|
|
917
|
+
return [...out];
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function entryFor(part, kind) {
|
|
921
|
+
const state = part.kind?.[kind];
|
|
922
|
+
if (!state) throw new SpecError(`the spec gives part ${part.id} no state for kind ${kind}`);
|
|
923
|
+
return state;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const result = (part, state, findings = []) => ({ id: part.id, path: part.path ?? part.default_path ?? null, procedure: part.procedure ?? null, holds: part.holds ?? '', class: part.class ?? null, state, findings });
|
|
927
|
+
|
|
928
|
+
function checkMarkdown(context, part) {
|
|
929
|
+
const text = readText(context.root, part.path);
|
|
930
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
931
|
+
// Design page D1 says the check says "wrong" when the entry map is missing,
|
|
932
|
+
// over its line limit, or a pointer is dead. All three are states, not notes
|
|
933
|
+
// on a filled file, or the count the report leads with is short (review R-1).
|
|
934
|
+
const findings = [];
|
|
935
|
+
const lines = text.replace(/\n$/, '').split('\n').length;
|
|
936
|
+
if (part.max_lines && lines > part.max_lines) findings.push(`${lines} lines, over the ${part.max_lines}-line limit`);
|
|
937
|
+
const dead = pointers(text).filter((path) => !existsSync(join(context.root, path)));
|
|
938
|
+
if (dead.length) findings.push(`${dead.length} pointer(s) name nothing on disk: ${dead.slice(0, MAX_LISTED).join(', ')}`);
|
|
939
|
+
return findings.length ? result(part, 'stale', findings) : result(part, 'filled');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function checkPathRules(context, part) {
|
|
943
|
+
if (!isDirectory(context.root, part.path)) return result(part, 'empty', [`${part.path}/ does not exist`]);
|
|
944
|
+
const files = walk(context.root, part.path).filter((path) => path.endsWith('.md'));
|
|
945
|
+
if (!files.length) return result(part, 'empty', [`${part.path}/ holds no rule`]);
|
|
946
|
+
const bad = [];
|
|
947
|
+
for (const file of files) {
|
|
948
|
+
const front = frontMatter(readText(context.root, file) ?? '');
|
|
949
|
+
const paths = front?.paths;
|
|
950
|
+
const list = Array.isArray(paths) ? paths : typeof paths === 'string' ? [paths] : [];
|
|
951
|
+
if (!list.length) { bad.push(`${file} has no paths: key`); continue; }
|
|
952
|
+
for (const pattern of list) {
|
|
953
|
+
const base = String(pattern).split(/[*?[]/)[0].replace(/\/+$/, '');
|
|
954
|
+
if (base && !existsSync(join(context.root, base))) bad.push(`${file} names ${pattern}, which does not exist`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return bad.length ? result(part, 'stale', bad.slice(0, MAX_LISTED)) : result(part, 'filled', [`${files.length} rule file(s)`]);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function checkProcedure(context, part) {
|
|
961
|
+
const named = context.facts?.procedures?.[part.procedure];
|
|
962
|
+
if (named === undefined) return result(part, 'empty', [`atlas.yaml has no procedures entry for ${part.procedure}`]);
|
|
963
|
+
if (named === null || named === '') return result(part, 'empty', [`atlas.yaml sets procedures.${part.procedure} to null: the repo has no skill for it yet`]);
|
|
964
|
+
const skill = posix.join('.claude/skills', String(named), 'SKILL.md');
|
|
965
|
+
if (!isFile(context.root, skill)) return result(part, 'stale', [`atlas.yaml names skill "${named}", and ${skill} does not exist`]);
|
|
966
|
+
const front = frontMatter(readText(context.root, skill) ?? '');
|
|
967
|
+
if (!front?.name || !front?.description) return result(part, 'stale', [`${skill} has no name or no description in its front matter`]);
|
|
968
|
+
return result(part, 'filled', [skill]);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// `scan` reports a missing or unreadable atlas.yaml as an error and returns
|
|
972
|
+
// before it scans any part, because the file names the kind, and the kind
|
|
973
|
+
// decides which parts a repo needs. So this scanner only ever runs on a file
|
|
974
|
+
// that parsed. The two dead guards that stood here are gone (acceptance review
|
|
975
|
+
// of batch 14b, finding 4).
|
|
976
|
+
function checkFactsRoot(context, part) {
|
|
977
|
+
const missing = (part.keys ?? []).filter((key) => context.facts[key] === undefined || context.facts[key] === null || context.facts[key] === '');
|
|
978
|
+
const findings = [];
|
|
979
|
+
if (missing.length) return result(part, 'stale', [`atlas.yaml has no ${missing.join(', ')}`]);
|
|
980
|
+
if (context.repoId && String(context.facts.repo) !== context.repoId) return result(part, 'stale', [`atlas.yaml names repo "${context.facts.repo}"; this checkout is ${context.repoId}`]);
|
|
981
|
+
if (!context.facts.procedures) findings.push('atlas.yaml has no procedures map, so no procedure can be found');
|
|
982
|
+
return result(part, 'filled', findings);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function sectionPath(context, part) {
|
|
986
|
+
const pointed = context.facts?.files?.[part.section];
|
|
987
|
+
return typeof pointed === 'string' && pointed.trim() ? pointed.trim() : part.default_path;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// A pointer in atlas.yaml names a file inside this repo. One that leaves the
|
|
991
|
+
// checkout would make the check read a stranger's file, and a parse error
|
|
992
|
+
// would carry a line of it into the CI log (review R-5).
|
|
993
|
+
export function insideRepo(root, path) {
|
|
994
|
+
const base = resolve(root);
|
|
995
|
+
const target = resolve(base, path);
|
|
996
|
+
const step = relative(base, target);
|
|
997
|
+
return step !== '' && !step.startsWith('..') && !posix.isAbsolute(step.split('\\').join('/'));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function readSection(context, part) {
|
|
1001
|
+
const path = sectionPath(context, part);
|
|
1002
|
+
if (!insideRepo(context.root, path)) return { path, outside: true };
|
|
1003
|
+
const text = readText(context.root, path);
|
|
1004
|
+
if (text === null || !text.trim()) return { path, missing: true };
|
|
1005
|
+
try { return { path, text, data: parseYaml(text) }; }
|
|
1006
|
+
catch (error) { return { path, text, error: error instanceof YamlError ? error.message : String(error?.message ?? error) }; }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const emptyValue = (value) => value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length) || (typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length);
|
|
1010
|
+
|
|
1011
|
+
// A secret in any file the check reads, over the raw text, before anything is
|
|
1012
|
+
// parsed or echoed. The patterns used to run over atlas/credentials.yaml alone,
|
|
1013
|
+
// and every other facts file has its values quoted into findings: the kind, the
|
|
1014
|
+
// repo id, a procedure's skill name, a pointer path (acceptance review of batch
|
|
1015
|
+
// 16, note S3-2).
|
|
1016
|
+
function secretIn(context, path, text) {
|
|
1017
|
+
for (const pattern of context.secretPatterns) {
|
|
1018
|
+
if (pattern.test(text ?? '')) {
|
|
1019
|
+
context.errors.push(`${path} holds something that looks like a secret value; no file the check reads carries one`);
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function checkFactsSection(context, part) {
|
|
1027
|
+
const section = readSection(context, part);
|
|
1028
|
+
const withPath = { ...part, path: section.path };
|
|
1029
|
+
if (section.outside) return result(withPath, 'stale', [`atlas.yaml points at "${section.path}", which is outside the repo; the check reads this repo only`]);
|
|
1030
|
+
if (section.missing) return result(withPath, 'empty', [`${section.path} does not exist`]);
|
|
1031
|
+
if (secretIn(context, section.path, section.text)) return result(withPath, 'stale', [`${section.path} holds something that looks like a secret value`]);
|
|
1032
|
+
if (section.error) return result(withPath, 'stale', [`${section.path} cannot be read: ${section.error}`]);
|
|
1033
|
+
// `keys` must hold content. `keys_present` must exist and may be empty: a
|
|
1034
|
+
// repo with no dependency still answers, with `repos: []`.
|
|
1035
|
+
const missing = (part.keys ?? []).filter((key) => emptyValue(section.data?.[key]));
|
|
1036
|
+
const absent = (part.keys_present ?? []).filter((key) => section.data?.[key] === undefined || section.data?.[key] === null);
|
|
1037
|
+
const findings = [...missing, ...absent];
|
|
1038
|
+
if (findings.length) return result(withPath, 'stale', [`${section.path} has no ${findings.join(', ')}`]);
|
|
1039
|
+
return result(withPath, 'filled');
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function checkCredentials(context, part) {
|
|
1043
|
+
const section = readSection(context, part);
|
|
1044
|
+
const withPath = { ...part, path: section.path };
|
|
1045
|
+
if (section.outside) return result(withPath, 'stale', [`atlas.yaml points at "${section.path}", which is outside the repo; the check reads this repo only`]);
|
|
1046
|
+
if (section.missing) return result(withPath, 'empty', [`${section.path} does not exist`]);
|
|
1047
|
+
// The patterns run over the raw text FIRST, before anything reads the parse
|
|
1048
|
+
// result and before a parse error can be reported. A credential written with
|
|
1049
|
+
// bad indentation used to take the parse-error path out of this function, so
|
|
1050
|
+
// the one content check in the whole check never ran on the one file it
|
|
1051
|
+
// guards (security persona, finding S2-6).
|
|
1052
|
+
for (const pattern of context.secretPatterns) {
|
|
1053
|
+
if (pattern.test(section.text ?? '')) {
|
|
1054
|
+
context.errors.push(`${section.path} holds something that looks like a secret value; this file carries names only`);
|
|
1055
|
+
return result(withPath, 'stale', [`${section.path} holds something that looks like a secret value`]);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (section.error) return result(withPath, 'stale', [`${section.path} cannot be read: ${section.error}`]);
|
|
1059
|
+
const rows = section.data?.credentials;
|
|
1060
|
+
if (emptyValue(rows)) return result(withPath, 'stale', [`${section.path} has no credentials`]);
|
|
1061
|
+
const names = (Array.isArray(rows) ? rows : Object.values(rows)).map((row) => (typeof row === 'string' ? row : row?.name));
|
|
1062
|
+
const bad = names.filter((name) => !CREDENTIAL_NAME.test(String(name ?? '')));
|
|
1063
|
+
if (bad.length) return result(withPath, 'stale', [`${bad.length} credential name(s) are not written as A-Z, digits and underscore: ${bad.slice(0, MAX_LISTED).join(', ')}`]);
|
|
1064
|
+
return result(withPath, 'filled', [`${names.length} credential name(s)`]);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function checkDocsIndex(context, part) {
|
|
1068
|
+
const text = readText(context.root, part.path);
|
|
1069
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1070
|
+
const listed = new Set(pointers(text).map((path) => (path.startsWith('docs/') ? path : posix.join('docs', path))));
|
|
1071
|
+
const onDisk = walk(context.root, 'docs').filter((path) => path.endsWith('.md') && path !== part.path && !path.startsWith('docs/site/'));
|
|
1072
|
+
const unlisted = onDisk.filter((path) => !listed.has(path));
|
|
1073
|
+
const gone = [...listed].filter((path) => !existsSync(join(context.root, path)));
|
|
1074
|
+
const findings = [];
|
|
1075
|
+
if (unlisted.length) findings.push(`${unlisted.length} file(s) under docs/ are not listed: ${unlisted.slice(0, MAX_LISTED).join(', ')}`);
|
|
1076
|
+
if (gone.length) findings.push(`${gone.length} listed file(s) are gone: ${gone.slice(0, MAX_LISTED).join(', ')}`);
|
|
1077
|
+
// The docs tree outside docs/site/. The pages part below owns docs/site/.
|
|
1078
|
+
findings.push(...symlinkFindings(context.root, 'docs', (path) => !path.startsWith('docs/site/')));
|
|
1079
|
+
return findings.length ? result(part, 'stale', findings) : result(part, 'filled', [`${onDisk.length} file(s) listed`]);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function checkDocsPages(context, part) {
|
|
1083
|
+
const links = symlinkFindings(context.root, part.path);
|
|
1084
|
+
if (!isDirectory(context.root, part.path)) return result(part, 'empty', [`${part.path}/ does not exist`]);
|
|
1085
|
+
const pages = walk(context.root, part.path).filter((path) => path.endsWith('.md') && !path.endsWith('/notes.md'));
|
|
1086
|
+
if (!pages.length) return result(part, links.length ? 'stale' : 'empty', [`${part.path}/ holds no page`, ...links]);
|
|
1087
|
+
const without = pages.filter((path) => !frontMatter(readText(context.root, path) ?? '')?.built_from);
|
|
1088
|
+
const findings = [...(without.length ? [`${without.length} page(s) do not name the commit they were built from: ${without.slice(0, MAX_LISTED).join(', ')}`] : []), ...links];
|
|
1089
|
+
return findings.length ? result(part, 'stale', findings) : result(part, 'filled', [`${pages.length} page(s)`]);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function checkFeatures(context, part) {
|
|
1093
|
+
const text = readText(context.root, part.path);
|
|
1094
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1095
|
+
let data;
|
|
1096
|
+
try { data = parseYaml(text); } catch (error) { return result(part, 'stale', [`${part.path} cannot be read: ${error.message}`]); }
|
|
1097
|
+
const rows = data?.features;
|
|
1098
|
+
if (emptyValue(rows)) return result(part, 'stale', [`${part.path} lists no feature`]);
|
|
1099
|
+
const list = Array.isArray(rows) ? rows : Object.values(rows);
|
|
1100
|
+
const dead = list.filter((row) => row?.page && !existsSync(join(context.root, String(row.page)))).map((row) => row.page);
|
|
1101
|
+
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)`]);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function checkNotes(context, part) {
|
|
1105
|
+
const text = readText(context.root, part.path);
|
|
1106
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1107
|
+
// Finding R12 and the spec: each note names who said it and the session.
|
|
1108
|
+
// A note that does not is a stale note, not a remark on a filled file.
|
|
1109
|
+
const entries = text.split('\n').filter((line) => /^\s*[-*]\s+/.test(line));
|
|
1110
|
+
if (!entries.length) return result(part, 'empty', [`${part.path} holds no note`]);
|
|
1111
|
+
const without = entries.filter((line) => !/session/i.test(line));
|
|
1112
|
+
if (without.length) return result(part, 'stale', [`${without.length} of ${entries.length} note(s) do not name the session they came from`]);
|
|
1113
|
+
return result(part, 'filled', [`${entries.length} note(s)`]);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function checkGenerated(context, part) {
|
|
1117
|
+
const available = context.extractors.includes(part.extractor);
|
|
1118
|
+
if (!available) return result(part, 'no extractor', [`no ${part.extractor} extractor exists yet; slice L3 builds it`]);
|
|
1119
|
+
const text = readText(context.root, part.path);
|
|
1120
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1121
|
+
return result(part, 'filled');
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Every permission the workflow grants at anything other than read. It reads a
|
|
1125
|
+
// block map under `permissions:` and a flow map on the same line.
|
|
1126
|
+
function writeGrants(text) {
|
|
1127
|
+
const out = [];
|
|
1128
|
+
const flow = /^\s*permissions\s*:\s*\{([^}]*)\}/m.exec(text);
|
|
1129
|
+
if (flow) {
|
|
1130
|
+
for (const pair of flow[1].split(',')) {
|
|
1131
|
+
const [name, value] = pair.split(':').map((piece) => (piece ?? '').trim());
|
|
1132
|
+
if (name && value && value !== 'read' && value !== 'none') out.push(`${name}: ${value}`);
|
|
1133
|
+
}
|
|
1134
|
+
return out;
|
|
1135
|
+
}
|
|
1136
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
1137
|
+
const head = lines.findIndex((line) => /^\s*permissions\s*:\s*$/.test(line));
|
|
1138
|
+
if (head === -1) return out;
|
|
1139
|
+
const indent = lines[head].length - lines[head].trimStart().length;
|
|
1140
|
+
for (let index = head + 1; index < lines.length; index += 1) {
|
|
1141
|
+
const line = lines[index];
|
|
1142
|
+
if (!line.trim()) continue;
|
|
1143
|
+
if (line.length - line.trimStart().length <= indent) break;
|
|
1144
|
+
const [name, value] = line.split(':').map((piece) => (piece ?? '').trim());
|
|
1145
|
+
if (name && value && value !== 'read' && value !== 'none') out.push(`${name}: ${value}`);
|
|
1146
|
+
}
|
|
1147
|
+
return out;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Everything the check can only learn from the workflow's STRUCTURE, read from
|
|
1151
|
+
// the parsed document and never from a line of text.
|
|
1152
|
+
//
|
|
1153
|
+
// The first version of this read lines. The acceptance reviewer of batch 16
|
|
1154
|
+
// defeated it nine ways (finding S2-1). Three of those mattered most: replace
|
|
1155
|
+
// the step that runs the check, delete it, or set `steps: []`, and the part
|
|
1156
|
+
// still read "filled". A rule about "an `if:` on the check step" means nothing
|
|
1157
|
+
// when nothing requires the check step to be there at all.
|
|
1158
|
+
//
|
|
1159
|
+
// A workflow this function cannot read is a finding, not a pass. That is the
|
|
1160
|
+
// safe direction: the check says it cannot confirm the job runs, instead of
|
|
1161
|
+
// saying the job is fine.
|
|
1162
|
+
function workflowStructure(document) {
|
|
1163
|
+
const out = [];
|
|
1164
|
+
const jobs = document?.jobs;
|
|
1165
|
+
if (!jobs || typeof jobs !== 'object') return ['the workflow declares no jobs'];
|
|
1166
|
+
|
|
1167
|
+
// The trigger. `paths` and `paths-ignore` stop the job from running at all on
|
|
1168
|
+
// the pull requests that change the files it guards, and the pull request
|
|
1169
|
+
// still reads green, because a job that never ran reports nothing (P13).
|
|
1170
|
+
const on = document.on;
|
|
1171
|
+
const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.pull_request : null;
|
|
1172
|
+
if (trigger && typeof trigger === 'object' && !Array.isArray(trigger)) {
|
|
1173
|
+
for (const key of ['paths', 'paths-ignore']) {
|
|
1174
|
+
if (trigger[key] !== undefined && trigger[key] !== null) {
|
|
1175
|
+
out.push(`the workflow narrows its trigger with ${key}; this check must run on every pull request`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// The step that runs the check. Without it the workflow is a workflow that
|
|
1181
|
+
// runs nothing, and every rule below has nothing to attach to.
|
|
1182
|
+
let found = null;
|
|
1183
|
+
for (const [jobName, job] of Object.entries(jobs)) {
|
|
1184
|
+
for (const step of Array.isArray(job?.steps) ? job.steps : []) {
|
|
1185
|
+
const runs = typeof step?.run === 'string' ? step.run : '';
|
|
1186
|
+
if (runs.includes(CHECK_PATH)) found = { jobName, job, step, runs };
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (!found) {
|
|
1190
|
+
out.push(`no step in this workflow runs "node ${CHECK_PATH}", so the job reports nothing`);
|
|
1191
|
+
return out;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// The ratchet's own two inputs, from slice L2 stage two.
|
|
1195
|
+
//
|
|
1196
|
+
// Without them the check still runs, still prints a report, and still goes
|
|
1197
|
+
// green — and the ratchet never runs at all. That is the same class of hole
|
|
1198
|
+
// the batch 16 reviewer found nine ways (finding S2-1): a workflow that
|
|
1199
|
+
// silences the job while the part still reads "filled". Deleting two flags
|
|
1200
|
+
// is the cheapest version of it, so the scanner reads the flags, not just
|
|
1201
|
+
// the path.
|
|
1202
|
+
for (const flag of ['--base', '--base-sha']) {
|
|
1203
|
+
if (!new RegExp(`${flag}(?:[ \t=]|$)`, 'm').test(found.runs)) {
|
|
1204
|
+
out.push(`the check step does not pass ${flag}, so the ratchet never runs and a pull request can take a part away with the job still green`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
// And the tree it compares against has to be fetched by something.
|
|
1208
|
+
const fetchesBase = Object.values(jobs).some((job) => (Array.isArray(job?.steps) ? job.steps : [])
|
|
1209
|
+
.some((step) => typeof step?.uses === 'string' && step.uses.startsWith('actions/checkout') && step?.with?.path === BASE_DIR));
|
|
1210
|
+
if (!fetchesBase) {
|
|
1211
|
+
out.push(`no step checks the base commit out into ${BASE_DIR}, so the ratchet has no tree to compare against`);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// A condition switches the step, or its whole job, off. Same effect as a
|
|
1215
|
+
// narrowed trigger, by another route.
|
|
1216
|
+
if (found.step.if !== undefined) out.push('the workflow puts an if: on the check step; the step must always run');
|
|
1217
|
+
if (found.job.if !== undefined) out.push("the workflow puts an if: on the check's job; the job must always run");
|
|
1218
|
+
// Two more ways to make a red check look green. Neither is in the frozen
|
|
1219
|
+
// list. Both defeat the same purpose, and stage two's ratchet needs a red job
|
|
1220
|
+
// to mean something, so the check names them (reviewer of batch 16, S2-1).
|
|
1221
|
+
if (found.step['continue-on-error'] === true || found.job['continue-on-error'] === true) {
|
|
1222
|
+
out.push('the workflow sets continue-on-error on the check; a failed check would not turn the job red');
|
|
1223
|
+
}
|
|
1224
|
+
if (/\|\|\s*(true|exit\s+0|:)\s*$/m.test(found.runs)) {
|
|
1225
|
+
out.push('the check step swallows its own exit code, so a failed check would not turn the job red');
|
|
1226
|
+
}
|
|
1227
|
+
return out;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function checkWorkflow(context, part) {
|
|
1231
|
+
const text = readText(context.root, part.path);
|
|
1232
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1233
|
+
const findings = [];
|
|
1234
|
+
if (/^\s*pull_request_target\s*:/m.test(text)) findings.push('the workflow runs on pull_request_target; it must run on pull_request');
|
|
1235
|
+
if (!/^\s*pull_request\s*:/m.test(text)) findings.push('the workflow does not run on pull_request');
|
|
1236
|
+
// The structure, from the parsed document. A workflow this check cannot read
|
|
1237
|
+
// is a finding: it can no longer say the job runs at all.
|
|
1238
|
+
try {
|
|
1239
|
+
findings.push(...workflowStructure(parseYaml(text)));
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
findings.push(`${part.path} cannot be read, so the check cannot confirm this workflow runs it: ${error instanceof YamlError ? error.message : 'the file is not the YAML subset this check reads'}`);
|
|
1242
|
+
}
|
|
1243
|
+
if (!/^\s*timeout-minutes\s*:\s*\d+\s*$/m.test(text)) findings.push('the workflow sets no timeout-minutes; a run with no wall-clock bound is billed until it is killed');
|
|
1244
|
+
if (!/^\s*contents\s*:\s*read\s*$/m.test(text)) findings.push('the workflow does not set permissions contents: read');
|
|
1245
|
+
// A write permission beside the read one still grants write. GitHub accepts
|
|
1246
|
+
// a block map and a flow map for both permissions and steps, so read both
|
|
1247
|
+
// spellings or a workflow can widen itself past the check (review R-3).
|
|
1248
|
+
for (const grant of writeGrants(text)) findings.push(`the workflow grants ${grant}; this check needs read access only`);
|
|
1249
|
+
if (/\bsecrets\./.test(text)) findings.push('the workflow reads a repository secret; this check takes none');
|
|
1250
|
+
const unpinned = [...text.matchAll(/uses[ \t]*:[ \t]*["']?([^\s,}\]"']+)/g)]
|
|
1251
|
+
.map(([, value]) => value)
|
|
1252
|
+
.filter((value) => !value.startsWith('./'))
|
|
1253
|
+
.filter((value) => !PINNED.test(value.split('@')[1] ?? ''));
|
|
1254
|
+
if (unpinned.length) findings.push(`${unpinned.length} action(s) are not pinned by commit SHA: ${unpinned.slice(0, MAX_LISTED).join(', ')}`);
|
|
1255
|
+
return findings.length ? result(part, 'stale', findings) : result(part, 'filled');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// The stamps a copy of the check carries, read from its text. Reading the text
|
|
1259
|
+
// works for any copy; importing it would only work for this one.
|
|
1260
|
+
//
|
|
1261
|
+
// A copy from before row F11 carries no CHECK_VERSION. When it declares a spec,
|
|
1262
|
+
// it still reads as the Atlas check, so the ratchet can score a base tree that
|
|
1263
|
+
// holds an old copy.
|
|
1264
|
+
export function checkStamps(text) {
|
|
1265
|
+
const check = /^export const CHECK_VERSION = (\d+);/m.exec(text ?? '');
|
|
1266
|
+
const specs = [...String(text ?? '').matchAll(/^export const SPEC_(\d+) = /gm)].map(([, version_]) => Number(version_));
|
|
1267
|
+
return {
|
|
1268
|
+
check_version: check ? Number(check[1]) : null,
|
|
1269
|
+
version: check ? `check ${check[1]}` : specs.length ? 'a check from before check versions' : null,
|
|
1270
|
+
specs: specs.sort((a, b) => a - b),
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// File 7's own part, from spec 2. The check is judging its own sibling here, so
|
|
1275
|
+
// it says only what a clone can prove. It never asks whether the copy is
|
|
1276
|
+
// current: CI has no package to compare with, and a check with no network
|
|
1277
|
+
// cannot learn the package's hash. The session verb makes that judgement from
|
|
1278
|
+
// the package's release list (amendment A1.9), and the merge actor makes it
|
|
1279
|
+
// again at slice L4.
|
|
1280
|
+
//
|
|
1281
|
+
// What it does prove, and what it puts in the report: the copy is there, it is
|
|
1282
|
+
// the Atlas check, it can read the spec version this repo declares, and its
|
|
1283
|
+
// sha256. The hash is the value the session verb and the merge actor compare,
|
|
1284
|
+
// so the report is where they read it.
|
|
1285
|
+
function checkCheckFile(context, part) {
|
|
1286
|
+
const text = readText(context.root, part.path);
|
|
1287
|
+
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing; this repo runs no check of its own`]);
|
|
1288
|
+
const stamps = checkStamps(text);
|
|
1289
|
+
if (!stamps.version || !stamps.specs.length) {
|
|
1290
|
+
return result(part, 'stale', [`${part.path} does not read as the Atlas check: it carries no check version or no spec`]);
|
|
1291
|
+
}
|
|
1292
|
+
const declared = Number(context.facts?.spec);
|
|
1293
|
+
const findings = [stamps.version, `reads spec ${stamps.specs.join(', ')}`, `sha256 ${sha256(text)}`];
|
|
1294
|
+
if (Number.isInteger(declared) && !stamps.specs.includes(declared)) {
|
|
1295
|
+
return result(part, 'stale', [...findings, `this copy cannot read spec ${declared}, which atlas.yaml declares; refresh it`]);
|
|
1296
|
+
}
|
|
1297
|
+
return result(part, 'filled', findings);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function checkLinters(context, part) {
|
|
1301
|
+
const found = (part.candidates ?? []).filter((path) => existsSync(join(context.root, path)));
|
|
1302
|
+
return found.length ? result(part, 'filled', [found.slice(0, MAX_LISTED).join(', ')]) : result(part, 'empty', ['no linter or formatter config at the root']);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const CHECKS = {
|
|
1306
|
+
markdown: checkMarkdown,
|
|
1307
|
+
'path-rules': checkPathRules,
|
|
1308
|
+
procedure: checkProcedure,
|
|
1309
|
+
'facts-root': checkFactsRoot,
|
|
1310
|
+
'facts-section': checkFactsSection,
|
|
1311
|
+
'facts-credentials': checkCredentials,
|
|
1312
|
+
'docs-index': checkDocsIndex,
|
|
1313
|
+
'docs-pages': checkDocsPages,
|
|
1314
|
+
features: checkFeatures,
|
|
1315
|
+
notes: checkNotes,
|
|
1316
|
+
generated: checkGenerated,
|
|
1317
|
+
workflow: checkWorkflow,
|
|
1318
|
+
'check-file': checkCheckFile,
|
|
1319
|
+
linters: checkLinters,
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
// The one exemption in the by-reference set, and it is exactly one.
|
|
1323
|
+
//
|
|
1324
|
+
// File 1, the entry map, must hold a pointer to each of the seven files, and
|
|
1325
|
+
// `docs/index.md` is one of them. Rule R1 makes any `docs/` file an instruction
|
|
1326
|
+
// file points at instruction class by reference. Those two together make the
|
|
1327
|
+
// index instruction class, and the frozen L4 path limit lets the upkeep routine
|
|
1328
|
+
// self-merge it. Three rules, and the index can satisfy none of them.
|
|
1329
|
+
//
|
|
1330
|
+
// The owner cut the knot: a pointer from the entry map to
|
|
1331
|
+
// `docs/index.md` does not make the index instruction class. An index lists
|
|
1332
|
+
// files; it does not steer an agent.
|
|
1333
|
+
//
|
|
1334
|
+
// Bounded three ways, because the reason is true of a list and false of prose:
|
|
1335
|
+
// only this one path, only these two pointing files, and the row stays in the
|
|
1336
|
+
// report with `exempt: true` so nothing is hidden. Any other instruction file
|
|
1337
|
+
// that names the index still makes it instruction class, and the index's own
|
|
1338
|
+
// row then carries that file. The docs lint of slice L3 bounds the rest, by
|
|
1339
|
+
// treating the index as an agent-facing surface.
|
|
1340
|
+
export const EXEMPT_PATH = 'docs/index.md';
|
|
1341
|
+
export const EXEMPT_FROM = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
|
|
1342
|
+
|
|
1343
|
+
export function applyExemption(rows) {
|
|
1344
|
+
return rows.map((row) => {
|
|
1345
|
+
if (row.path !== EXEMPT_PATH) return row;
|
|
1346
|
+
const kept = row.pointed_at_by.filter((path) => !EXEMPT_FROM.includes(path));
|
|
1347
|
+
if (kept.length === row.pointed_at_by.length) return row;
|
|
1348
|
+
const exempt = !kept.length;
|
|
1349
|
+
return {
|
|
1350
|
+
...row,
|
|
1351
|
+
pointed_at_by: kept,
|
|
1352
|
+
exempt,
|
|
1353
|
+
exempt_note: exempt
|
|
1354
|
+
? `the entry map points here and that alone does not make it instruction class; nothing else names it`
|
|
1355
|
+
: `the entry map points here and that alone does not make it instruction class; ${kept.join(', ')} still does`,
|
|
1356
|
+
};
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function byReference(context, spec) {
|
|
1361
|
+
const scanned = [];
|
|
1362
|
+
for (const pattern of spec.by_reference?.scanned ?? []) {
|
|
1363
|
+
if (!pattern.includes('*')) { if (isFile(context.root, pattern)) scanned.push(pattern); continue; }
|
|
1364
|
+
const [head] = pattern.split('*');
|
|
1365
|
+
const directory = head.replace(/\/+$/, '');
|
|
1366
|
+
const tail = pattern.slice(pattern.lastIndexOf('*') + 1);
|
|
1367
|
+
for (const file of walk(context.root, directory)) if (file.endsWith(tail)) scanned.push(file);
|
|
1368
|
+
}
|
|
1369
|
+
const found = new Map();
|
|
1370
|
+
for (const file of scanned) {
|
|
1371
|
+
for (const path of docsReferences(readText(context.root, file) ?? '')) {
|
|
1372
|
+
if (!found.has(path)) found.set(path, { path, exists: existsSync(join(context.root, path)), pointed_at_by: [] });
|
|
1373
|
+
found.get(path).pointed_at_by.push(file);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return applyExemption([...found.values()].sort((a, b) => a.path.localeCompare(b.path)));
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
export function readFacts(root) {
|
|
1380
|
+
const text = readText(root, 'atlas.yaml');
|
|
1381
|
+
if (text === null || !text.trim()) return { facts: null, error: null };
|
|
1382
|
+
try {
|
|
1383
|
+
const data = parseYaml(text);
|
|
1384
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return { facts: null, error: 'atlas.yaml is not a map of keys' };
|
|
1385
|
+
return { facts: data, error: null };
|
|
1386
|
+
} catch (error) { return { facts: null, error: `atlas.yaml cannot be read: ${error.message}` }; }
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// `scoreAs` is the ratchet's one lever. It replaces the kind and the spec this
|
|
1390
|
+
// tree declares with the ones handed in, so two trees are scored by the same
|
|
1391
|
+
// yardstick. Without it the base's `kind: service` and the head's
|
|
1392
|
+
// `kind: library` would be two different spec tables, and the comparison would
|
|
1393
|
+
// mean nothing (architect finding P2). Nothing else about the scan changes: the
|
|
1394
|
+
// tree still speaks for itself about every part.
|
|
1395
|
+
export function scan({ root, repoId = null, extractors = [], secretPatterns: patterns = secretPatterns(), scoreAs = null }) {
|
|
1396
|
+
const { facts, error: factsError } = readFacts(root);
|
|
1397
|
+
const errors = [];
|
|
1398
|
+
// The credential-value check is the only thing that turns the job red at the
|
|
1399
|
+
// shipped fail-on level. An empty pattern list disarms it, so it is an error,
|
|
1400
|
+
// never a silent pass (review R-6).
|
|
1401
|
+
// A list that is not a list of patterns used to crash here. It must refuse
|
|
1402
|
+
// the same way an empty list does (acceptance review of batch 14b, finding 10).
|
|
1403
|
+
const usable = Array.isArray(patterns) && patterns.every((pattern) => pattern instanceof RegExp) ? patterns : [];
|
|
1404
|
+
if (!usable.length) errors.push('the secret pattern list is empty or is not a list of patterns, so a credential value could not be caught; this check cannot be trusted');
|
|
1405
|
+
// Counting the patterns was never enough. Each one is tested against its own
|
|
1406
|
+
// canary, so a pattern edited to match nothing is loud (finding S2-5).
|
|
1407
|
+
const missed = canaryMisses();
|
|
1408
|
+
if (missed.length) errors.push(`${missed.length} secret pattern(s) no longer catch their own canary: ${missed.join(', ')}; this check cannot be trusted`);
|
|
1409
|
+
if (!facts && !factsError) errors.push('atlas.yaml does not exist, so the check cannot learn the spec version or the repo kind');
|
|
1410
|
+
if (factsError) errors.push(factsError);
|
|
1411
|
+
const kind = scoreAs ? scoreAs.kind ?? null : facts?.kind ?? null;
|
|
1412
|
+
const wantedSpec = scoreAs ? scoreAs.spec : facts?.spec;
|
|
1413
|
+
let spec = null;
|
|
1414
|
+
if (facts) {
|
|
1415
|
+
try { spec = loadSpec(wantedSpec); }
|
|
1416
|
+
catch (error) { errors.push(error.message); }
|
|
1417
|
+
}
|
|
1418
|
+
if (spec && !spec.kinds[kind]) { errors.push(`atlas.yaml names kind "${kind}"; spec ${spec.spec} knows ${Object.keys(spec.kinds).join(', ')}`); spec = null; }
|
|
1419
|
+
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 };
|
|
1420
|
+
|
|
1421
|
+
const context = { root, facts, factsError, repoId, extractors, secretPatterns: usable, errors };
|
|
1422
|
+
// atlas.yaml's own values reach the report: the kind, the repo id, a
|
|
1423
|
+
// procedure's skill name, every pointer path. Scan it before any of them is
|
|
1424
|
+
// quoted (acceptance review of batch 16, note S3-2).
|
|
1425
|
+
secretIn(context, 'atlas.yaml', readText(root, 'atlas.yaml'));
|
|
1426
|
+
const files = spec.files.map((file) => ({
|
|
1427
|
+
id: file.id,
|
|
1428
|
+
number: file.number,
|
|
1429
|
+
title: file.title,
|
|
1430
|
+
class: file.class,
|
|
1431
|
+
written_by: file.written_by,
|
|
1432
|
+
parts: file.parts.map((part) => {
|
|
1433
|
+
const required = entryFor(part, kind);
|
|
1434
|
+
if (required === 'not applicable') return { ...result(part, 'not applicable', [`a ${kind} repo does not need it`]), required };
|
|
1435
|
+
const check = CHECKS[part.check];
|
|
1436
|
+
if (!check) throw new SpecError(`the spec names check "${part.check}", which this version of the check does not have`);
|
|
1437
|
+
return { ...check(context, part), required };
|
|
1438
|
+
}),
|
|
1439
|
+
}));
|
|
1440
|
+
return { ok: !errors.length, check_version: CHECK_VERSION, repo: repoId ?? facts.repo ?? null, kind, spec: spec.spec, scored_as: scoreAs, product: facts.product ?? null, files, by_reference: byReference(context, spec), counts: counts(files), errors };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
export function counts(files) {
|
|
1444
|
+
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 };
|
|
1445
|
+
for (const file of files) for (const part of file.parts) {
|
|
1446
|
+
out[part.state] += 1;
|
|
1447
|
+
if (part.required === 'required') out.required += 1;
|
|
1448
|
+
if (part.required === 'optional') out.optional += 1;
|
|
1449
|
+
if (NOT_FILLED.includes(part.state)) {
|
|
1450
|
+
out.not_filled += 1;
|
|
1451
|
+
if (part.required === 'required') out.required_not_filled += 1;
|
|
1452
|
+
if (part.required === 'optional') out.optional_not_filled += 1;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return out;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ---------------------------------------------------------------------------
|
|
1459
|
+
// The ratchet. Milestone M2, slice L2 stage two.
|
|
1460
|
+
//
|
|
1461
|
+
// What it is. A pull request may not take away an instruction-class part that
|
|
1462
|
+
// the base commit already had. Nothing else. It is never red on debt that was
|
|
1463
|
+
// there before, and it is red on the pull request that made things worse.
|
|
1464
|
+
//
|
|
1465
|
+
// Why a ratchet and not a level. Turning `fail-on` up to `required` goes red on
|
|
1466
|
+
// the day it is switched on, for parts a later slice builds. The first repo it
|
|
1467
|
+
// ran on had seven required parts not filled, four of them a later slice's
|
|
1468
|
+
// work. A level
|
|
1469
|
+
// asks the repository to be finished. A ratchet asks it not to go backwards.
|
|
1470
|
+
//
|
|
1471
|
+
// Why per part and never a count. A count is defeated two ways. Empty one part,
|
|
1472
|
+
// fill another, and the total does not move. Or change one line: `atlas.yaml`
|
|
1473
|
+
// names the `kind`, the kind decides which parts are required, and `atlas.yaml`
|
|
1474
|
+
// is inside the diff being scored, so `kind: service` to `kind: library` turns
|
|
1475
|
+
// four required parts into "not applicable" and the count falls. Scoring per
|
|
1476
|
+
// part, with the base tree's `kind` and `spec`, closes both (findings P2, P20).
|
|
1477
|
+
//
|
|
1478
|
+
// Which copy scores. The head's. That is this file. A pull request that
|
|
1479
|
+
// improves the check would otherwise turn red under the old scanners, and a
|
|
1480
|
+
// pull request that weakens one would turn green under them. The head's copy
|
|
1481
|
+
// judging its own diff would be a hole, so the two cases where the diff touches
|
|
1482
|
+
// the gate are the first two report-only cases below: a person merges those
|
|
1483
|
+
// files, and the check says plainly that it did not gate (finding P1).
|
|
1484
|
+
//
|
|
1485
|
+
// Where it lives. Here. The repository still carries two files and only two.
|
|
1486
|
+
// ---------------------------------------------------------------------------
|
|
1487
|
+
|
|
1488
|
+
// The five cases where the ratchet reports and does not gate. Each is named in
|
|
1489
|
+
// the report, so a reader never has to guess why the job stayed green.
|
|
1490
|
+
export const RATCHET_CASES = Object.freeze(['check-file', 'workflow', 'kind', 'spec', 'base-tree']);
|
|
1491
|
+
|
|
1492
|
+
const fileHash = (root, path) => { const text = readText(root, path); return text === null ? null : sha256(text); };
|
|
1493
|
+
|
|
1494
|
+
// The commit a checkout sits on, read from `.git/HEAD` as text. No git binary,
|
|
1495
|
+
// no network. `actions/checkout` leaves a detached HEAD, so the first line is
|
|
1496
|
+
// usually the SHA itself; a `ref:` line is followed once.
|
|
1497
|
+
export function checkoutHead(root) {
|
|
1498
|
+
const head = readText(root, '.git/HEAD');
|
|
1499
|
+
if (head === null) return null;
|
|
1500
|
+
const line = head.trim();
|
|
1501
|
+
if (/^[0-9a-f]{40}$/.test(line)) return line;
|
|
1502
|
+
const named = /^ref:\s*(\S+)$/.exec(line);
|
|
1503
|
+
if (!named) return null;
|
|
1504
|
+
const loose = readText(root, posix.join('.git', named[1]));
|
|
1505
|
+
if (loose && /^[0-9a-f]{40}$/.test(loose.trim())) return loose.trim();
|
|
1506
|
+
// A clone may pack its refs instead of writing one file each.
|
|
1507
|
+
for (const row of (readText(root, '.git/packed-refs') ?? '').split('\n')) {
|
|
1508
|
+
const [sha, ref] = row.trim().split(/\s+/);
|
|
1509
|
+
if (ref === named[1] && /^[0-9a-f]{40}$/.test(sha ?? '')) return sha;
|
|
1510
|
+
}
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const partIndex = (report) => {
|
|
1515
|
+
const map = new Map();
|
|
1516
|
+
for (const file of report.files) for (const part of file.parts) map.set(`${file.id}/${part.id}`, { file, part });
|
|
1517
|
+
return map;
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
export function ratchet({ headRoot, baseRoot, baseSha = null, repoId = null, secretPatterns: patterns = secretPatterns() }) {
|
|
1521
|
+
const out = {
|
|
1522
|
+
ran: true,
|
|
1523
|
+
gates: true,
|
|
1524
|
+
green: true,
|
|
1525
|
+
base_dir: baseRoot,
|
|
1526
|
+
base_sha: baseSha,
|
|
1527
|
+
base_head: null,
|
|
1528
|
+
base_facts: null,
|
|
1529
|
+
head_facts: null,
|
|
1530
|
+
parts_compared: 0,
|
|
1531
|
+
report_only: [],
|
|
1532
|
+
regressions: [],
|
|
1533
|
+
};
|
|
1534
|
+
const excuse = (name, why) => { out.gates = false; out.report_only.push({ case: name, why }); };
|
|
1535
|
+
|
|
1536
|
+
// 1. Is there a base tree at all, and is it the pull request's base?
|
|
1537
|
+
const baseRead = readFacts(baseRoot);
|
|
1538
|
+
if (!existsSync(join(baseRoot, 'atlas.yaml'))) {
|
|
1539
|
+
excuse('base-tree', `no base tree at ${baseRoot}: the checkout of the base commit did not land, so there is nothing to compare`);
|
|
1540
|
+
return out;
|
|
1541
|
+
}
|
|
1542
|
+
if (baseRead.error || !baseRead.facts) {
|
|
1543
|
+
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`);
|
|
1544
|
+
return out;
|
|
1545
|
+
}
|
|
1546
|
+
out.base_head = checkoutHead(baseRoot);
|
|
1547
|
+
// The pull request promised a base commit. If the tree cannot say which
|
|
1548
|
+
// commit it is, the promise is unchecked, and an unchecked promise is not a
|
|
1549
|
+
// base tree. Reporting a sha this check was handed but never read would be a
|
|
1550
|
+
// claim it cannot support, so it takes the base-tree case instead.
|
|
1551
|
+
if (baseSha && !out.base_head) {
|
|
1552
|
+
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`);
|
|
1553
|
+
return out;
|
|
1554
|
+
}
|
|
1555
|
+
if (baseSha && out.base_head !== baseSha) {
|
|
1556
|
+
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`);
|
|
1557
|
+
return out;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// 2. The base tree's kind and spec score both trees.
|
|
1561
|
+
const scoreAs = { kind: baseRead.facts.kind ?? null, spec: baseRead.facts.spec ?? null };
|
|
1562
|
+
const headRead = readFacts(headRoot);
|
|
1563
|
+
out.base_facts = scoreAs;
|
|
1564
|
+
out.head_facts = { kind: headRead.facts?.kind ?? null, spec: headRead.facts?.spec ?? null };
|
|
1565
|
+
|
|
1566
|
+
if (String(out.head_facts.kind) !== String(scoreAs.kind)) {
|
|
1567
|
+
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`);
|
|
1568
|
+
}
|
|
1569
|
+
if (String(out.head_facts.spec) !== String(scoreAs.spec)) {
|
|
1570
|
+
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`);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// 3. Does the diff change the gate itself?
|
|
1574
|
+
for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH]]) {
|
|
1575
|
+
if (fileHash(baseRoot, path) !== fileHash(headRoot, path)) {
|
|
1576
|
+
excuse(name, `this pull request changes ${path}, which is the gate itself; it is instruction class, so a person merges it`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// 4. Score both trees, by the same yardstick, and compare part by part.
|
|
1581
|
+
const base = scan({ root: baseRoot, repoId, secretPatterns: patterns, scoreAs });
|
|
1582
|
+
const head = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs });
|
|
1583
|
+
if (!base.files.length) {
|
|
1584
|
+
excuse('base-tree', `the base tree at ${baseRoot} could not be scored: ${base.errors.join('; ') || 'no parts came back'}`);
|
|
1585
|
+
return out;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const headParts = partIndex(head);
|
|
1589
|
+
for (const [key, { file, part }] of partIndex(base)) {
|
|
1590
|
+
if (file.class !== 'instruction' || part.state !== 'filled') continue;
|
|
1591
|
+
out.parts_compared += 1;
|
|
1592
|
+
const now = headParts.get(key);
|
|
1593
|
+
if (!now) {
|
|
1594
|
+
out.regressions.push({ part: key, label: label(part), was: 'filled', now: 'gone', why: `the spec part ${key} was filled at the base and this copy of the check no longer scores it` });
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
if (now.part.state !== 'filled') {
|
|
1598
|
+
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` });
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
out.green = !out.regressions.length;
|
|
1602
|
+
return out;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ---------------------------------------------------------------------------
|
|
1606
|
+
// The report. Two shapes: plain text for the CI log, and GitHub markdown for
|
|
1607
|
+
// the job summary. Both list every part, its state and its findings.
|
|
1608
|
+
// ---------------------------------------------------------------------------
|
|
1609
|
+
|
|
1610
|
+
const MARK = { filled: '+', empty: '-', stale: '~', 'not applicable': '.', 'no extractor': '?' };
|
|
1611
|
+
const label = (part) => part.procedure ? `procedure ${part.procedure}` : part.path ?? part.id;
|
|
1612
|
+
|
|
1613
|
+
// The one number the owner sees is the count of REQUIRED files not filled.
|
|
1614
|
+
// An optional file that is empty is not a gap: the spec says optional (review
|
|
1615
|
+
// finding S2-1). The optional count follows it, so nothing is hidden.
|
|
1616
|
+
export function headline(report) {
|
|
1617
|
+
if (!report.spec) return 'Atlas repo shape: the check could not run.';
|
|
1618
|
+
const { counts: seen } = report;
|
|
1619
|
+
return `Atlas repo shape: ${seen.required_not_filled} of ${seen.required} required file(s) not filled; `
|
|
1620
|
+
+ `${seen.optional_not_filled} of ${seen.optional} optional; ${seen['not applicable']} not applicable. `
|
|
1621
|
+
+ `Kind: ${report.kind}. Spec: ${report.spec}. Check version: ${report.check_version}.`;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
export function toText(report) {
|
|
1625
|
+
const lines = [headline(report), ''];
|
|
1626
|
+
for (const error of report.errors) lines.push(`ERROR ${error}`);
|
|
1627
|
+
if (report.errors.length) lines.push('');
|
|
1628
|
+
for (const file of report.files) {
|
|
1629
|
+
lines.push(`${file.number}. ${file.title} (${file.class})`);
|
|
1630
|
+
for (const part of file.parts) {
|
|
1631
|
+
lines.push(` ${MARK[part.state]} ${part.state.padEnd(14)} ${part.required.padEnd(8)} ${label(part)}`);
|
|
1632
|
+
for (const finding of part.findings) lines.push(` - ${finding}`);
|
|
1633
|
+
}
|
|
1634
|
+
lines.push('');
|
|
1635
|
+
}
|
|
1636
|
+
const instruction = report.by_reference.filter((row) => !row.exempt);
|
|
1637
|
+
lines.push(`By reference: ${instruction.length} file(s) under docs/ are named by an instruction file, so they are instruction class too.`);
|
|
1638
|
+
for (const row of report.by_reference) {
|
|
1639
|
+
lines.push(` ${row.exists ? '+' : '-'} ${row.path} <- ${row.pointed_at_by.join(', ') || 'the entry map only'}${row.exempt ? ' [exempt]' : ''}`);
|
|
1640
|
+
if (row.exempt_note) lines.push(` - ${row.exempt_note}`);
|
|
1641
|
+
}
|
|
1642
|
+
if (report.ratchet) lines.push('', ...ratchetLines(report.ratchet));
|
|
1643
|
+
return `${lines.join('\n')}\n`;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// The ratchet, in the CI log. It says which tree it compared against, what it
|
|
1647
|
+
// compared, and either the parts that went backwards or the case that made it
|
|
1648
|
+
// report instead of gate.
|
|
1649
|
+
export function ratchetLines(state) {
|
|
1650
|
+
const out = ['Ratchet: instruction-class parts, base to head.'];
|
|
1651
|
+
// The sha this check READ from the base tree, never the one it was handed.
|
|
1652
|
+
// Printing the promise would assert a commit nothing verified.
|
|
1653
|
+
const read = state.base_head ? state.base_head.slice(0, 12) : 'a commit the tree does not name';
|
|
1654
|
+
const promised = state.base_sha ? `, named ${state.base_sha.slice(0, 12)} by the pull request` : '';
|
|
1655
|
+
out.push(` base ${read} in ${state.base_dir}${promised}`);
|
|
1656
|
+
if (state.base_facts) out.push(` scored both trees as kind ${state.base_facts.kind}, spec ${state.base_facts.spec}, from the base tree`);
|
|
1657
|
+
if (state.parts_compared) out.push(` ${state.parts_compared} instruction-class part(s) were filled at the base`);
|
|
1658
|
+
for (const row of state.report_only) out.push(` REPORT ONLY (${row.case}): ${row.why}`);
|
|
1659
|
+
for (const row of state.regressions) {
|
|
1660
|
+
out.push(` BACKWARDS ${row.label}: ${row.was} at the base, ${row.now} at the head`);
|
|
1661
|
+
for (const finding of row.findings ?? []) out.push(` - ${finding}`);
|
|
1662
|
+
}
|
|
1663
|
+
if (!state.regressions.length && state.base_facts) out.push(' no instruction-class part went backwards');
|
|
1664
|
+
if (!state.gates) {
|
|
1665
|
+
out.push(' this ratchet reports and does not gate on this pull request');
|
|
1666
|
+
// The loudest line in the report, and the one a reader must not miss. The
|
|
1667
|
+
// frozen text excuses these five cases because a person merges every file
|
|
1668
|
+
// that causes one. That is a compensating control, not an absence of
|
|
1669
|
+
// findings, so the findings are said out loud beside the green.
|
|
1670
|
+
if (state.regressions.length) {
|
|
1671
|
+
out.push(` WARNING: ${state.regressions.length} part(s) above went backwards and this job is NOT red for them.`);
|
|
1672
|
+
out.push(' A person merges this pull request. Read the BACKWARDS lines before you do.');
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return out;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
export function toMarkdown(report) {
|
|
1679
|
+
const out = ['## Atlas repo shape', '', headline(report), ''];
|
|
1680
|
+
if (report.errors.length) {
|
|
1681
|
+
out.push('### Errors', '');
|
|
1682
|
+
for (const error of report.errors) out.push(`- ${error}`);
|
|
1683
|
+
out.push('');
|
|
1684
|
+
}
|
|
1685
|
+
for (const file of report.files) {
|
|
1686
|
+
out.push(`### ${file.number}. ${file.title}`, '', '| Part | Needed | State | Findings |', '|---|---|---|---|');
|
|
1687
|
+
for (const part of file.parts) out.push(`| \`${label(part)}\` | ${part.required} | ${part.state} | ${part.findings.map((finding) => finding.replaceAll('|', '\\|')).join('; ') || '—'} |`);
|
|
1688
|
+
out.push('');
|
|
1689
|
+
}
|
|
1690
|
+
const instruction = report.by_reference.filter((row) => !row.exempt);
|
|
1691
|
+
out.push('### By reference', '', `${instruction.length} file(s) under \`docs/\` are named by an instruction file. They are instruction class too.`, '');
|
|
1692
|
+
if (report.by_reference.length) {
|
|
1693
|
+
out.push('| Path | On disk | Named by | Instruction class |', '|---|---|---|---|');
|
|
1694
|
+
for (const row of report.by_reference) {
|
|
1695
|
+
out.push(`| \`${row.path}\` | ${row.exists ? 'yes' : 'no'} | ${row.pointed_at_by.map((path) => `\`${path}\``).join(', ') || '—'} | ${row.exempt ? `no — ${row.exempt_note}` : 'yes'} |`);
|
|
1696
|
+
}
|
|
1697
|
+
out.push('');
|
|
1698
|
+
}
|
|
1699
|
+
if (report.ratchet) {
|
|
1700
|
+
out.push('### Ratchet', '');
|
|
1701
|
+
for (const line of ratchetLines(report.ratchet).slice(1)) out.push(`- ${line.trim()}`);
|
|
1702
|
+
out.push('');
|
|
1703
|
+
}
|
|
1704
|
+
return `${out.join('\n')}\n`;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Two things can turn the job red, and they answer different questions.
|
|
1708
|
+
//
|
|
1709
|
+
// `fail-on` answers "is this repository finished?" over the whole-repository
|
|
1710
|
+
// count. It keeps its three levels and it stays a report line at the shipped
|
|
1711
|
+
// level.
|
|
1712
|
+
//
|
|
1713
|
+
// The ratchet answers "did this pull request take something away?" It is the
|
|
1714
|
+
// one that can be true on a repository with debt, so it is checked first.
|
|
1715
|
+
export function verdict(report, failOn) {
|
|
1716
|
+
if (report.errors.length) return { green: false, why: `the check hit ${report.errors.length} error(s)` };
|
|
1717
|
+
const state = report.ratchet;
|
|
1718
|
+
if (state?.gates && state.regressions.length) {
|
|
1719
|
+
return { green: false, why: `${state.regressions.length} instruction-class part(s) went backwards against the base: ${state.regressions.map((row) => row.label).join(', ')}` };
|
|
1720
|
+
}
|
|
1721
|
+
if (failOn === 'required' && report.counts.required_not_filled) return { green: false, why: `${report.counts.required_not_filled} required file(s) are not filled` };
|
|
1722
|
+
if (failOn === 'any' && report.counts.not_filled) return { green: false, why: `${report.counts.not_filled} file(s) are not filled` };
|
|
1723
|
+
const excused = state && !state.gates && state.regressions.length
|
|
1724
|
+
? ` — WARNING: ${state.regressions.length} part(s) went backwards and were NOT gated; a person merges this one`
|
|
1725
|
+
: '';
|
|
1726
|
+
const ratchetWhy = state ? (state.gates ? '; no instruction-class part went backwards' : `; the ratchet reported and did not gate (${state.report_only.map((row) => row.case).join(', ')})${excused}`) : '';
|
|
1727
|
+
return { green: true, why: `${report.counts.required_not_filled} required file(s) are not filled; fail-on is "${failOn}"${ratchetWhy}` };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// ---------------------------------------------------------------------------
|
|
1731
|
+
// The command line.
|
|
1732
|
+
// ---------------------------------------------------------------------------
|
|
1733
|
+
|
|
1734
|
+
export const FAIL_ON = Object.freeze(['error', 'required', 'any']);
|
|
1735
|
+
export const EXTRACTORS = Object.freeze([]); // Slice L3 adds routes, migrations and mcp.
|
|
1736
|
+
|
|
1737
|
+
export function parseArguments(argv) {
|
|
1738
|
+
const out = { root: process.cwd(), failOn: 'error', json: null, summary: null, quiet: false, base: null, baseSha: null };
|
|
1739
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1740
|
+
const flag = argv[index];
|
|
1741
|
+
const next = () => { const value = argv[index + 1]; if (value === undefined) throw new Error(`${flag} needs a value`); index += 1; return value; };
|
|
1742
|
+
if (flag === '--root') out.root = resolve(next());
|
|
1743
|
+
else if (flag === '--fail-on') out.failOn = next();
|
|
1744
|
+
else if (flag === '--json') out.json = resolve(next());
|
|
1745
|
+
else if (flag === '--summary') out.summary = resolve(next());
|
|
1746
|
+
else if (flag === '--quiet') out.quiet = true;
|
|
1747
|
+
// The ratchet's two inputs. `--base` is the directory the workflow checked
|
|
1748
|
+
// the base commit out into. `--base-sha` is the pull request's base.sha, so
|
|
1749
|
+
// the check can say the tree it reads is the tree it was promised.
|
|
1750
|
+
else if (flag === '--base') out.base = resolve(next());
|
|
1751
|
+
else if (flag === '--base-sha') out.baseSha = next().trim() || null;
|
|
1752
|
+
else if (flag === '--repo') out.repo = next();
|
|
1753
|
+
else if (flag === '--version') out.version = true;
|
|
1754
|
+
else throw new Error(`unknown argument ${flag}`);
|
|
1755
|
+
}
|
|
1756
|
+
if (!FAIL_ON.includes(out.failOn)) throw new Error(`--fail-on must be one of ${FAIL_ON.join(', ')}`);
|
|
1757
|
+
return out;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
export function run(argv, env = process.env) {
|
|
1761
|
+
const options = parseArguments(argv);
|
|
1762
|
+
const repoId = options.repo ?? env.GITHUB_REPOSITORY ?? null;
|
|
1763
|
+
const patterns = secretPatterns();
|
|
1764
|
+
const report = scan({ root: options.root, repoId, extractors: EXTRACTORS, secretPatterns: patterns });
|
|
1765
|
+
// No `--base` means no pull request to measure: a push build, or a run by
|
|
1766
|
+
// hand. The ratchet then does not run at all, and the report says nothing
|
|
1767
|
+
// about it rather than claiming a pass.
|
|
1768
|
+
report.ratchet = options.base
|
|
1769
|
+
? ratchet({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, repoId, secretPatterns: patterns })
|
|
1770
|
+
: null;
|
|
1771
|
+
const decision = verdict(report, options.failOn);
|
|
1772
|
+
const text = toText(report);
|
|
1773
|
+
if (options.json) writeFileSync(options.json, `${JSON.stringify({ ...report, fail_on: options.failOn, green: decision.green }, null, 2)}\n`);
|
|
1774
|
+
if (options.summary) writeFileSync(options.summary, toMarkdown(report));
|
|
1775
|
+
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, toMarkdown(report));
|
|
1776
|
+
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `not-filled=${report.counts.not_filled}\nrequired-not-filled=${report.counts.required_not_filled}\nkind=${report.kind ?? ''}\ngreen=${decision.green}\n`);
|
|
1777
|
+
return { report, decision, text, options };
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
1781
|
+
try {
|
|
1782
|
+
// --version is parsed like every other flag, so an unknown flag beside it
|
|
1783
|
+
// is still refused (acceptance review of batch 14b, finding 11).
|
|
1784
|
+
const asked = parseArguments(process.argv.slice(2));
|
|
1785
|
+
if (asked.version) {
|
|
1786
|
+
process.stdout.write(`atlas repo-shape check, check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}\n`);
|
|
1787
|
+
} else {
|
|
1788
|
+
const { decision, text, options } = run(process.argv.slice(2));
|
|
1789
|
+
if (!options.quiet) process.stdout.write(text);
|
|
1790
|
+
process.stdout.write(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}\n`);
|
|
1791
|
+
process.exitCode = decision.green ? 0 : 1;
|
|
1792
|
+
}
|
|
1793
|
+
} catch (error) {
|
|
1794
|
+
process.stderr.write(`atlas repo-shape check: ${error.message}\n`);
|
|
1795
|
+
process.exitCode = 2;
|
|
1796
|
+
}
|
|
1797
|
+
}
|