@arjunkhera/atlas 0.3.46 → 0.3.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +2 -2
- package/door/lib/owner-only.mjs +778 -0
- package/door/lib/pull-request.mjs +3 -2
- package/door/lib/tooling.mjs +34 -10
- package/door/lib/upkeep.mjs +47 -6
- package/door/templates/pull_request_template.md +25 -0
- package/owner-only.yaml +238 -0
- package/package.json +3 -2
- package/shape/check.mjs +44 -3
- package/shape/releases.json +5 -0
|
@@ -21,8 +21,9 @@ export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'ta
|
|
|
21
21
|
// The one label every tooling pull request carries. A person filters on it.
|
|
22
22
|
export const TOOLING_LABEL = 'atlas-helper';
|
|
23
23
|
|
|
24
|
-
// The only
|
|
25
|
-
|
|
24
|
+
// The only files the tooling writer may write: the check, its workflow, and
|
|
25
|
+
// the pull request template when the repo has none.
|
|
26
|
+
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/pull_request_template.md']);
|
|
26
27
|
|
|
27
28
|
export class ChangeError extends Error {}
|
|
28
29
|
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -20,8 +20,19 @@ import { dirname, join } from 'node:path';
|
|
|
20
20
|
import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
|
|
21
21
|
import { packagedCheck, packagePackageVersion, PACKAGE_CHECK_SOURCE } from './releases.mjs';
|
|
22
22
|
import { status } from './status.mjs';
|
|
23
|
+
import { PR_TEMPLATE_PATH } from './upkeep.mjs';
|
|
23
24
|
|
|
24
|
-
export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE };
|
|
25
|
+
export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
|
|
26
|
+
|
|
27
|
+
// The pull request template (roadmap R8). The writer puts it into a repo that
|
|
28
|
+
// has none, and never replaces a template the repo keeps.
|
|
29
|
+
export const PACKAGE_TEMPLATE_SOURCE = 'door/templates/pull_request_template.md';
|
|
30
|
+
// A package with no template file writes none.
|
|
31
|
+
const packagedTemplate = (packageRoot) => {
|
|
32
|
+
const full = join(packageRoot, PACKAGE_TEMPLATE_SOURCE);
|
|
33
|
+
return existsSync(full) ? readFileSync(full, 'utf8') : null;
|
|
34
|
+
};
|
|
35
|
+
const templateMissing = (root, packageRoot) => packagedTemplate(packageRoot) !== null && !existsSync(join(root, PR_TEMPLATE_PATH));
|
|
25
36
|
|
|
26
37
|
export class ToolingRefusal extends Error {
|
|
27
38
|
constructor(message, { current = false } = {}) { super(message); this.current = current; }
|
|
@@ -64,7 +75,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
|
|
|
64
75
|
throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
|
|
65
76
|
}
|
|
66
77
|
const now = status({ root, packageRoot });
|
|
67
|
-
if (now.state === 'current' && !workflowOnly) {
|
|
78
|
+
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot)) {
|
|
68
79
|
throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
|
|
69
80
|
}
|
|
70
81
|
if (now.state === 'unknown' && !replaceUnknown && !own) {
|
|
@@ -90,22 +101,33 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
|
|
|
90
101
|
}
|
|
91
102
|
rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
|
|
92
103
|
}
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
const template = workflowOnly ? null : packagedTemplate(packageRoot);
|
|
105
|
+
if (template !== null) {
|
|
106
|
+
const contents = template;
|
|
107
|
+
const full = join(root, PR_TEMPLATE_PATH);
|
|
108
|
+
const kept = existsSync(full);
|
|
109
|
+
if (!kept && !dryRun) {
|
|
110
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
111
|
+
writeFileSync(full, contents);
|
|
112
|
+
}
|
|
113
|
+
rows.push({ path: PR_TEMPLATE_PATH, state: kept ? 'kept' : 'written', sha256: sha256(kept ? readFileSync(full, 'utf8') : contents), was: kept ? sha256(readFileSync(full, 'utf8')) : null });
|
|
114
|
+
}
|
|
115
|
+
if (!dryRun && rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
|
|
116
|
+
throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to write.', { current: true });
|
|
95
117
|
}
|
|
96
|
-
return { rows, version: packagePackageVersion(packageRoot), check_version: CHECK_VERSION, status: before?.state ?? null, changed: rows.some((row) => row.state !== 'unchanged') };
|
|
118
|
+
return { rows, version: packagePackageVersion(packageRoot), check_version: CHECK_VERSION, status: before?.state ?? null, changed: rows.some((row) => row.state !== 'unchanged' && row.state !== 'kept') };
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
// The change set the pull request machinery takes, for the two files above.
|
|
100
122
|
export function changes(root, packageRoot, { replaceUnknown = false, workflowOnly = false } = {}) {
|
|
101
123
|
mayWrite(root, packageRoot, { replaceUnknown, workflowOnly });
|
|
102
124
|
const { rows, version } = write(root, packageRoot, { dryRun: true, workflowOnly });
|
|
103
|
-
if (rows.every((row) => row.state === 'unchanged')) {
|
|
104
|
-
throw new ToolingRefusal('this repo is already current:
|
|
125
|
+
if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
|
|
126
|
+
throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
|
|
105
127
|
}
|
|
106
|
-
const wanted = packagedFiles(packageRoot);
|
|
128
|
+
const wanted = { ...packagedFiles(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
|
|
107
129
|
return rows
|
|
108
|
-
.filter((row) => row.state !== 'unchanged')
|
|
130
|
+
.filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
|
|
109
131
|
.map((row) => ({
|
|
110
132
|
path: row.path,
|
|
111
133
|
contents: wanted[row.path],
|
|
@@ -113,7 +135,9 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
|
|
|
113
135
|
source: 'repo read',
|
|
114
136
|
detail: row.path === CHECK_PATH
|
|
115
137
|
? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
|
|
116
|
-
:
|
|
138
|
+
: row.path === PR_TEMPLATE_PATH
|
|
139
|
+
? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
|
|
140
|
+
: `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
|
|
117
141
|
},
|
|
118
142
|
}));
|
|
119
143
|
}
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -119,21 +119,57 @@ const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i
|
|
|
119
119
|
const SHORT = /\bitems?\b[\s:#`(_-]*(?:id[\s:#`_-]*)?([0-9a-f]{8})\b/i;
|
|
120
120
|
const NO_ITEM = /\bno item:\s*(\S[^\n]*)/i;
|
|
121
121
|
|
|
122
|
+
// The six headings of the pull request text (roadmap R8, design
|
|
123
|
+
// docs/design-docs/pr-template/). The template in door/templates/ holds them,
|
|
124
|
+
// and a test keeps the two equal. A later job reads the text under each one.
|
|
125
|
+
export const PR_HEADINGS = Object.freeze(['Definition of done', 'Kind of change', 'Undo', 'Behaviour change', 'Changed tests', 'Proof']);
|
|
126
|
+
export const PR_TEMPLATE_PATH = '.github/pull_request_template.md';
|
|
127
|
+
|
|
128
|
+
// A pull request text without its HTML comments. The template's own comment
|
|
129
|
+
// says "no item:", so the item rule and the heading rule never read a comment.
|
|
130
|
+
const withoutComments = (text) => String(text ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
131
|
+
|
|
132
|
+
// The sections of a pull request text: each `## ` heading and the text under it.
|
|
133
|
+
export function prSections(body) {
|
|
134
|
+
const sections = new Map();
|
|
135
|
+
let current = null;
|
|
136
|
+
for (const line of withoutComments(body).split('\n')) {
|
|
137
|
+
const heading = /^##\s+(.+?)\s*#*\s*$/.exec(line);
|
|
138
|
+
if (heading) { current = heading[1].toLowerCase(); sections.set(current, []); continue; }
|
|
139
|
+
if (current) sections.get(current).push(line);
|
|
140
|
+
}
|
|
141
|
+
return new Map([...sections].map(([name, lines]) => [name, lines.join('\n').trim()]));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The findings of the text of a pull request that names an item.
|
|
145
|
+
export function prTextFindings(body) {
|
|
146
|
+
const sections = prSections(body);
|
|
147
|
+
const findings = [];
|
|
148
|
+
for (const heading of PR_HEADINGS) {
|
|
149
|
+
if (!sections.has(heading.toLowerCase())) findings.push(`pr text: missing heading "${heading}"`);
|
|
150
|
+
}
|
|
151
|
+
const done = sections.get('definition of done');
|
|
152
|
+
if (done !== undefined && !/^[ \t]*\d+[.)][ \t]+\S/m.test(done)) findings.push('pr text: "Definition of done" has no numbered line');
|
|
153
|
+
const behaviour = sections.get('behaviour change');
|
|
154
|
+
if (behaviour !== undefined && !/\b(yes|no)\b/i.test(behaviour)) findings.push('pr text: "Behaviour change" says neither yes nor no');
|
|
155
|
+
return findings;
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
// Does the pull request name its tracker item? Read from a GitHub event file.
|
|
123
159
|
export function itemNamed(event) {
|
|
124
160
|
const pr = event?.pull_request;
|
|
125
161
|
if (!pr) return { checked: false };
|
|
126
|
-
if (pr.user?.type === 'Bot') return { checked: true, ok: true, how: `opened by a bot (${pr.user.login})
|
|
127
|
-
const texts = [pr.title, pr.body, pr.head?.ref].map((one) => String(one ?? ''));
|
|
162
|
+
if (pr.user?.type === 'Bot') return { checked: true, ok: true, how: `opened by a bot (${pr.user.login})`, named: false };
|
|
163
|
+
const texts = [pr.title, withoutComments(pr.body), pr.head?.ref].map((one) => String(one ?? ''));
|
|
128
164
|
for (const text of texts) {
|
|
129
165
|
const full = text.match(UUID);
|
|
130
|
-
if (full) return { checked: true, ok: true, how: `names item ${full[0]}
|
|
166
|
+
if (full) return { checked: true, ok: true, how: `names item ${full[0]}`, named: true };
|
|
131
167
|
const short = text.match(SHORT);
|
|
132
|
-
if (short) return { checked: true, ok: true, how: `names item ${short[1]}
|
|
168
|
+
if (short) return { checked: true, ok: true, how: `names item ${short[1]}`, named: true };
|
|
133
169
|
}
|
|
134
170
|
for (const text of texts) {
|
|
135
171
|
const none = text.match(NO_ITEM);
|
|
136
|
-
if (none) return { checked: true, ok: true, how: `no item: ${none[1].trim()}
|
|
172
|
+
if (none) return { checked: true, ok: true, how: `no item: ${none[1].trim()}`, named: false };
|
|
137
173
|
}
|
|
138
174
|
return { checked: true, ok: false };
|
|
139
175
|
}
|
|
@@ -149,7 +185,11 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
149
185
|
for (const one of dead.slice(0, MAX_LISTED)) findings.push(`dead link: ${one.file}:${one.line} -> ${one.target}`);
|
|
150
186
|
if (dead.length > MAX_LISTED) findings.push(`dead link: ${dead.length - MAX_LISTED} more`);
|
|
151
187
|
if (item.checked && !item.ok) findings.push('item: the pull request names no tracker item. Put the item id in the title, the body or the branch, or write "no item:" and a reason');
|
|
152
|
-
|
|
188
|
+
// The heading check holds a pull request that names an item, in a repo that
|
|
189
|
+
// keeps the Atlas template. A repo without the template is not held yet.
|
|
190
|
+
const text = { checked: Boolean(item.named) && existsSync(join(root, PR_TEMPLATE_PATH)) };
|
|
191
|
+
if (text.checked) findings.push(...prTextFindings(event.pull_request.body));
|
|
192
|
+
return { green: findings.length === 0, findings, index, dead, item, text };
|
|
153
193
|
}
|
|
154
194
|
|
|
155
195
|
export function upkeepCommand({ what, event: eventFile, json = false }, root, line) {
|
|
@@ -163,6 +203,7 @@ export function upkeepCommand({ what, event: eventFile, json = false }, root, li
|
|
|
163
203
|
line(`docs index: ${result.index.checked ? `${result.index.unlisted.length} not listed, ${result.index.gone.length} gone` : 'no docs/ folder'}`);
|
|
164
204
|
line(`dead links: ${result.dead.length}`);
|
|
165
205
|
line(`item: ${!result.item.checked ? 'not a pull request' : result.item.ok ? result.item.how : 'none named'}`);
|
|
206
|
+
line(`pr text: ${result.text.checked ? 'the six headings were read' : 'not checked'}`);
|
|
166
207
|
for (const finding of result.findings) line(` - ${finding}`);
|
|
167
208
|
line(`upkeep check: ${result.green ? 'GREEN' : 'RED'}`);
|
|
168
209
|
return result.green ? 0 : 1;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!-- The Atlas pull request text. Name the tracker item in the title, this text or the branch, or write "no item:" and a reason. When the text names an item, `atlas upkeep check` needs each heading below. -->
|
|
2
|
+
|
|
3
|
+
## Definition of done
|
|
4
|
+
|
|
5
|
+
1.
|
|
6
|
+
|
|
7
|
+
## Kind of change
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## Undo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## Behaviour change
|
|
16
|
+
|
|
17
|
+
No.
|
|
18
|
+
|
|
19
|
+
## Changed tests
|
|
20
|
+
|
|
21
|
+
None.
|
|
22
|
+
|
|
23
|
+
## Proof
|
|
24
|
+
|
|
25
|
+
|
package/owner-only.yaml
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# The owner-only list: the kinds of change that always come to the owner.
|
|
2
|
+
#
|
|
3
|
+
# The owner picked these eight classes on 24 October 2026 (decision 1c2f0d53,
|
|
4
|
+
# item 94b2e2ff). Design: docs/design-docs/owner-only-list/ in the Atlas repo.
|
|
5
|
+
#
|
|
6
|
+
# Each class has two examples and its net. `code` means the reader in
|
|
7
|
+
# door/lib/owner-only.mjs matches it on a diff. `review` means the review job
|
|
8
|
+
# reads for it. A code class lists its signs: exact things that the reader
|
|
9
|
+
# matches.
|
|
10
|
+
#
|
|
11
|
+
# The floor below holds in every repo, whatever the repo's own `owner_only:`
|
|
12
|
+
# list says. The reader takes the floor from this package and the repo list
|
|
13
|
+
# from the base commit, so a pull request cannot take itself off either one.
|
|
14
|
+
#
|
|
15
|
+
# Sign types:
|
|
16
|
+
# path a changed path that matches one of `paths`
|
|
17
|
+
# added_line an added line, in a file of `paths`, that matches `pattern`
|
|
18
|
+
# secret an added line that matches a secret pattern of shape/check.mjs
|
|
19
|
+
# json_keys a change to one of `keys` of a JSON file of `paths`
|
|
20
|
+
# surface a line that `line` matches, keyed by its first group, is gone
|
|
21
|
+
# at the head, or lost a part that `part` matches
|
|
22
|
+
# schema a JSON file of `paths` lost a key, lost an enum value, or
|
|
23
|
+
# gained a required field; versioned files compare newest to newest
|
|
24
|
+
# any_change any change to a file of `paths`
|
|
25
|
+
#
|
|
26
|
+
# A code class with no signs says where its code net comes from instead:
|
|
27
|
+
# `signs_from: repo` (each repo's `owner_only:` list) or `signs_from: floor`.
|
|
28
|
+
#
|
|
29
|
+
# Paths match on whole folder names and with no case. `**/` matches any depth.
|
|
30
|
+
# A path that ends in `/` is a folder.
|
|
31
|
+
|
|
32
|
+
classes:
|
|
33
|
+
- id: money
|
|
34
|
+
name: Money
|
|
35
|
+
examples:
|
|
36
|
+
- A new paid plan, or a change to a price.
|
|
37
|
+
- A cloud resource that bills by the hour.
|
|
38
|
+
net:
|
|
39
|
+
- review
|
|
40
|
+
|
|
41
|
+
- id: credentials
|
|
42
|
+
name: Credentials and keys
|
|
43
|
+
examples:
|
|
44
|
+
- A new API key, or a key written into a file.
|
|
45
|
+
- A change to the list of credentials that a repo names.
|
|
46
|
+
net:
|
|
47
|
+
- code
|
|
48
|
+
- review
|
|
49
|
+
signs:
|
|
50
|
+
- id: secret-value
|
|
51
|
+
type: secret
|
|
52
|
+
- id: credential-files
|
|
53
|
+
type: path
|
|
54
|
+
paths:
|
|
55
|
+
- "atlas/credentials.yaml"
|
|
56
|
+
- "**/.env"
|
|
57
|
+
- "**/.env.*"
|
|
58
|
+
- "**/*.pem"
|
|
59
|
+
- "**/*.key"
|
|
60
|
+
|
|
61
|
+
- id: outside-visibility
|
|
62
|
+
name: Outside visibility
|
|
63
|
+
examples:
|
|
64
|
+
- A public repo, or a public post.
|
|
65
|
+
- A release that removes a command that other people use.
|
|
66
|
+
net:
|
|
67
|
+
- code
|
|
68
|
+
- review
|
|
69
|
+
signs:
|
|
70
|
+
- id: release-surface
|
|
71
|
+
type: json_keys
|
|
72
|
+
paths:
|
|
73
|
+
- "**/package.json"
|
|
74
|
+
keys:
|
|
75
|
+
- name
|
|
76
|
+
- private
|
|
77
|
+
- publishConfig
|
|
78
|
+
- files
|
|
79
|
+
- bin
|
|
80
|
+
- main
|
|
81
|
+
- exports
|
|
82
|
+
- scripts
|
|
83
|
+
- id: removed-command-or-switch
|
|
84
|
+
type: surface
|
|
85
|
+
paths:
|
|
86
|
+
- "packages/atlas/door/cli.mjs"
|
|
87
|
+
line: "^ '?([a-z][a-z-]*)'?: \\{.*\\},?\\s*$"
|
|
88
|
+
part: "'?([a-z][a-z-]*)'?: '(?:switch|value|optional)'"
|
|
89
|
+
- id: removed-mcp-tool
|
|
90
|
+
type: surface
|
|
91
|
+
paths:
|
|
92
|
+
- "packages/atlas/work/mcp.mjs"
|
|
93
|
+
line: "^\\s*\\{ name: '([a-z_]+)'"
|
|
94
|
+
- id: kit-manifest
|
|
95
|
+
type: any_change
|
|
96
|
+
# A release moves only the "atlas" version line. That is not a sign.
|
|
97
|
+
version_key: atlas
|
|
98
|
+
paths:
|
|
99
|
+
- "**/test/atlas/manifest.json"
|
|
100
|
+
- "packages/atlas/tests/link-check.mjs"
|
|
101
|
+
- id: narrowed-schema
|
|
102
|
+
type: schema
|
|
103
|
+
paths:
|
|
104
|
+
- "packages/atlas/work/manifest-*.json"
|
|
105
|
+
|
|
106
|
+
- id: lost-data
|
|
107
|
+
name: Data that cannot be undone
|
|
108
|
+
examples:
|
|
109
|
+
- A migration that drops a table.
|
|
110
|
+
- A script that deletes rows from a live store.
|
|
111
|
+
net:
|
|
112
|
+
- code
|
|
113
|
+
- review
|
|
114
|
+
signs:
|
|
115
|
+
- id: drop-or-delete-in-migration
|
|
116
|
+
type: added_line
|
|
117
|
+
paths:
|
|
118
|
+
- "**/migrations/**"
|
|
119
|
+
- "**/migrate/**"
|
|
120
|
+
- "**/*.sql"
|
|
121
|
+
pattern: "(?i)\\b(drop|delete|truncate)\\b|\\b(drop|truncate)[A-Z_]"
|
|
122
|
+
|
|
123
|
+
- id: security
|
|
124
|
+
name: Security code
|
|
125
|
+
examples:
|
|
126
|
+
- The code that checks who may sign in.
|
|
127
|
+
- The code that keeps one tenant's rows from another.
|
|
128
|
+
net:
|
|
129
|
+
- code
|
|
130
|
+
- review
|
|
131
|
+
# No path holds sign-in code in every repo, so each repo lists its own
|
|
132
|
+
# security paths under `owner_only:` in atlas.yaml.
|
|
133
|
+
signs_from: repo
|
|
134
|
+
|
|
135
|
+
- id: new-service
|
|
136
|
+
name: A new outside service or a new repo
|
|
137
|
+
examples:
|
|
138
|
+
- A call to a new outside API.
|
|
139
|
+
- A new repo, or a new GitHub App.
|
|
140
|
+
net:
|
|
141
|
+
- review
|
|
142
|
+
|
|
143
|
+
- id: rule-book
|
|
144
|
+
name: The rule book
|
|
145
|
+
examples:
|
|
146
|
+
- An entry file, a path rule or a hook.
|
|
147
|
+
- The owner-only list itself.
|
|
148
|
+
net:
|
|
149
|
+
- code
|
|
150
|
+
- review
|
|
151
|
+
# The floor below is the code net of this class, with each repo's own list.
|
|
152
|
+
signs_from: floor
|
|
153
|
+
|
|
154
|
+
- id: direction
|
|
155
|
+
name: Direction
|
|
156
|
+
examples:
|
|
157
|
+
- What to build next.
|
|
158
|
+
- A goal of a product, or a change to a locked design.
|
|
159
|
+
net:
|
|
160
|
+
- review
|
|
161
|
+
|
|
162
|
+
# The floor: rule-book paths in every repo. Each entry is class rule-book.
|
|
163
|
+
# `follow` means the reader also reads the file for names: each file that it
|
|
164
|
+
# names with an @ import, a Markdown link or a path in backticks joins the
|
|
165
|
+
# floor, and the reader follows names out of that file in turn. A plugin
|
|
166
|
+
# root's skills, agents, hooks and commands are followed the same way.
|
|
167
|
+
# `version_only_exempt` lets a change to the version alone pass, because the
|
|
168
|
+
# owner took Atlas's own releases off the list.
|
|
169
|
+
floor:
|
|
170
|
+
- path: "**/CLAUDE.md"
|
|
171
|
+
rule: entry file
|
|
172
|
+
follow: true
|
|
173
|
+
- path: "**/CLAUDE.local.md"
|
|
174
|
+
rule: entry file
|
|
175
|
+
follow: true
|
|
176
|
+
- path: "**/AGENTS.md"
|
|
177
|
+
rule: entry file
|
|
178
|
+
follow: true
|
|
179
|
+
- path: "**/.claude/"
|
|
180
|
+
rule: .claude at any depth
|
|
181
|
+
follow: true
|
|
182
|
+
- path: "**/.claude-plugin/"
|
|
183
|
+
rule: .claude-plugin at any depth
|
|
184
|
+
- path: ".mcp.json"
|
|
185
|
+
rule: root MCP servers
|
|
186
|
+
- path: ".github/actions/"
|
|
187
|
+
rule: CI actions
|
|
188
|
+
- path: ".github/workflows/"
|
|
189
|
+
rule: CI workflows
|
|
190
|
+
- path: ".github/atlas/"
|
|
191
|
+
rule: the Atlas check copy
|
|
192
|
+
- path: "atlas.yaml"
|
|
193
|
+
rule: atlas.yaml keys
|
|
194
|
+
- path: "atlas/"
|
|
195
|
+
rule: repo facts
|
|
196
|
+
- path: "**/.gitattributes"
|
|
197
|
+
rule: git attributes
|
|
198
|
+
- path: ".gitmodules"
|
|
199
|
+
rule: submodules
|
|
200
|
+
- path: "**/.github/CODEOWNERS"
|
|
201
|
+
rule: code owners
|
|
202
|
+
- path: "docs/intake/"
|
|
203
|
+
rule: the owner's words
|
|
204
|
+
- path: "archive/"
|
|
205
|
+
rule: archive
|
|
206
|
+
- path: "packages/atlas/owner-only.yaml"
|
|
207
|
+
rule: the owner-only list
|
|
208
|
+
- path: "packages/atlas/door/lib/owner-only.mjs"
|
|
209
|
+
rule: the owner-only reader
|
|
210
|
+
- path: "packages/atlas/package.json"
|
|
211
|
+
rule: the package manifest
|
|
212
|
+
version_only_exempt: true
|
|
213
|
+
- path: "packages/atlas/.claude-plugin/plugin.json"
|
|
214
|
+
rule: the plugin manifest
|
|
215
|
+
version_only_exempt: true
|
|
216
|
+
|
|
217
|
+
# The entry map. A doc that only it names is not on the floor, and the reader
|
|
218
|
+
# does not follow names out of it (shape/check.mjs, the exemption).
|
|
219
|
+
entry_map: docs/index.md
|
|
220
|
+
|
|
221
|
+
# The keys of atlas.yaml whose change is always owner-only. A change to any
|
|
222
|
+
# other key is owner-only too, until roadmap R62 lists the safe ones. The one
|
|
223
|
+
# change that passes is an addition under `parts:`.
|
|
224
|
+
atlas_yaml:
|
|
225
|
+
add_only:
|
|
226
|
+
- parts
|
|
227
|
+
fixed:
|
|
228
|
+
- owner_only
|
|
229
|
+
- areas
|
|
230
|
+
- required_checks
|
|
231
|
+
- product
|
|
232
|
+
- product_paths
|
|
233
|
+
- repo
|
|
234
|
+
- kind
|
|
235
|
+
- kinds
|
|
236
|
+
- up_to_date
|
|
237
|
+
- brake
|
|
238
|
+
- spec
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arjunkhera/atlas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.48",
|
|
4
4
|
"description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"agents/",
|
|
27
27
|
"hooks/",
|
|
28
28
|
".claude-plugin/",
|
|
29
|
-
".mcp.json"
|
|
29
|
+
".mcp.json",
|
|
30
|
+
"owner-only.yaml"
|
|
30
31
|
],
|
|
31
32
|
"peerDependencies": {
|
|
32
33
|
"playwright": ">=1.40.0"
|
package/shape/check.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
|
|
|
34
34
|
// Whether a copy is current or behind is not decided here. CI has no package
|
|
35
35
|
// and no network. The session verb decides it, from the release list that
|
|
36
36
|
// ships beside this file in the package, shape/releases.json.
|
|
37
|
-
export const CHECK_VERSION =
|
|
37
|
+
export const CHECK_VERSION = 5;
|
|
38
38
|
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
40
|
// The spec. One object per version. The check reads a version it has here and
|
|
@@ -774,7 +774,11 @@ function indentOf(line) {
|
|
|
774
774
|
return line.length - line.trimStart().length;
|
|
775
775
|
}
|
|
776
776
|
|
|
777
|
-
|
|
777
|
+
// `strict` is for atlas.yaml. It refuses a key that appears twice, an anchor,
|
|
778
|
+
// an alias and a merge key. Each one lets two readers see two different maps
|
|
779
|
+
// in one file, and the owner-only reader and this check must see the same one
|
|
780
|
+
// (security review of the owner-only list, 24 October 2026).
|
|
781
|
+
export function parseYaml(source, { strict = false } = {}) {
|
|
778
782
|
const raw = String(source ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
779
783
|
const lines = [];
|
|
780
784
|
for (let index = 0; index < raw.length; index += 1) {
|
|
@@ -825,6 +829,7 @@ export function parseYaml(source) {
|
|
|
825
829
|
const body = line.text.trimStart();
|
|
826
830
|
if (!body.startsWith('- ') && body !== '-') break;
|
|
827
831
|
const rest = body === '-' ? '' : body.slice(2);
|
|
832
|
+
if (strict && /^[&*]/.test(rest.trim())) throw new YamlError('an anchor or an alias is not read in strict mode', line.number, line.indent);
|
|
828
833
|
state.at += 1;
|
|
829
834
|
if (!rest.trim()) { out.push(value(indent + 1)); continue; }
|
|
830
835
|
const match = KEY.exec(rest);
|
|
@@ -851,6 +856,11 @@ export function parseYaml(source) {
|
|
|
851
856
|
if (!match) throw new YamlError('this line is neither a key nor a list entry', line.number, line.indent);
|
|
852
857
|
const key = match[1] !== undefined ? match[1] : match[2] !== undefined ? match[2].replaceAll("''", "'") : match[3].trim();
|
|
853
858
|
const rest = (match[4] ?? '').trim();
|
|
859
|
+
if (strict) {
|
|
860
|
+
if (Object.hasOwn(out, key)) throw new YamlError(`the key "${key}" appears twice`, line.number, line.indent);
|
|
861
|
+
if (key === '<<' || /^[&*]/.test(key)) throw new YamlError('a merge key, an anchor or an alias is not read in strict mode', line.number, line.indent);
|
|
862
|
+
if (/^[&*]/.test(rest)) throw new YamlError('an anchor or an alias is not read in strict mode', line.number, line.indent);
|
|
863
|
+
}
|
|
854
864
|
state.at += 1;
|
|
855
865
|
if (/^[|>][-+]?\d*$/.test(rest)) { out[key] = blockScalar(rest, indent); continue; }
|
|
856
866
|
if (rest && !rest.startsWith('#')) { out[key] = scalar(rest, line.number); continue; }
|
|
@@ -1428,11 +1438,41 @@ function byReference(context, spec) {
|
|
|
1428
1438
|
return applyExemption([...found.values()].sort((a, b) => a.path.localeCompare(b.path)));
|
|
1429
1439
|
}
|
|
1430
1440
|
|
|
1441
|
+
// The eight classes of the owner-only list. The authority is the package's
|
|
1442
|
+
// owner-only.yaml, and a test keeps the two equal. This copy exists because the
|
|
1443
|
+
// check runs alone in each repo's CI, where the package is not there.
|
|
1444
|
+
export const OWNER_ONLY_CLASSES = Object.freeze(['money', 'credentials', 'outside-visibility', 'lost-data', 'security', 'new-service', 'rule-book', 'direction']);
|
|
1445
|
+
|
|
1446
|
+
const stringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim() !== '');
|
|
1447
|
+
|
|
1448
|
+
// The three keys of the owner-only list in atlas.yaml. Each is optional. When
|
|
1449
|
+
// one is there, its shape is checked, and an unknown class is an error.
|
|
1450
|
+
export function ownerOnlyErrors(facts) {
|
|
1451
|
+
const errors = [];
|
|
1452
|
+
if (!facts || typeof facts !== 'object') return errors;
|
|
1453
|
+
if (Object.hasOwn(facts, 'owner_only')) {
|
|
1454
|
+
const list = facts.owner_only;
|
|
1455
|
+
if (!Array.isArray(list)) errors.push('atlas.yaml: owner_only is not a list');
|
|
1456
|
+
else list.forEach((entry, index) => {
|
|
1457
|
+
const at = `atlas.yaml: owner_only[${index}]`;
|
|
1458
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { errors.push(`${at} is not a map with a path and a class`); return; }
|
|
1459
|
+
for (const key of Object.keys(entry)) if (!['path', 'class', 'why', 'except'].includes(key)) errors.push(`${at} has the key "${key}"; the keys are path, class, why and except`);
|
|
1460
|
+
if (typeof entry.path !== 'string' || !entry.path.trim()) errors.push(`${at} has no path`);
|
|
1461
|
+
if (!OWNER_ONLY_CLASSES.includes(entry.class)) errors.push(`${at}.class "${entry.class}" is not a class of owner-only.yaml; the classes are ${OWNER_ONLY_CLASSES.join(', ')}`);
|
|
1462
|
+
if (entry.except !== undefined && !stringList(entry.except)) errors.push(`${at}.except is not a list of paths`);
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
for (const key of ['required_checks', 'product_paths']) {
|
|
1466
|
+
if (Object.hasOwn(facts, key) && !stringList(facts[key])) errors.push(`atlas.yaml: ${key} is not a list of names`);
|
|
1467
|
+
}
|
|
1468
|
+
return errors;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1431
1471
|
export function readFacts(root) {
|
|
1432
1472
|
const text = readText(root, 'atlas.yaml');
|
|
1433
1473
|
if (text === null || !text.trim()) return { facts: null, error: null };
|
|
1434
1474
|
try {
|
|
1435
|
-
const data = parseYaml(text);
|
|
1475
|
+
const data = parseYaml(text, { strict: true });
|
|
1436
1476
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return { facts: null, error: 'atlas.yaml is not a map of keys' };
|
|
1437
1477
|
return { facts: data, error: null };
|
|
1438
1478
|
} catch (error) { return { facts: null, error: `atlas.yaml cannot be read: ${error.message}` }; }
|
|
@@ -1460,6 +1500,7 @@ export function scan({ root, repoId = null, extractors = [], secretPatterns: pat
|
|
|
1460
1500
|
if (missed.length) errors.push(`${missed.length} secret pattern(s) no longer catch their own canary: ${missed.join(', ')}; this check cannot be trusted`);
|
|
1461
1501
|
if (!facts && !factsError) errors.push('atlas.yaml does not exist, so the check cannot learn the spec version or the repo kind');
|
|
1462
1502
|
if (factsError) errors.push(factsError);
|
|
1503
|
+
errors.push(...ownerOnlyErrors(facts));
|
|
1463
1504
|
const kind = scoreAs ? scoreAs.kind ?? null : facts?.kind ?? null;
|
|
1464
1505
|
const wantedSpec = scoreAs ? scoreAs.spec : facts?.spec;
|
|
1465
1506
|
let spec = null;
|
package/shape/releases.json
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
"check_version": 4,
|
|
21
21
|
"sha256": "3357ea247694634c3535998368683eceffaf863221dee5fec9e43a03042afb95",
|
|
22
22
|
"first_package": "0.3.36"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"check_version": 5,
|
|
26
|
+
"sha256": "8f36633fe693ebf7a7cc0f7237d49eb4720f67d371d4758d556bab51965ec334",
|
|
27
|
+
"first_package": "0.3.48"
|
|
23
28
|
}
|
|
24
29
|
]
|
|
25
30
|
}
|