@arjunkhera/atlas 0.4.49 → 0.4.51
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 +51 -4
- package/door/lib/changed-docs.mjs +22 -5
- package/door/lib/design-freeze.mjs +251 -0
- package/door/lib/doc-sources.mjs +11 -16
- package/door/lib/owner-only.mjs +22 -0
- package/door/lib/runner.mjs +286 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +2 -1
- package/skills/runner/SKILL.md +83 -0
- package/skills/runner/trigger.yaml +24 -0
- package/skills/sdlc-task/SKILL.md +9 -1
- package/skills/sdlc-task/design/README.md +8 -5
- package/skills/upkeep/SKILL.md +26 -12
- package/work/lib/delivery.mjs +22 -9
- package/work/lib/questions.mjs +2 -2
- package/work/lib/roles.mjs +5 -2
- package/work/lib/roundup.mjs +26 -5
- package/work/lib/verb-fields.mjs +57 -0
- package/work/lib/verbs.mjs +83 -7
- package/work/lib/view-rules.mjs +19 -0
- package/work/mcp.mjs +11 -4
package/door/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
|
|
|
24
24
|
import { checkPaths, LIMITS } from './lib/ste.mjs';
|
|
25
25
|
import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
|
|
26
26
|
import { writeDesignPage, readTracker } from './lib/design-build.mjs';
|
|
27
|
+
import { listDesigns, freezeDesign } from './lib/design-freeze.mjs';
|
|
27
28
|
import { writePage } from './lib/page.mjs';
|
|
28
29
|
import { writeViewPage, viewCapabilities } from './lib/view.mjs';
|
|
29
30
|
import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
@@ -34,6 +35,7 @@ import { runKept } from '../tests/kept.mjs';
|
|
|
34
35
|
import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
|
|
35
36
|
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
36
37
|
import { jobsCommand } from './lib/jobs.mjs';
|
|
38
|
+
import { runnerCommand } from './lib/runner.mjs';
|
|
37
39
|
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
38
40
|
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
39
41
|
import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
@@ -70,6 +72,11 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
70
72
|
--tracker <file> the tracker data, as JSON, from the lead
|
|
71
73
|
--out <file> the page; default docs/artifacts/<folder name>.html
|
|
72
74
|
--draft build even when the check has findings, and mark the page a draft
|
|
75
|
+
atlas design list list each design with its item: live in the design folder, or frozen in archive/
|
|
76
|
+
--json print the rows as JSON: folder, item, state
|
|
77
|
+
atlas design freeze <folder> … freeze the design of a done item: move it to archive/ at its old path, point the links there
|
|
78
|
+
--date <YYYY-MM-DD> the frozen date for its page rows; default today
|
|
79
|
+
--dry-run say what would change, and change nothing
|
|
73
80
|
|
|
74
81
|
atlas page build <file or folder> build an explainer page from markdown: woodcut figures, the Atlas theme, light and dark
|
|
75
82
|
--out <file> the page; default <file or folder>.html beside it
|
|
@@ -143,6 +150,13 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
143
150
|
--dry-run print what it would open; change nothing
|
|
144
151
|
The token is only GITHUB_TOKEN. The atlas-jobs workflow runs both, once a day
|
|
145
152
|
|
|
153
|
+
atlas runner start --repo <owner/name> the start of a runner run: the start checks, the inputs it may build, the outside issues and the room under the caps; writes nothing
|
|
154
|
+
--branch <name> the default branch; default GitHub's answer
|
|
155
|
+
--json print the whole answer as JSON
|
|
156
|
+
atlas runner claim --repo <owner/name> --issue <n> --holder <run id> claim one atlas-job issue; the oldest live claim holds it
|
|
157
|
+
atlas runner line --from <file> print the last line of a run, from a JSON file of ids and counts
|
|
158
|
+
Until the A2 exit test, the runner works on the scratch repo only
|
|
159
|
+
|
|
146
160
|
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, stale pages, and in a pull request its tracker item, its changed designs and STE on its changed lines
|
|
147
161
|
atlas upkeep replay --root <repo> what the doc-sources rule would have asked of the last merges; writes nothing
|
|
148
162
|
atlas upkeep procedures --root <repo> run the procedure steps marked atlas-run; --base <ref> runs only the procedures a change touches
|
|
@@ -216,11 +230,12 @@ export const FLAGS = Object.freeze({
|
|
|
216
230
|
status: { root: 'optional', all: 'switch', json: 'switch' },
|
|
217
231
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
|
|
218
232
|
ste: { share: 'switch', terms: 'value' },
|
|
219
|
-
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
233
|
+
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional', date: 'value', 'dry-run': 'switch', json: 'switch' },
|
|
220
234
|
page: { out: 'value', title: 'value' },
|
|
221
235
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
222
236
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
|
|
223
237
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
238
|
+
runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', json: 'switch' },
|
|
224
239
|
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
|
|
225
240
|
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value', errors: 'switch', forced: 'switch', findings: 'value' },
|
|
226
241
|
approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value' },
|
|
@@ -363,13 +378,40 @@ function designCommand(chosen) {
|
|
|
363
378
|
const what = chosen._[1];
|
|
364
379
|
const folders = chosen._.slice(2);
|
|
365
380
|
if (what === 'folder') {
|
|
366
|
-
if (folders.length || chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft) throw new Error('atlas design folder takes only --root.');
|
|
381
|
+
if (folders.length || chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft || chosen.date !== undefined || chosen['dry-run'] || chosen.json) throw new Error('atlas design folder takes only --root.');
|
|
367
382
|
const where = designFolderOf(chosen.root === undefined || chosen.root === true ? repoRootFrom(process.cwd()) : resolve(chosen.root));
|
|
368
383
|
line(where.folder);
|
|
369
384
|
return 0;
|
|
370
385
|
}
|
|
371
|
-
if (
|
|
372
|
-
|
|
386
|
+
if (what === 'list' || what === 'freeze') {
|
|
387
|
+
if (chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft) throw new Error(`--tracker, --out and --draft work only with atlas design build.`);
|
|
388
|
+
const root = chosen.root === undefined || chosen.root === true ? repoRootFrom(process.cwd()) : resolve(chosen.root);
|
|
389
|
+
if (what === 'list') {
|
|
390
|
+
if (folders.length || chosen.date !== undefined || chosen['dry-run']) throw new Error('atlas design list takes only --root and --json.');
|
|
391
|
+
const rows = listDesigns(root);
|
|
392
|
+
if (chosen.json) { line(JSON.stringify(rows, null, 2)); return 0; }
|
|
393
|
+
for (const row of rows) line(`${row.state.padEnd(7)} ${(row.item ?? 'no item').slice(0, 8).padEnd(8)} ${row.folder}`);
|
|
394
|
+
line('');
|
|
395
|
+
line(`${rows.filter((row) => row.state === 'live').length} live, ${rows.filter((row) => row.state === 'frozen').length} frozen. Freeze a live design when the tracker says its item is done: atlas design freeze <folder>.`);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
if (chosen.json) throw new Error('--json works only with atlas design list.');
|
|
399
|
+
if (!folders.length) throw new Error('give a design folder: atlas design freeze <folder>');
|
|
400
|
+
if (chosen.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(chosen.date)) throw new Error(`--date "${chosen.date}": write a date as YYYY-MM-DD`);
|
|
401
|
+
for (const folder of folders) {
|
|
402
|
+
// A folder that cannot freeze is a finding about the input, not a
|
|
403
|
+
// wrong command: say why and stop with exit 1.
|
|
404
|
+
let report;
|
|
405
|
+
try { report = freezeDesign(root, folder, { date: chosen.date, dryRun: Boolean(chosen['dry-run']) }); } catch (error) { line(`Not frozen: ${error.message}`); return 1; }
|
|
406
|
+
const links = report.links.reduce((n, one) => n + one.count, 0);
|
|
407
|
+
line(`${report.dryRun ? 'Would move' : 'Moved'} ${report.from} to ${report.to} (item ${report.item ? report.item.slice(0, 8) : 'none'}): ${links} link(s) in ${report.links.length} file(s), ${report.index ? 'its index entry under Frozen designs' : 'no index entry'}, ${report.pages} page row(s) frozen.`);
|
|
408
|
+
}
|
|
409
|
+
if (!chosen['dry-run']) line('Stage the move with git add -A, so git sees each file as moved. Then run atlas upkeep check.');
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
if (chosen.root !== undefined) throw new Error('--root works only with atlas design folder, list and freeze.');
|
|
413
|
+
if (chosen.date !== undefined || chosen['dry-run'] || chosen.json) throw new Error('--date, --dry-run and --json work only with atlas design list and freeze.');
|
|
414
|
+
if (what !== 'check' && what !== 'build') throw new Error('use atlas design check <folder>, atlas design build <folder>, atlas design folder, atlas design list or atlas design freeze <folder>');
|
|
373
415
|
if (!folders.length) throw new Error(`give a design folder: atlas design ${what} <folder>`);
|
|
374
416
|
if (what === 'check' && (chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft)) throw new Error('--tracker, --out and --draft work only with atlas design build.');
|
|
375
417
|
if (what === 'build') {
|
|
@@ -453,6 +495,10 @@ function jobsVerb(chosen) {
|
|
|
453
495
|
return jobsCommand({ what: chosen._[1], root: rootOf(chosen), repo: chosen.repo, branch: chosen.branch, out: chosen.out, findings: chosen.findings, max: chosen.max, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
454
496
|
}
|
|
455
497
|
|
|
498
|
+
function runnerVerb(chosen) {
|
|
499
|
+
return runnerCommand({ what: chosen._[1], repo: chosen.repo, issue: chosen.issue, holder: chosen.holder, from: chosen.from, json: Boolean(chosen.json), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
500
|
+
}
|
|
501
|
+
|
|
456
502
|
function upkeepVerb(chosen) {
|
|
457
503
|
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last, base: chosen.base }, rootOf(chosen), line);
|
|
458
504
|
}
|
|
@@ -512,6 +558,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
512
558
|
case 'view': return viewCommand(chosen);
|
|
513
559
|
case 'tests': return testsCommand(chosen);
|
|
514
560
|
case 'jobs': return jobsVerb(chosen);
|
|
561
|
+
case 'runner': return runnerVerb(chosen);
|
|
515
562
|
case 'upkeep': return upkeepVerb(chosen);
|
|
516
563
|
case 'gate': return gateVerb(chosen);
|
|
517
564
|
case 'judge': return judgeVerb(chosen);
|
|
@@ -58,14 +58,31 @@ export function changedPaths(root, base, head) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// The line numbers of one file that the pull request adds or changes, from
|
|
61
|
-
// the hunks of a diff with no context.
|
|
61
|
+
// the hunks of a diff with no context. A line that the pull request only
|
|
62
|
+
// moves is not new text, and so is a line whose only change is a link
|
|
63
|
+
// target: the words are the same words (item e4df0d33, where
|
|
64
|
+
// `atlas design freeze` moves index entries and points links at the
|
|
65
|
+
// archive). Each removed line can excuse one added line.
|
|
66
|
+
export const sameWords = (line) => line.replace(/\]\(\s*(?:<[^>\n]*>|[^)\s]*)(?:\s+"[^"]*")?\s*\)/g, ']()').replace(/^(\s{0,3}\[[^\]\n]+\]:\s*)\S+/, '$1').trimEnd();
|
|
62
67
|
export function addedLines(root, base, head, path) {
|
|
63
68
|
const out = git(root, ['diff', '-U0', '--no-renames', '--no-ext-diff', '--no-textconv', `${base}...${head}`, '--', path]);
|
|
69
|
+
const removed = new Map();
|
|
70
|
+
for (const row of out.split('\n')) {
|
|
71
|
+
if (row.startsWith('-') && !row.startsWith('---')) {
|
|
72
|
+
const key = sameWords(row.slice(1));
|
|
73
|
+
if (key.trim()) removed.set(key, (removed.get(key) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
64
76
|
const lines = new Set();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
77
|
+
let at = null;
|
|
78
|
+
for (const row of out.split('\n')) {
|
|
79
|
+
const hunk = row.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
80
|
+
if (hunk) { at = Number(hunk[1]); continue; }
|
|
81
|
+
if (at === null || !row.startsWith('+') || row.startsWith('+++')) continue;
|
|
82
|
+
const key = sameWords(row.slice(1));
|
|
83
|
+
if (removed.get(key) > 0) removed.set(key, removed.get(key) - 1);
|
|
84
|
+
else if (key.trim()) lines.add(at);
|
|
85
|
+
at += 1;
|
|
69
86
|
}
|
|
70
87
|
return lines;
|
|
71
88
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// A design freezes when its item is done (tracker item e4df0d33). Code, no
|
|
2
|
+
// model, no key, no network. `atlas design list` and `atlas design freeze`
|
|
3
|
+
// use it.
|
|
4
|
+
//
|
|
5
|
+
// The owner, 31 October 2026: "Guide and product documentation is the one
|
|
6
|
+
// that is supposed to be kept updated, design is just a fleeting item of work
|
|
7
|
+
// that gets done, delivered and forgotten. But we should be able to read
|
|
8
|
+
// those anytime later".
|
|
9
|
+
//
|
|
10
|
+
// So a design is a decision record. While its item is open, it lives in the
|
|
11
|
+
// design folder. When the item is done, `atlas design freeze` moves it to
|
|
12
|
+
// archive/ at its old path, and it is frozen:
|
|
13
|
+
// 1. The folder moves as it is. Not one byte of it changes, so the gate reads
|
|
14
|
+
// the move as a move into the archive, not as an edit of the archive.
|
|
15
|
+
// 2. Each relative link in a live markdown file that points into the folder
|
|
16
|
+
// points at the new place, and so does each `sources:` entry in its front
|
|
17
|
+
// matter. Only the link target changes, never the words around it.
|
|
18
|
+
// 3. The docs index entry of the folder moves under "## Frozen designs", so a
|
|
19
|
+
// reader still finds it there.
|
|
20
|
+
// 4. Each row of docs/artifacts.json that is built from the folder names the
|
|
21
|
+
// new place and a frozen date, so the page is never called stale.
|
|
22
|
+
// The upkeep check reads no frozen design: archive/ is outside docs/, so the
|
|
23
|
+
// doc-sources rule and the docs index rule never see it.
|
|
24
|
+
//
|
|
25
|
+
// The rule reads no repo kind: a service, a library and a plugin keep designs
|
|
26
|
+
// alike.
|
|
27
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, renameSync, mkdirSync } from 'node:fs';
|
|
28
|
+
import { join, posix, relative, resolve, sep } from 'node:path';
|
|
29
|
+
import { parseYaml, markdownFiles, DOCS_INDEX_PATH } from '../../shape/check.mjs';
|
|
30
|
+
import { designFolderOf, DESIGN_FILE } from './design.mjs';
|
|
31
|
+
|
|
32
|
+
export const ARCHIVE = 'archive';
|
|
33
|
+
export const FROZEN_HEADING = '## Frozen designs';
|
|
34
|
+
const ARTIFACTS = 'docs/artifacts.json';
|
|
35
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
36
|
+
|
|
37
|
+
const readText = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
|
|
38
|
+
const isDir = (path) => { try { return statSync(path).isDirectory(); } catch { return false; } };
|
|
39
|
+
const toPosix = (path) => path.split(sep).join('/');
|
|
40
|
+
|
|
41
|
+
function itemOf(root, folder) {
|
|
42
|
+
try { return String(parseYaml(readText(join(root, folder, DESIGN_FILE)) ?? '')?.item ?? '') || null; } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function designsUnder(root, folder) {
|
|
46
|
+
const path = join(root, folder);
|
|
47
|
+
if (!isDir(path)) return [];
|
|
48
|
+
return readdirSync(path, { withFileTypes: true })
|
|
49
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(path, entry.name, DESIGN_FILE)))
|
|
50
|
+
.map((entry) => posix.join(folder, entry.name))
|
|
51
|
+
.sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Every design of the repo: the live ones in the design folder, and the
|
|
55
|
+
// frozen ones at the same path under archive/. Each row names its item.
|
|
56
|
+
export function listDesigns(root) {
|
|
57
|
+
const { folder } = designFolderOf(root);
|
|
58
|
+
const live = designsUnder(root, folder).map((path) => ({ folder: path, item: itemOf(root, path), state: 'live' }));
|
|
59
|
+
const frozen = designsUnder(root, posix.join(ARCHIVE, folder)).map((path) => ({ folder: path, item: itemOf(root, path), state: 'frozen' }));
|
|
60
|
+
return [...live, ...frozen];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The design whose item id starts with `prefix`: live first, then frozen.
|
|
64
|
+
export function designsOfItem(rows, prefix) {
|
|
65
|
+
const want = String(prefix ?? '').toLowerCase();
|
|
66
|
+
return want ? rows.filter((row) => row.item && row.item.toLowerCase().startsWith(want)) : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A relative link target that points into `from` (a posix path from the repo
|
|
70
|
+
// root) is rewritten to point at `to`. `file` is the markdown file that holds
|
|
71
|
+
// the link. A target with a scheme, a bare anchor, or a path outside the
|
|
72
|
+
// folder is kept as it is.
|
|
73
|
+
export function moveTarget(file, target, from, to) {
|
|
74
|
+
if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#') || target.startsWith('/')) return null;
|
|
75
|
+
const cut = target.search(/[#?]/);
|
|
76
|
+
const path = cut === -1 ? target : target.slice(0, cut);
|
|
77
|
+
const tail = cut === -1 ? '' : target.slice(cut);
|
|
78
|
+
if (!path) return null;
|
|
79
|
+
let decoded = path;
|
|
80
|
+
try { decoded = decodeURIComponent(path); } catch { /* keep it as written */ }
|
|
81
|
+
const at = posix.normalize(posix.join(posix.dirname(file), decoded));
|
|
82
|
+
const slash = decoded.endsWith('/');
|
|
83
|
+
const clean = at.replace(/\/$/, '');
|
|
84
|
+
if (clean !== from && !clean.startsWith(`${from}/`)) return null;
|
|
85
|
+
const moved = to + clean.slice(from.length);
|
|
86
|
+
let next = posix.relative(posix.dirname(file), moved) || '.';
|
|
87
|
+
if (slash) next += '/';
|
|
88
|
+
return next + tail;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The markdown text with each link into `from` pointed at `to`. Inline links
|
|
92
|
+
// and reference definitions; text inside code is never changed.
|
|
93
|
+
export function rewriteLinks(text, file, from, to) {
|
|
94
|
+
let count = 0;
|
|
95
|
+
const lines = String(text).split('\n');
|
|
96
|
+
let fence = null;
|
|
97
|
+
const out = lines.map((one) => {
|
|
98
|
+
const open = one.match(/^\s*(`{3,}|~{3,})/);
|
|
99
|
+
if (fence) {
|
|
100
|
+
if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null;
|
|
101
|
+
return one;
|
|
102
|
+
}
|
|
103
|
+
if (open) { fence = open[1]; return one; }
|
|
104
|
+
// Inline code spans stay as they are: split on them, and rewrite the rest.
|
|
105
|
+
const parts = one.split(/(`+[^`\n]*`+)/);
|
|
106
|
+
return parts.map((part, index) => {
|
|
107
|
+
if (index % 2 === 1) return part;
|
|
108
|
+
let next = part.replace(/(\]\(\s*)(<[^>\n]+>|[^)\s]+)((?:\s+"[^"]*")?\s*\))/g, (whole, head, raw, end) => {
|
|
109
|
+
const angled = raw.startsWith('<');
|
|
110
|
+
const moved = moveTarget(file, angled ? raw.slice(1, -1) : raw, from, to);
|
|
111
|
+
if (moved === null) return whole;
|
|
112
|
+
count += 1;
|
|
113
|
+
return `${head}${angled ? `<${moved}>` : moved}${end}`;
|
|
114
|
+
});
|
|
115
|
+
next = next.replace(/^(\s{0,3}\[[^\]\n]+\]:\s*)(\S+)/, (whole, head, raw) => {
|
|
116
|
+
const moved = moveTarget(file, raw, from, to);
|
|
117
|
+
if (moved === null) return whole;
|
|
118
|
+
count += 1;
|
|
119
|
+
return `${head}${moved}`;
|
|
120
|
+
});
|
|
121
|
+
return next;
|
|
122
|
+
}).join('');
|
|
123
|
+
});
|
|
124
|
+
return { text: out.join('\n'), count };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The front matter of a markdown text with each `sources:` entry in `from`
|
|
128
|
+
// pointed at `to`. A doc may name a design as a source; the path must still
|
|
129
|
+
// resolve, or the upkeep check calls the doc stale.
|
|
130
|
+
export function rewriteSources(text, from, to) {
|
|
131
|
+
const match = String(text).match(/^---\n([\s\S]*?)\n---(\n|$)/);
|
|
132
|
+
if (!match) return { text, count: 0 };
|
|
133
|
+
let count = 0;
|
|
134
|
+
const head = match[1].replace(/^(\s*-\s*['"]?)([^'"\s#]+)/gm, (whole, lead, path) => {
|
|
135
|
+
const clean = path.replace(/\/+$/, '');
|
|
136
|
+
if (clean !== from && !clean.startsWith(`${from}/`)) return whole;
|
|
137
|
+
count += 1;
|
|
138
|
+
return `${lead}${to}${path.slice(from.length)}`;
|
|
139
|
+
});
|
|
140
|
+
return { text: count ? `---\n${head}\n---${match[2]}${String(text).slice(match[0].length)}` : text, count };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The docs index with the entry of the moved folder under the heading for
|
|
144
|
+
// frozen designs. An entry is one list line that links the folder's
|
|
145
|
+
// design.yaml, and the indented lines below it.
|
|
146
|
+
export function moveIndexEntry(text, link) {
|
|
147
|
+
const lines = String(text).replace(/\n$/, '').split('\n');
|
|
148
|
+
const at = lines.findIndex((one) => /^- /.test(one) && one.includes(`](${link})`));
|
|
149
|
+
if (at === -1) return { text, moved: false };
|
|
150
|
+
let end = at + 1;
|
|
151
|
+
while (end < lines.length && /^ {2,}\S/.test(lines[end])) end += 1;
|
|
152
|
+
const entry = lines.splice(at, end - at);
|
|
153
|
+
// Drop a blank line that the cut left doubled.
|
|
154
|
+
if (lines[at - 1] === '' && lines[at] === '') lines.splice(at, 1);
|
|
155
|
+
let head = lines.indexOf(FROZEN_HEADING);
|
|
156
|
+
if (head === -1) {
|
|
157
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
158
|
+
lines.push('', FROZEN_HEADING, '', 'Designs of done items, frozen in the archive at their old path. They say why each part works the way it does. The user guide says how it works now.', '');
|
|
159
|
+
head = lines.indexOf(FROZEN_HEADING);
|
|
160
|
+
}
|
|
161
|
+
// The section runs to the next heading. Keep its entries in name order.
|
|
162
|
+
let stop = lines.findIndex((one, index) => index > head && /^#{1,6} /.test(one));
|
|
163
|
+
if (stop === -1) stop = lines.length;
|
|
164
|
+
const key = (one) => (one.match(/^- \[([^\]]*)\]/)?.[1] ?? one).toLowerCase();
|
|
165
|
+
let put = stop;
|
|
166
|
+
for (let index = head + 1; index < stop; index += 1) {
|
|
167
|
+
if (/^- /.test(lines[index]) && key(lines[index]) > key(entry[0])) { put = index; break; }
|
|
168
|
+
}
|
|
169
|
+
if (put === stop) {
|
|
170
|
+
while (put > head + 1 && lines[put - 1] === '') put -= 1;
|
|
171
|
+
const before = lines[put - 1];
|
|
172
|
+
lines.splice(put, 0, ...(before !== undefined && /^\S/.test(before) && !/^- /.test(before) ? ['', ...entry] : entry));
|
|
173
|
+
} else {
|
|
174
|
+
lines.splice(put, 0, ...entry);
|
|
175
|
+
}
|
|
176
|
+
return { text: `${lines.join('\n')}\n`, moved: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The rows of docs/artifacts.json built from the folder: each path names the
|
|
180
|
+
// new place, and the row gets a frozen date. The file keeps its form: two
|
|
181
|
+
// spaces, one row per object, a new line at the end.
|
|
182
|
+
export function freezeRows(text, from, to, date) {
|
|
183
|
+
const data = JSON.parse(text);
|
|
184
|
+
const rows = Array.isArray(data?.artifacts) ? data.artifacts : [];
|
|
185
|
+
let count = 0;
|
|
186
|
+
// The folder as a whole path word: at the start or after a space or a
|
|
187
|
+
// quote, and at the end or before a slash, a space or a quote.
|
|
188
|
+
const word = new RegExp(`(^|[\\s'"(])${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s/'")])`, 'g');
|
|
189
|
+
const hit = (value) => typeof value === 'string' && new RegExp(word.source).test(value);
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
if (!row || typeof row !== 'object') continue;
|
|
192
|
+
if (!Object.values(row).some(hit)) continue;
|
|
193
|
+
for (const [key, value] of Object.entries(row)) {
|
|
194
|
+
if (typeof value === 'string') row[key] = value.replace(word, `$1${to}`);
|
|
195
|
+
}
|
|
196
|
+
if (typeof row.frozen !== 'string') row.frozen = date;
|
|
197
|
+
count += 1;
|
|
198
|
+
}
|
|
199
|
+
return { text: `${JSON.stringify(data, null, 2)}\n`, count };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Move one live design to the archive. `folder` is a path from the repo root
|
|
203
|
+
// or from here. With dryRun, it changes nothing and says what it would do.
|
|
204
|
+
export function freezeDesign(root, folder, { date = new Date().toISOString().slice(0, 10), dryRun = false } = {}) {
|
|
205
|
+
if (!DATE.test(date)) throw new Error(`--date "${date}": write a date as YYYY-MM-DD`);
|
|
206
|
+
const abs = resolve(folder);
|
|
207
|
+
const from = toPosix(relative(root, abs)).replace(/\/+$/, '');
|
|
208
|
+
if (!from || from.startsWith('..') || from.startsWith('/')) throw new Error(`${folder} is not inside the repo ${root}`);
|
|
209
|
+
if (from === ARCHIVE || from.startsWith(`${ARCHIVE}/`)) throw new Error(`${from} is in ${ARCHIVE}/: it is frozen already`);
|
|
210
|
+
if (!existsSync(join(root, from, DESIGN_FILE))) throw new Error(`${from} holds no ${DESIGN_FILE}: it is not a design folder`);
|
|
211
|
+
const { folder: designs } = designFolderOf(root);
|
|
212
|
+
if (posix.dirname(from) !== designs) throw new Error(`${from} is not a design in the design folder ${designs}/. Run atlas design list`);
|
|
213
|
+
const to = posix.join(ARCHIVE, from);
|
|
214
|
+
if (existsSync(join(root, to))) throw new Error(`${to} exists already. Move it away by hand, or keep the design live`);
|
|
215
|
+
|
|
216
|
+
const report = { from, to, item: itemOf(root, from), links: [], index: false, pages: 0, dryRun };
|
|
217
|
+
const writes = [];
|
|
218
|
+
// Links in live markdown files. The files inside the moved folder keep
|
|
219
|
+
// their bytes, so the move stays a pure move.
|
|
220
|
+
for (const file of markdownFiles(root)) {
|
|
221
|
+
if (file === from || file.startsWith(`${from}/`)) continue;
|
|
222
|
+
const text = readText(join(root, file));
|
|
223
|
+
if (text === null) continue;
|
|
224
|
+
const next = rewriteLinks(text, file, from, to);
|
|
225
|
+
const named = rewriteSources(next.text, from, to);
|
|
226
|
+
if (next.count + named.count) { report.links.push({ file, count: next.count + named.count }); writes.push([file, named.text]); }
|
|
227
|
+
}
|
|
228
|
+
// The docs index entry moves under the frozen heading.
|
|
229
|
+
const indexAt = writes.findIndex(([file]) => file === DOCS_INDEX_PATH);
|
|
230
|
+
const indexText = indexAt === -1 ? readText(join(root, DOCS_INDEX_PATH)) : writes[indexAt][1];
|
|
231
|
+
if (indexText !== null) {
|
|
232
|
+
const link = posix.relative(posix.dirname(DOCS_INDEX_PATH), posix.join(to, DESIGN_FILE));
|
|
233
|
+
const moved = moveIndexEntry(indexText, link);
|
|
234
|
+
if (moved.moved) {
|
|
235
|
+
report.index = true;
|
|
236
|
+
if (indexAt === -1) writes.push([DOCS_INDEX_PATH, moved.text]);
|
|
237
|
+
else writes[indexAt][1] = moved.text;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// The page rows.
|
|
241
|
+
const rowsText = readText(join(root, ARTIFACTS));
|
|
242
|
+
if (rowsText !== null) {
|
|
243
|
+
const next = freezeRows(rowsText, from, to, date);
|
|
244
|
+
if (next.count) { report.pages = next.count; writes.push([ARTIFACTS, next.text]); }
|
|
245
|
+
}
|
|
246
|
+
if (dryRun) return report;
|
|
247
|
+
mkdirSync(join(root, posix.dirname(to)), { recursive: true });
|
|
248
|
+
renameSync(join(root, from), join(root, to));
|
|
249
|
+
for (const [file, text] of writes) writeFileSync(join(root, file), text);
|
|
250
|
+
return report;
|
|
251
|
+
}
|
package/door/lib/doc-sources.mjs
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
// 1. a .md file under docs/, by its front matter: `sources:` (a list of
|
|
10
10
|
// paths from the repo root; a path that ends in / is a folder), or
|
|
11
11
|
// `sources_why:` (why it names no code);
|
|
12
|
-
// 2. a
|
|
13
|
-
// same two keys in design.yaml. Its parts are one unit with it;
|
|
14
|
-
// 3. a procedure: the SKILL.md that atlas.yaml `procedures:` names, by the
|
|
12
|
+
// 2. a procedure: the SKILL.md that atlas.yaml `procedures:` names, by the
|
|
15
13
|
// same two keys in its front matter.
|
|
14
|
+
// A design folder (a folder under docs/ that holds design.yaml) is no unit
|
|
15
|
+
// (item e4df0d33). A design is a decision record: the user guide and the
|
|
16
|
+
// reference pages say how the code works now, and they name their code. The
|
|
17
|
+
// owner, 31 October 2026: "Guide and product documentation is the one that
|
|
18
|
+
// is supposed to be kept updated, design is just a fleeting item of work".
|
|
19
|
+
// Its `sources:` key stays legal, and nothing gates on it. When its item is
|
|
20
|
+
// done, `atlas design freeze` moves it to archive/.
|
|
16
21
|
// A doc that must not change (a locked text, the owner's own words) can name
|
|
17
22
|
// its code in a `sources.yaml` beside it, or in a folder above it under docs/:
|
|
18
23
|
// `files: { <name>: { sources: [...] } }` for one file, or `sources:` and
|
|
@@ -94,16 +99,8 @@ function procedureFiles(root) {
|
|
|
94
99
|
// names neither its code nor a reason.
|
|
95
100
|
export function docUnits(root) {
|
|
96
101
|
const units = [];
|
|
97
|
-
const folders = new Set();
|
|
98
102
|
for (const file of markdownFiles(root, 'docs')) {
|
|
99
|
-
|
|
100
|
-
if (folder) {
|
|
101
|
-
if (folders.has(folder)) continue;
|
|
102
|
-
folders.add(folder);
|
|
103
|
-
const where = posix.join(folder, DESIGN);
|
|
104
|
-
units.push({ unit: folder, kind: 'design', where, named: declared(yamlOf(readText(root, where) ?? ''), 'sources_why') ?? fromSidecar(root, where), generated: false });
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
103
|
+
if (designFolderOf(root, file)) continue;
|
|
107
104
|
const head = headOf(readText(root, file));
|
|
108
105
|
units.push({ unit: file, kind: 'doc', where: file, named: declared(head, 'sources_why') ?? fromSidecar(root, file), generated: head?.generated === true });
|
|
109
106
|
}
|
|
@@ -141,7 +138,7 @@ export function covers(root, source, path) {
|
|
|
141
138
|
const touchedItself = (unit, changed) => changed.some((path) => path === unit.unit || path === unit.where || path.startsWith(`${unit.unit}/`));
|
|
142
139
|
|
|
143
140
|
// The pull request text that marks a doc "still true": one line that names the
|
|
144
|
-
// unit (its path, or for a
|
|
141
|
+
// unit (its path, or for a procedure its folder) and says "still true".
|
|
145
142
|
export function stillTrue(body, unit) {
|
|
146
143
|
const text = String(body ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
|
|
147
144
|
return text.split('\n').some((line) => /still true/i.test(line) && (line.includes(unit.unit) || line.includes(unit.where)));
|
|
@@ -232,9 +229,7 @@ function changedLines(root, parent, commit) {
|
|
|
232
229
|
// then against each folder it names, then against packages/<name>/.
|
|
233
230
|
const CITE = /`?([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5}):(\d+)(?:-(\d+))?/g;
|
|
234
231
|
function citedLines(root, unit) {
|
|
235
|
-
const files = unit.
|
|
236
|
-
? markdownFiles(root, unit.unit).concat([unit.where])
|
|
237
|
-
: [unit.where];
|
|
232
|
+
const files = [unit.where];
|
|
238
233
|
const out = [];
|
|
239
234
|
for (const file of files) {
|
|
240
235
|
const text = readText(root, file) ?? '';
|
package/door/lib/owner-only.mjs
CHANGED
|
@@ -542,9 +542,30 @@ export function classify({ root, base, head, rules: rulesRef = null, classes = l
|
|
|
542
542
|
const signs = classes.classes.flatMap((entry) => (entry.signs ?? []).map((sign) => ({ ...sign, class: entry.id })));
|
|
543
543
|
const secrets = SECRET_PATTERNS.map((pattern) => ({ id: pattern.id, regex: compile(pattern.source) }));
|
|
544
544
|
|
|
545
|
+
// A pure move into the archive (item e4df0d33): a file at P is deleted, and
|
|
546
|
+
// archive/P is added with the same bytes and mode. `atlas design freeze`
|
|
547
|
+
// makes such moves when a design's item is done. The archive floor keeps
|
|
548
|
+
// archived records from change; a move of one unchanged file into it at its
|
|
549
|
+
// own path changes no record, so that floor alone does not send the add to
|
|
550
|
+
// the owner. The delete of P still meets every rule on P, and every other
|
|
551
|
+
// rule and sign still reads archive/P.
|
|
552
|
+
const deleted = new Map(changes.filter((one) => one.status === 'D').map((one) => [one.path, one]));
|
|
553
|
+
const pureMove = (change) => {
|
|
554
|
+
if (change.status !== 'A' || !change.path.startsWith('archive/')) return false;
|
|
555
|
+
const from = change.path.slice('archive/'.length);
|
|
556
|
+
const gone = deleted.get(from);
|
|
557
|
+
if (!gone || from.startsWith('archive/') || gone.oldMode !== change.newMode) return false;
|
|
558
|
+
const blob = (ref, path) => (ref === null ? null : git(root, ['rev-parse', '--verify', '--quiet', `${ref}:${path}`], { allowFail: true })?.toString('utf8').trim() || null);
|
|
559
|
+
const a = blob(base, from), b = blob(head, change.path);
|
|
560
|
+
if (a && b) return a === b;
|
|
561
|
+
const before = readAt(root, base, from), after = readAt(root, head, change.path);
|
|
562
|
+
return before !== null && after !== null && before === after;
|
|
563
|
+
};
|
|
564
|
+
|
|
545
565
|
for (const change of changes) {
|
|
546
566
|
const { path } = change;
|
|
547
567
|
const hits = [];
|
|
568
|
+
const moved = pureMove(change);
|
|
548
569
|
// A symlink or a submodule can point anywhere, so it always comes to the owner.
|
|
549
570
|
if ([change.oldMode, change.newMode].includes('120000')) hits.push(['rule-book', 'a symlink changed']);
|
|
550
571
|
if ([change.oldMode, change.newMode].includes('160000')) hits.push(['rule-book', 'a submodule changed']);
|
|
@@ -560,6 +581,7 @@ export function classify({ root, base, head, rules: rulesRef = null, classes = l
|
|
|
560
581
|
const kitRelease = change.status === 'M' && signs.some((sign) => sign.version_key && matchesAny(sign.paths, path) && versionOnly(baseText, headText, sign.version_key));
|
|
561
582
|
for (const rule of release ? [] : rules) {
|
|
562
583
|
if (!ruleHits(rule, path)) continue;
|
|
584
|
+
if (moved && rule.pattern === 'archive/' && rule.cause === 'floor: archive') { hits.push([null, 'cleared']); continue; }
|
|
563
585
|
if (kitRelease && rule.cause.startsWith('list: ')) continue;
|
|
564
586
|
if (rule.atlas_yaml) {
|
|
565
587
|
const cause = atlasYamlCause(baseText, headText, classes.atlas_yaml);
|