@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.49",
3
+ "version": "0.4.51",
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
@@ -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 (chosen.root !== undefined) throw new Error('--root works only with atlas design folder.');
372
- if (what !== 'check' && what !== 'build') throw new Error('use atlas design check <folder>, atlas design build <folder> or atlas design folder');
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
- for (const [, start, count] of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
66
- const first = Number(start);
67
- const n = count === undefined ? 1 : Number(count);
68
- for (let line = first; line < first + n; line += 1) lines.add(line);
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
+ }
@@ -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 design folder (a folder under docs/ that holds design.yaml), by the
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
- const folder = designFolderOf(root, file);
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 design folder its folder) and says "still true".
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.kind === 'design'
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) ?? '';
@@ -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);