@arjunkhera/atlas 0.3.49 → 0.3.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.49",
3
+ "version": "0.3.50",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -17,7 +17,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { open } from './lib/pull-request.mjs';
19
19
  import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
20
- import { status, statusMany } from './lib/status.mjs';
20
+ import { statusMany, copies } from './lib/status.mjs';
21
21
  import { drift, kindsFromRegistry, fileKind } from './lib/kind-drift.mjs';
22
22
  import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
23
23
  import { packagePackageVersion } from './lib/releases.mjs';
@@ -50,6 +50,8 @@ const HELP = `atlas — the door into a repo's Atlas files
50
50
  --replace-unknown also replace a copy that is in no release
51
51
  --workflow rewrite only the workflow, for a repo whose check is current
52
52
  atlas status --root <repo> is the repo's check current, behind, unknown or missing
53
+ --all every Atlas copy in the repo: the check, its workflow, each test kit, each pinned Atlas version
54
+ --json print the rows as JSON: type, path, state, have, want, why
53
55
  atlas kind-drift --root <repo> --record-kind <kinds>
54
56
  does atlas.yaml agree with the repo record
55
57
  atlas check --root <repo> run the repo shape check here
@@ -167,7 +169,7 @@ A person merges every file the tooling writer writes.
167
169
  // skipped the privacy filter and printed GREEN (review of PR #107).
168
170
  export const FLAGS = Object.freeze({
169
171
  tooling: { root: 'optional', branch: 'optional', 'replace-unknown': 'switch', workflow: 'switch', 'dry-run': 'switch' },
170
- status: { root: 'optional' },
172
+ status: { root: 'optional', all: 'switch', json: 'switch' },
171
173
  'kind-drift': { root: 'optional', 'record-kind': 'value', registry: 'value', repo: 'value' },
172
174
  check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
173
175
  ste: { share: 'switch', terms: 'value' },
@@ -250,6 +252,7 @@ function toolingCommand(chosen) {
250
252
 
251
253
  function statusCommand(chosen) {
252
254
  const roots = chosen._.slice(1).length ? chosen._.slice(1).map((one) => resolve(one)) : [rootOf(chosen)];
255
+ if (chosen.all || chosen.json) return copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
253
256
  const rows = statusMany(roots, PACKAGE_ROOT);
254
257
  for (const row of rows) {
255
258
  line(`${row.state.toUpperCase().padEnd(8)} ${row.root}`);
@@ -262,6 +265,29 @@ function statusCommand(chosen) {
262
265
  return rows.some((row) => row.state === 'behind') ? 1 : 0;
263
266
  }
264
267
 
268
+ // One row for each Atlas copy (roadmap item R19). Exit 1 when a copy is
269
+ // behind or broken, so a watcher can act on the exit alone.
270
+ function copiesCommand(roots, { all, json }) {
271
+ const repos = roots.map((root) => copies({ root, packageRoot: PACKAGE_ROOT, all }));
272
+ const decided = `Decided by the release lists in Atlas ${ATLAS_VERSION}, never by git history or the network.`;
273
+ if (json) {
274
+ line(JSON.stringify({ atlas: ATLAS_VERSION, decided_by: decided, repos }, null, 2));
275
+ } else {
276
+ for (const repo of repos) {
277
+ line(repo.root);
278
+ for (const one of repo.rows) {
279
+ line(` ${one.state.toUpperCase().padEnd(8)} ${one.type.padEnd(8)} ${one.path}`);
280
+ line(` have ${one.have ?? '—'} · want ${one.want}`);
281
+ if (one.state !== 'current' || one.have !== one.want) line(` ${one.why}`);
282
+ }
283
+ line('');
284
+ }
285
+ line(decided);
286
+ }
287
+ const bad = all ? ['behind', 'broken'] : ['behind'];
288
+ return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
289
+ }
290
+
265
291
  function kindDriftCommand(chosen) {
266
292
  const root = rootOf(chosen);
267
293
  let recordKind = chosen['record-kind'] ?? null;
@@ -13,10 +13,12 @@
13
13
  //
14
14
  // CI never makes this judgement. A check with no network cannot learn the
15
15
  // package's list. This runs in a session, with the package on disk.
16
- import { readFileSync, existsSync } from 'node:fs';
17
- import { join } from 'node:path';
18
- import { CHECK_PATH, checkStamps, sha256 } from '../../shape/check.mjs';
19
- import { packagedCheck, placeOf } from './releases.mjs';
16
+ import { readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
17
+ import { join, relative, sep } from 'node:path';
18
+ import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, checkStamps, sha256 } from '../../shape/check.mjs';
19
+ import { packagedCheck, placeOf, packagePackageVersion } from './releases.mjs';
20
+ import { currentKitHash, KIT_RELEASES_FILE, KIT_TARGET, MANIFEST } from './tests.mjs';
21
+ import { kitHashOf } from '../../tests/link-check.mjs';
20
22
 
21
23
  export const STATES = Object.freeze(['current', 'behind', 'unknown', 'missing']);
22
24
 
@@ -45,3 +47,220 @@ export function status({ root, packageRoot }) {
45
47
  export function statusMany(roots, packageRoot) {
46
48
  return roots.map((root) => { try { return status({ root, packageRoot }); } catch (error) { return { root, state: 'unknown', why: error.message, packaged: null, copy: null }; } });
47
49
  }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Every Atlas copy in a repository, and the age of each one (roadmap item
53
+ // R19, slice 0 of the routines design). The check is one copy of four types:
54
+ //
55
+ // check .github/atlas/check.mjs, by the release list of the check
56
+ // workflow .github/workflows/atlas-check.yml, by the workflow fingerprint
57
+ // in each row of that list
58
+ // kit each <area>/test/atlas/, by its manifest and the kit list
59
+ // pin an Atlas version written in a workflow, a script or package.json
60
+ //
61
+ // Each row has have, want and state. A copy that cannot be read is broken,
62
+ // never missing. Like the check alone, every answer comes from the lists this
63
+ // package carries, never from git history or the network.
64
+ export const COPY_STATES = Object.freeze(['current', 'behind', 'unknown', 'missing', 'broken']);
65
+ export const COPY_TYPES = Object.freeze(['check', 'workflow', 'kit', 'pin']);
66
+
67
+ const posix = (path) => path.split(sep).join('/');
68
+ const SKIP = new Set(['node_modules', '.git']);
69
+ const MAX_DEPTH = 8;
70
+ const MAX_PIN_FILE = 512 * 1024;
71
+
72
+ const row = (type, path, state, have, want, why) => ({ type, path, state, have, want, why });
73
+ const broken = (type, path, want, error) => row(type, path, 'broken', null, want, `it cannot be read: ${error.message}`);
74
+ // A link is never read. A link at a copy's path, even one that points
75
+ // nowhere, is a copy that cannot be read: broken, never missing.
76
+ const linkAt = (full) => { try { return lstatSync(full).isSymbolicLink(); } catch { return false; } };
77
+ const linked = (type, path, want) => row(type, path, 'broken', null, want, `${path} is a link. Atlas never reads a copy through a link. Replace it with a plain file or folder.`);
78
+
79
+ function checkRow(root, packageRoot) {
80
+ const pkg = packagedCheck(packageRoot);
81
+ const want = `check version ${checkStamps(pkg.text).check_version}`;
82
+ if (linkAt(join(root, CHECK_PATH))) return linked('check', CHECK_PATH, want);
83
+ let answer;
84
+ try { answer = status({ root, packageRoot }); } catch (error) { return broken('check', CHECK_PATH, want, error); }
85
+ const have = answer.copy ? (answer.state === 'unknown' ? `sha256 ${answer.copy.sha256.slice(0, 12)}…` : `check version ${answer.copy.check_version}`) : null;
86
+ return row('check', CHECK_PATH, answer.state, have, want, answer.why);
87
+ }
88
+
89
+ // The workflow is written with the check, from the WORKFLOW_TEMPLATE of that
90
+ // check. Each row of the release list keeps the fingerprint of its template.
91
+ function workflowRow(root, packageRoot) {
92
+ const pkg = packagedCheck(packageRoot);
93
+ const version = checkStamps(pkg.text).check_version;
94
+ const want = `workflow of check version ${version}`;
95
+ const full = join(root, WORKFLOW_PATH);
96
+ if (linkAt(full)) return linked('workflow', WORKFLOW_PATH, want);
97
+ if (!existsSync(full)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
98
+ let text;
99
+ try { text = readFileSync(full, 'utf8'); } catch (error) { return broken('workflow', WORKFLOW_PATH, want, error); }
100
+ const fingerprint = sha256(text);
101
+ if (fingerprint === sha256(WORKFLOW_TEMPLATE)) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
102
+ const rows = pkg.releases.filter((one) => one.workflow_sha256 === fingerprint);
103
+ if (rows.length) {
104
+ const newest = rows.at(-1);
105
+ return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package}. "atlas tooling pr --workflow" refreshes it.`);
106
+ }
107
+ return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${fingerprint.slice(0, 12)}…`, want, `the workflow is in no release. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
108
+ }
109
+
110
+ // A walk never follows a link and never enters node_modules or .git. The
111
+ // visit sees a link as a link, and says which other folders it enters: it
112
+ // returns false to stop.
113
+ function walk(dir, visit, depth = 0) {
114
+ if (depth > MAX_DEPTH) return;
115
+ let names;
116
+ try { names = readdirSync(dir).sort(); } catch { return; }
117
+ for (const name of names) {
118
+ if (SKIP.has(name)) continue;
119
+ const full = join(dir, name);
120
+ let stat;
121
+ try { stat = lstatSync(full); } catch { continue; }
122
+ if (visit(full, name, stat) === false) continue;
123
+ if (stat.isDirectory()) walk(full, visit, depth + 1);
124
+ }
125
+ }
126
+
127
+ // Each <area>/test/atlas/ folder, where "atlas tests write" puts the kit. A
128
+ // dot folder holds no area: the check's base tree is one.
129
+ function kitFolders(root) {
130
+ const found = [];
131
+ const [parent, leaf] = KIT_TARGET.split('/');
132
+ walk(root, (full, name, stat) => {
133
+ const isKit = name === leaf && full.split(sep).at(-2) === parent;
134
+ if (isKit && stat.isSymbolicLink()) { found.push(full); return false; }
135
+ if (!stat.isDirectory() || name.startsWith('.')) return false;
136
+ if (isKit) { found.push(full); return false; }
137
+ return true;
138
+ });
139
+ return found;
140
+ }
141
+
142
+ function kitFilesOnDisk(dir, prefix = '') {
143
+ const out = [];
144
+ for (const name of readdirSync(dir).sort()) {
145
+ if (name === '__pycache__') continue;
146
+ const full = join(dir, name);
147
+ const stat = lstatSync(full);
148
+ if (stat.isDirectory()) out.push(...kitFilesOnDisk(full, `${prefix}${name}/`));
149
+ else out.push({ path: `${prefix}${name}`, full, link: stat.isSymbolicLink() });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function kitRow(root, dir, ctx) {
155
+ const path = `${posix(relative(root, dir))}/`;
156
+ const area = posix(relative(root, join(dir, '..', '..'))) || '.';
157
+ const want = `kit of Atlas ${ctx.version}`;
158
+ const refresh = `"atlas tests write --root ${area}" refreshes it.`;
159
+ if (linkAt(dir)) return linked('kit', path, want);
160
+ try {
161
+ const manifestPath = join(dir, MANIFEST);
162
+ if (!existsSync(manifestPath)) return row('kit', path, 'broken', null, want, `the kit has no ${MANIFEST}, so its age cannot be read. ${refresh}`);
163
+ let manifest;
164
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return row('kit', path, 'broken', null, want, `${MANIFEST} is not JSON, so the kit's age cannot be read. ${refresh}`); }
165
+ const listed = manifest?.files;
166
+ if (!listed || typeof listed !== 'object' || Array.isArray(listed)) return row('kit', path, 'broken', null, want, `${MANIFEST} lists no files, so the kit's age cannot be read. ${refresh}`);
167
+ const have = `kit of Atlas ${typeof manifest.atlas === 'string' ? manifest.atlas : '?'}`;
168
+ const onDisk = kitFilesOnDisk(dir).filter((one) => one.path !== MANIFEST);
169
+ const edited = [];
170
+ for (const one of onDisk) {
171
+ if (one.link || !Object.hasOwn(listed, one.path) || sha256(readFileSync(one.full)) !== listed[one.path]) edited.push(one.path);
172
+ }
173
+ const present = new Set(onDisk.map((one) => one.path));
174
+ for (const listedPath of Object.keys(listed)) if (!present.has(listedPath)) edited.push(listedPath);
175
+ if (edited.length) return row('kit', path, 'unknown', have, want, `${edited.length} file(s) differ from ${MANIFEST}: ${edited.slice(0, 3).join(', ')}. Someone edited the kit. ${refresh}`);
176
+ const hash = kitHashOf(listed);
177
+ if (hash === ctx.kitHash) return row('kit', path, 'current', have, want, `the kit is the one this package ships${have === want ? '' : '; only its version label is older'}.`);
178
+ const first = ctx.kitReleases.get(hash);
179
+ if (first) return row('kit', path, 'behind', have, want, `the kit was first shipped in Atlas ${first}. ${refresh}`);
180
+ return row('kit', path, 'unknown', have, want, `the kit is in no release. It came from an unreleased package, or someone edited its ${MANIFEST}. ${refresh}`);
181
+ } catch (error) {
182
+ return broken('kit', path, want, error);
183
+ }
184
+ }
185
+
186
+ // A version number compared part by part: -1, 0 or 1.
187
+ function compare(a, b) {
188
+ const [x, y] = [a, b].map((one) => one.split('.').map(Number));
189
+ for (let index = 0; index < 3; index += 1) if (x[index] !== y[index]) return x[index] < y[index] ? -1 : 1;
190
+ return 0;
191
+ }
192
+
193
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
194
+
195
+ // The places a repo writes an Atlas version: the workflows and actions under
196
+ // .github/, the scripts under scripts/, and package.json. A tag such as
197
+ // "latest", or a version held in a variable, is no pin.
198
+ function pinFiles(root) {
199
+ const files = [];
200
+ for (const top of ['.github', 'scripts']) {
201
+ const dir = join(root, top);
202
+ if (!existsSync(dir)) continue;
203
+ walk(dir, (full, name, stat) => {
204
+ if (stat.isFile() && stat.size <= MAX_PIN_FILE && posix(relative(root, full)) !== CHECK_PATH) files.push(full);
205
+ return stat.isDirectory();
206
+ });
207
+ }
208
+ const manifest = join(root, 'package.json');
209
+ if (existsSync(manifest)) files.push(manifest);
210
+ return files;
211
+ }
212
+
213
+ function pinRows(root, ctx) {
214
+ const name = escape(ctx.name);
215
+ const patterns = [
216
+ new RegExp(`${name}@(\\d+\\.\\d+\\.\\d+)\\b`, 'g'),
217
+ new RegExp(`"${name}"\\s*:\\s*"[~^=]?(\\d+\\.\\d+\\.\\d+)"`, 'g'),
218
+ /\bATLAS_VERSION\s*[:=]\s*["']?(\d+\.\d+\.\d+)\b/g,
219
+ ];
220
+ const rows = [];
221
+ for (const full of pinFiles(root)) {
222
+ const file = posix(relative(root, full));
223
+ let text;
224
+ try { text = readFileSync(full, 'utf8'); } catch (error) { rows.push(broken('pin', file, ctx.version, error)); continue; }
225
+ text.split('\n').forEach((line, index) => {
226
+ const seen = new Set();
227
+ for (const pattern of patterns) {
228
+ for (const match of line.matchAll(pattern)) {
229
+ if (seen.has(match[1])) continue;
230
+ seen.add(match[1]);
231
+ const at = `${file}:${index + 1}`;
232
+ const order = compare(match[1], ctx.version);
233
+ if (order === 0) rows.push(row('pin', at, 'current', match[1], ctx.version, 'the pin names the version of this package.'));
234
+ else if (order < 0) rows.push(row('pin', at, 'behind', match[1], ctx.version, `the pin names Atlas ${match[1]}, and this package is ${ctx.version}. Move the pin in this line.`));
235
+ else rows.push(row('pin', at, 'unknown', match[1], ctx.version, `the pin names Atlas ${match[1]}, which is newer than this package. Upgrade Atlas to judge it.`));
236
+ }
237
+ }
238
+ });
239
+ }
240
+ return rows;
241
+ }
242
+
243
+ function kitReleases(packageRoot) {
244
+ const file = join(packageRoot, KIT_RELEASES_FILE);
245
+ if (!existsSync(file)) return new Map();
246
+ return new Map((JSON.parse(readFileSync(file, 'utf8')).releases ?? []).map((one) => [String(one.kit_sha256), String(one.first_package)]));
247
+ }
248
+
249
+ // One row for each Atlas copy in the repository. With all false, only the
250
+ // check, which is what "atlas status" answered before --all.
251
+ export function copies({ root, packageRoot, all = true }) {
252
+ const version = packagePackageVersion(packageRoot);
253
+ const rows = [checkRow(root, packageRoot)];
254
+ if (all) {
255
+ const ctx = {
256
+ version,
257
+ name: JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).name,
258
+ kitHash: currentKitHash(packageRoot),
259
+ kitReleases: kitReleases(packageRoot),
260
+ };
261
+ rows.push(workflowRow(root, packageRoot));
262
+ for (const dir of kitFolders(root)) rows.push(kitRow(root, dir, ctx));
263
+ rows.push(...pinRows(root, ctx));
264
+ }
265
+ return { root, atlas: version, rows };
266
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.49",
3
+ "version": "0.3.50",
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",
@@ -1,30 +1,35 @@
1
1
  {
2
- "note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. atlas status reads this list; git history is never read.",
2
+ "note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. workflow_sha256 is the fingerprint of the workflow that check writes (its WORKFLOW_TEMPLATE). atlas status reads this list; git history is never read.",
3
3
  "releases": [
4
4
  {
5
5
  "check_version": 1,
6
6
  "sha256": "3dfd659a7533c51dce11bd0e2d7a9312f041c5955d45ac3cc67d3e29d328b5d2",
7
- "first_package": "0.1.1"
7
+ "first_package": "0.1.1",
8
+ "workflow_sha256": "a463b58c30d5d7b1de27b5c7c145dd86d6ea58356ab83d9e6ddc62705902299e"
8
9
  },
9
10
  {
10
11
  "check_version": 2,
11
12
  "sha256": "a4bc5ab8c75be8f216d460df9233670f5ba4ed9d853236f6b55a7f0d10dfe293",
12
- "first_package": "0.2.0"
13
+ "first_package": "0.2.0",
14
+ "workflow_sha256": "b333b57c016791e2b77f983f6fe70c9aaf50f5f6fca28b7b9ca84c222dd7b7e1"
13
15
  },
14
16
  {
15
17
  "check_version": 3,
16
18
  "sha256": "0184db66fd5d5061c21ee7d2d88395b5bac64ab1308e02e06781e6015f3d7608",
17
- "first_package": "0.3.0"
19
+ "first_package": "0.3.0",
20
+ "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
18
21
  },
19
22
  {
20
23
  "check_version": 4,
21
24
  "sha256": "3357ea247694634c3535998368683eceffaf863221dee5fec9e43a03042afb95",
22
- "first_package": "0.3.36"
25
+ "first_package": "0.3.36",
26
+ "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
23
27
  },
24
28
  {
25
29
  "check_version": 5,
26
30
  "sha256": "8f36633fe693ebf7a7cc0f7237d49eb4720f67d371d4758d556bab51965ec334",
27
- "first_package": "0.3.48"
31
+ "first_package": "0.3.48",
32
+ "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
28
33
  }
29
34
  ]
30
35
  }
@@ -96,7 +96,7 @@ set. A test keeps the two lists equal.
96
96
  | `atlas upgrade` | When the owner asks to upgrade Atlas. Call the `atlas_upgrade` tool instead: the Bash sandbox blocks this command. Run the command by hand only where that tool is missing. In a cloud session, call the tool with `check: true` at the first reply, as section 7 says |
97
97
  | `atlas doctor` | When Atlas seems missing or broken in a session |
98
98
  | `atlas check` | Before you ask for a merge in a repo that has the shape check |
99
- | `atlas status` | To learn whether a repo's check is current or behind |
99
+ | `atlas status` | To learn whether a repo's check is current or behind. `atlas status --all --json <repo>` lists each Atlas copy in a repo, with have, want and state |
100
100
  | `atlas tooling` | When `atlas status` says behind; `pr` opens the pull request |
101
101
  | `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
102
102
  | `atlas code-read` | To resolve the citations of a code digest |