@arjunkhera/atlas 0.4.44 → 0.4.46

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.4.44",
3
+ "version": "0.4.46",
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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // The atlas command: install, upgrade, doctor, check, status, the tooling
3
- // writer, the kind drift report, the code-read resolver, the STE check, the
3
+ // writer, the code-read resolver, the STE check, the
4
4
  // design check and page builder, the tests verbs (the link check and the
5
5
  // test kit writer), the upkeep check, and the judge of the fresh-agent test.
6
6
  //
@@ -18,7 +18,6 @@ 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
20
  import { statusMany, copies } from './lib/status.mjs';
21
- import { drift, kindsFromRegistry, fileKind } from './lib/kind-drift.mjs';
22
21
  import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
23
22
  import { packagePackageVersion } from './lib/releases.mjs';
24
23
  import { install, upgrade, doctor } from './lib/install.mjs';
@@ -54,9 +53,8 @@ const HELP = `atlas — the door into a repo's Atlas files
54
53
  atlas status --root <repo> is the repo's check current, behind, unknown or missing
55
54
  --all every Atlas copy in the repo: the check, its workflow, each test kit, each pinned Atlas version
56
55
  --json print the rows as JSON: type, path, state, have, want, why
57
- atlas kind-drift --root <repo> --record-kind <kinds>
58
- does atlas.yaml agree with the repo record
59
56
  atlas check --root <repo> run the repo shape check here
57
+ --json print the report as JSON, with the counts of parts, procedures and jobs
60
58
 
61
59
  atlas code-read resolve … resolve the citations of a code digest
62
60
 
@@ -210,8 +208,7 @@ A person merges every file the tooling writer writes.
210
208
  export const FLAGS = Object.freeze({
211
209
  tooling: { root: 'optional', branch: 'optional', 'replace-unknown': 'switch', workflow: 'switch', 'dry-run': 'switch' },
212
210
  status: { root: 'optional', all: 'switch', json: 'switch' },
213
- 'kind-drift': { root: 'optional', 'record-kind': 'value', registry: 'value', repo: 'value' },
214
- check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
211
+ check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
215
212
  ste: { share: 'switch', terms: 'value' },
216
213
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
217
214
  view: { product: 'value', name: 'value', out: 'value' },
@@ -338,40 +335,6 @@ function copiesCommand(roots, { all, json }) {
338
335
  return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
339
336
  }
340
337
 
341
- function kindDriftCommand(chosen) {
342
- const root = rootOf(chosen);
343
- let recordKind = chosen['record-kind'] ?? null;
344
- if (!recordKind && chosen.registry) {
345
- const kinds = kindsFromRegistry(JSON.parse(readFileSync(resolve(chosen.registry), 'utf8')));
346
- const repo = chosen.repo ?? null;
347
- recordKind = repo ? kinds[repo] ?? null : null;
348
- if (!repo) {
349
- // One checkout, so only the registry row that names THIS repo can be
350
- // compared. Comparing every row against one root invented drift that was
351
- // not there (acceptance review of batch 16, finding S2-6).
352
- const here = fileKind(root);
353
- if (!here.repo) { line(`${root} has no atlas.yaml, or its atlas.yaml names no repo, so no registry row can be matched to it.`); return 1; }
354
- recordKind = kinds[here.repo] ?? null;
355
- const others = Object.keys(kinds).filter((id) => id !== here.repo);
356
- const report = drift([{ root, recordKind, recordRepo: here.repo }]);
357
- for (const row of report.rows) line(`${row.state.toUpperCase().padEnd(10)} ${row.repo} file=${row.file_kind ?? '-'} record=${row.record_kind ?? '-'} ${row.why}`);
358
- if (!(here.repo in kinds)) line(`The registry holds no row for ${here.repo}. Register it with the repo_add verb.`);
359
- if (others.length) line(`Not this checkout, so not read: ${others.join(', ')}. Point --root at each one.`);
360
- return report.drifting ? 1 : 0;
361
- }
362
- }
363
- const report = drift([{ root, recordKind }]);
364
- for (const row of report.rows) {
365
- line(`${row.state.toUpperCase()} ${row.repo ?? row.root}`);
366
- line(` atlas.yaml kind ${row.file_kind ?? '(none)'} spec ${row.spec ?? '(none)'}`);
367
- line(` repo record kind ${row.record_kind ?? '(not given)'}`);
368
- line(` ${row.why}`);
369
- }
370
- line('');
371
- line('Which of the two is the source is open question O-9. This report picks no winner.');
372
- return report.drifting ? 1 : 0;
373
- }
374
-
375
338
  function steCommand(chosen) {
376
339
  const paths = chosen._.slice(1);
377
340
  if (!paths.length) throw new Error('give a file or a folder: atlas ste <path> [--share]');
@@ -484,6 +447,12 @@ function checkCommand(chosen) {
484
447
  const root = rootOf(chosen);
485
448
  const report = scan({ root, repoId: chosen.repo ?? null });
486
449
  const decision = verdict(report, chosen['fail-on'] ?? 'error');
450
+ // The O7 count is one command (roadmap R62 line 6): the report, its counts
451
+ // of listed parts, procedures and jobs, and the verdict.
452
+ if (chosen.json) {
453
+ process.stdout.write(`${JSON.stringify({ ...report, fail_on: chosen['fail-on'] ?? 'error', green: decision.green, why: decision.why }, null, 2)}\n`);
454
+ return decision.green ? 0 : 1;
455
+ }
487
456
  process.stdout.write(toText(report));
488
457
  // A report, never a reason for red: the list is an owner-only key.
489
458
  const list = mergeList(root);
@@ -509,7 +478,6 @@ export async function main(argv = process.argv.slice(2)) {
509
478
  case 'doctor': return doctor({ packageRoot: PACKAGE_ROOT, options: chosen, line });
510
479
  case 'tooling': return toolingCommand(chosen);
511
480
  case 'status': return statusCommand(chosen);
512
- case 'kind-drift': return kindDriftCommand(chosen);
513
481
  case 'check': return checkCommand(chosen);
514
482
  case 'ste': return steCommand(chosen);
515
483
  case 'design': return designCommand(chosen);
@@ -215,7 +215,7 @@ export function atlasYamlCause(baseText, headText, rules) {
215
215
  if (!changed.length) return 'atlas.yaml text changed outside its keys';
216
216
  for (const key of changed) {
217
217
  if (rules.fixed.includes(key)) return `atlas.yaml key ${key} changed`;
218
- if (!rules.add_only.includes(key)) return `atlas.yaml key ${key} changed, and R62 does not list it yet`;
218
+ if (!rules.add_only.includes(key)) return `atlas.yaml key ${key} changed, and the key list in owner-only.yaml names no rule for it`;
219
219
  if (!onlyAdds(a[key], b[key])) return `atlas.yaml key ${key}: an entry was removed, moved or changed`;
220
220
  }
221
221
  return null;
@@ -17,7 +17,7 @@
17
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
18
18
  import { execFileSync } from 'node:child_process';
19
19
  import { dirname, join } from 'node:path';
20
- import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
20
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, CHECK_VERSION, SPECS, SPEC_3, 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
23
  import { PR_TEMPLATE_PATH } from './upkeep.mjs';
@@ -122,12 +122,43 @@ export function isPackageRepo(root, packageRoot) {
122
122
  // refusal that says what to do instead.
123
123
  const readWorkflow = (root) => { const full = join(root, WORKFLOW_PATH); return existsSync(full) ? readFileSync(full, 'utf8') : null; };
124
124
 
125
+ // The spec 3 lines for a repo whose atlas.yaml is older (roadmap R62). Check
126
+ // version 10 reads spec 3 only, so a copy written into a spec 2 repo would turn
127
+ // its own check red. The lines name the parts this repo already carries: the
128
+ // two entry maps, the index and the two tooling files, plus the rules and a
129
+ // linter config where the repo has them.
130
+ export function specThreeLines(root) {
131
+ const parts = ['claude-md', 'agents-md'];
132
+ if (existsSync(join(root, '.claude', 'rules'))) parts.push('rules');
133
+ parts.push('docs-index', 'check-file', 'gate-workflow');
134
+ const linters = SPEC_3.files.flatMap((file) => file.parts).find((part) => part.id === 'linters').candidates;
135
+ if (linters.some((path) => existsSync(join(root, path)))) parts.push('linters');
136
+ return [`parts: [${parts.join(', ')}]`];
137
+ }
138
+
139
+ // The spec version atlas.yaml declares, or null when there is no file or it
140
+ // does not read. The check reports those cases itself.
141
+ function declaredSpec(root) {
142
+ const file = join(root, 'atlas.yaml');
143
+ if (!existsSync(file)) return null;
144
+ try { return parseYaml(readFileSync(file, 'utf8'))?.spec ?? null; } catch { return null; }
145
+ }
146
+
125
147
  export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOnly = false } = {}) {
126
148
  const pkg = packagedCheck(packageRoot);
127
149
  const own = isPackageRepo(root, packageRoot);
128
150
  if (!pkg.latest && !own) {
129
151
  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.`);
130
152
  }
153
+ const spec = declaredSpec(root);
154
+ if (spec !== null && !workflowOnly && !SPECS[spec]) {
155
+ throw new ToolingRefusal([
156
+ `atlas.yaml is spec ${spec}. Check version ${CHECK_VERSION} reads spec ${Object.keys(SPECS).join(', ')} only.`,
157
+ 'Set spec: 3, remove the kind: line, and add:',
158
+ ...specThreeLines(root),
159
+ 'Then run atlas tooling again. Nothing was written.',
160
+ ].join('\n'));
161
+ }
131
162
  const now = status({ root, packageRoot });
132
163
  if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !pinnedNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
133
164
  throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE || workflowKept(root) ? '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 });
@@ -7,7 +7,8 @@
7
7
  //
8
8
  // 1. Docs index. docs/index.md lists each .md file under docs/. A file in a
9
9
  // design folder (a folder that holds design.yaml) counts as listed when the
10
- // index lists that folder's design.yaml. Each listed path exists.
10
+ // index lists that folder's design.yaml. Each listed path exists. The rule
11
+ // is the shape check's own, docsIndexRule (roadmap R62 line 5).
11
12
  // 2. Dead links. Each relative link in a tracked .md file names a file or a
12
13
  // folder that exists. archive/ is frozen, and test fixtures are planted on
13
14
  // purpose, so both are skipped; so is text inside code.
@@ -24,8 +25,9 @@
24
25
  // and a stale page is listed.
25
26
  //
26
27
  // It reads the tree and the event file, and writes nothing.
27
- import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
28
- import { join, dirname, posix, resolve } from 'node:path';
28
+ import { readFileSync, existsSync } from 'node:fs';
29
+ import { join, dirname, resolve } from 'node:path';
30
+ import { DOCS_INDEX_PATH, docsIndexRule, markdownFiles, proseOf, linkTargets } from '../../shape/check.mjs';
29
31
  import { listChanged } from './changed-tests.mjs';
30
32
  import { docSourceFindings, replay } from './doc-sources.mjs';
31
33
  import { proceduresCommand } from './procedure-steps.mjs';
@@ -37,62 +39,13 @@ import { changedDocFindings } from './changed-docs.mjs';
37
39
  // touched doc with no update and no mark a note, not a finding.
38
40
  export const TOUCHED_DOCS_GATE = true;
39
41
 
40
- const INDEX = 'docs/index.md';
41
- const SKIP_DIRS = new Set(['node_modules', 'archive', 'fixtures']);
42
+ const INDEX = DOCS_INDEX_PATH;
42
43
  const MAX_LISTED = 20;
43
44
 
44
- // Every .md file under the root, as a posix path. Dot folders, node_modules,
45
- // archive/ and any folder named fixtures are skipped.
46
- export function markdownFiles(root, under = '') {
47
- const out = [];
48
- const walk = (rel) => {
49
- let entries;
50
- try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
51
- for (const entry of entries) {
52
- if (entry.name.startsWith('.')) continue;
53
- const path = rel ? `${rel}/${entry.name}` : entry.name;
54
- if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) walk(path); }
55
- else if (entry.isFile() && entry.name.endsWith('.md')) out.push(path);
56
- }
57
- };
58
- walk(under);
59
- return out.sort();
60
- }
61
-
62
- // Text outside code: fenced blocks of three or more marks, and inline code.
63
- export function proseOf(text) {
64
- const lines = String(text ?? '').split('\n');
65
- const kept = [];
66
- let fence = null;
67
- for (const one of lines) {
68
- const open = one.match(/^\s*(`{3,}|~{3,})/);
69
- if (fence) {
70
- if (open && open[1][0] === fence[0] && open[1].length >= fence.length && !one.trim().slice(open[1].length).trim()) fence = null;
71
- kept.push('');
72
- continue;
73
- }
74
- if (open) { fence = open[1]; kept.push(''); continue; }
75
- kept.push(one.replace(/`+[^`\n]*`+/g, ''));
76
- }
77
- return kept.join('\n');
78
- }
79
-
80
- // The relative link targets in one markdown text, with their line numbers.
81
- export function linkTargets(text) {
82
- const out = [];
83
- proseOf(text).split('\n').forEach((one, index) => {
84
- for (const [, angled, bare] of one.matchAll(/\]\(\s*(?:<([^>\n]+)>|([^)\s]+))(?:\s+"[^"]*")?\s*\)/g)) {
85
- const raw = angled ?? bare;
86
- const target = raw.split('#')[0].split('?')[0];
87
- if (!target || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('#')) continue;
88
- if (/[{}$*\\]/.test(target)) continue;
89
- let decoded = target;
90
- try { decoded = decodeURIComponent(target); } catch { /* keep it as written */ }
91
- out.push({ target: decoded, line: index + 1 });
92
- }
93
- });
94
- return out;
95
- }
45
+ // markdownFiles, proseOf and linkTargets live in the shape check, beside the
46
+ // one docs index rule (roadmap R62 line 5). They stay exported here, so their
47
+ // readers keep one door.
48
+ export { markdownFiles, proseOf, linkTargets };
96
49
 
97
50
  export function deadLinks(root) {
98
51
  const dead = [];
@@ -106,32 +59,11 @@ export function deadLinks(root) {
106
59
  return dead;
107
60
  }
108
61
 
109
- // The nearest folder at or above `file` that holds design.yaml, or null.
110
- function designFolderOf(root, file) {
111
- let dir = posix.dirname(file);
112
- while (dir && dir !== '.' && dir !== 'docs') {
113
- if (existsSync(join(root, dir, 'design.yaml'))) return dir;
114
- dir = posix.dirname(dir);
115
- }
116
- return null;
117
- }
118
-
62
+ // The docs index rule is the shape check's, so the check and this command
63
+ // give one count on one tree (roadmap R62 line 5).
119
64
  export function docsIndex(root) {
120
- if (!existsSync(join(root, 'docs')) || !statSync(join(root, 'docs')).isDirectory()) return { checked: false, unlisted: [], gone: [] };
121
- if (!existsSync(join(root, INDEX))) return { checked: true, missing: true, unlisted: markdownFiles(root, 'docs'), gone: [] };
122
- const listed = new Set();
123
- for (const { target } of linkTargets(readFileSync(join(root, INDEX), 'utf8'))) {
124
- if (target.startsWith('/')) continue;
125
- const path = posix.normalize(posix.join('docs', target)).replace(/\/$/, '');
126
- if (path.startsWith('docs/')) listed.add(path);
127
- }
128
- const unlisted = markdownFiles(root, 'docs').filter((path) => {
129
- if (path === INDEX || listed.has(path)) return false;
130
- const folder = designFolderOf(root, path);
131
- return !(folder && listed.has(`${folder}/design.yaml`));
132
- });
133
- const gone = [...listed].filter((path) => !existsSync(join(root, path))).sort();
134
- return { checked: true, unlisted, gone };
65
+ const { checked, missing, unlisted, gone } = docsIndexRule(root);
66
+ return missing ? { checked, missing, unlisted, gone } : { checked, unlisted, gone };
135
67
  }
136
68
 
137
69
  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;
package/owner-only.yaml CHANGED
@@ -218,22 +218,30 @@ floor:
218
218
  # does not follow names out of it (shape/check.mjs, the exemption).
219
219
  entry_map: docs/index.md
220
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:`.
221
+ # The key rule for atlas.yaml (roadmap R2 line 11, R62 line 3). Each key of
222
+ # atlas.yaml is on one of the two lists. A pull request may add entries under
223
+ # an add_only key and merge on green; any other change to it, and any change
224
+ # to a fixed key, comes to the owner. A key on neither list comes to the owner
225
+ # too, until a pull request puts it on one.
224
226
  atlas_yaml:
225
227
  add_only:
226
228
  - parts
229
+ - procedures
230
+ - files
231
+ - kinds_of_change
227
232
  fixed:
228
- - owner_only
233
+ - spec
234
+ - product
235
+ - repo
229
236
  - areas
237
+ - owner_only
230
238
  - required_checks
231
- - product
232
239
  - product_paths
233
- - repo
234
- - kind
235
- - kinds
236
240
  - up_to_date
237
241
  - brake
242
+ - live
243
+ - jobs
244
+ - design
238
245
  - generated
239
- - spec
246
+ - verbatim
247
+ - private_terms
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.44",
3
+ "version": "0.4.46",
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",