@arjunkhera/atlas 0.3.8 → 0.3.10

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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/artifact-renderer.md +22 -22
  3. package/door/cli.mjs +36 -11
  4. package/door/lib/design-build.mjs +409 -0
  5. package/door/lib/design.mjs +199 -118
  6. package/door/lib/markdown.mjs +160 -0
  7. package/package.json +1 -1
  8. package/skills/lead/SKILL.md +1 -1
  9. package/skills/sdlc-task/SKILL.md +33 -24
  10. package/skills/sdlc-task/design/README.md +187 -0
  11. package/skills/sdlc-task/design/parts/actors.md +26 -0
  12. package/skills/sdlc-task/design/parts/alternatives.md +24 -0
  13. package/skills/sdlc-task/design/parts/build.md +23 -0
  14. package/skills/sdlc-task/design/parts/calls.md +25 -0
  15. package/skills/sdlc-task/design/parts/change.md +27 -0
  16. package/skills/sdlc-task/design/parts/data.md +22 -0
  17. package/skills/sdlc-task/design/parts/done.md +23 -0
  18. package/skills/sdlc-task/design/parts/edges.md +24 -0
  19. package/skills/sdlc-task/design/parts/goals.md +27 -0
  20. package/skills/sdlc-task/design/parts/key.md +25 -0
  21. package/skills/sdlc-task/design/parts/migration.md +22 -0
  22. package/skills/sdlc-task/design/parts/order.md +24 -0
  23. package/skills/sdlc-task/design/parts/problem.md +22 -0
  24. package/skills/sdlc-task/design/parts/proof.md +24 -0
  25. package/skills/sdlc-task/design/parts/proposal.md +24 -0
  26. package/skills/sdlc-task/design/parts/records.md +24 -0
  27. package/skills/sdlc-task/design/parts/repos.md +25 -0
  28. package/skills/sdlc-task/design/parts/risks.md +24 -0
  29. package/skills/sdlc-task/design/parts/rollout.md +24 -0
  30. package/skills/sdlc-task/design/parts/routes.md +24 -0
  31. package/skills/sdlc-task/design/parts/scorecard.md +25 -0
  32. package/skills/sdlc-task/design/parts/security.md +22 -0
  33. package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
  34. package/skills/sdlc-task/design/parts/states.md +25 -0
  35. package/skills/sdlc-task/design/parts/stories.md +26 -0
  36. package/skills/sdlc-task/design/parts/summary.md +33 -0
  37. package/skills/sdlc-task/design/parts/why.md +22 -0
  38. package/skills/sdlc-task/design/parts/words.md +29 -0
  39. package/skills/sdlc-task/design/parts/yardstick.md +25 -0
  40. package/skills/sdlc-task/design/parts.yaml +306 -0
  41. package/skills/sdlc-task/lifecycle.yaml +2 -2
  42. package/work/lib/verbs.mjs +99 -14
  43. package/work/mcp.mjs +1 -1
  44. package/agents/artifact-format/walkthrough.html +0 -706
  45. package/skills/sdlc-task/templates/design-doc.md +0 -126
@@ -1,148 +1,229 @@
1
- // The design check. It reads one markdown design doc made from the
2
- // walkthrough template and flags each place where the doc breaks the shape:
3
- // a summary, four named parts of steps, an example and a picture in every
4
- // step, a definition of done, the kind of change, and a reference part.
1
+ // The design check. A design is a folder: `design.yaml` names its title and
2
+ // its kinds, and each part is one markdown file with `part: <id>` in its
3
+ // front matter. The parts registry, `skills/sdlc-task/design/parts.yaml`,
4
+ // says which kind needs which part.
5
5
  //
6
- // It proves the shape, not the quality. A person still judges whether an
7
- // example is good. It never rewrites a word.
8
- //
9
- // Grammar: a part is a line `## Part N — <name>`; a step is a line
10
- // `### Step N — <title>`. A step's text is the prose between its heading and
11
- // its `Example:` line. Lists, tables, code and quotes do not count as prose.
12
- import { readFileSync } from 'node:fs';
6
+ // The check flags what a script can see: a needed part that is missing or
7
+ // empty, a state line (state lives in the tracker), a code with no row in
8
+ // the Key, a part the registry does not know, and a figure whose tags do not
9
+ // close. It proves the shape, not the quality. It never rewrites a word.
10
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
11
+ import { join, dirname, resolve, basename } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { parseYaml } from '../../shape/check.mjs';
13
14
 
14
- export const PARTS = Object.freeze(['What and why', 'How it flows', 'How it works', 'Decisions and done']);
15
- export const REFERENCE = Object.freeze({ name: 'Reference', sections: ['Approaches considered', 'Risks', 'Test plan'] });
16
- export const REQUIRED_SECTIONS = Object.freeze(['Definition of done', 'Kind of change, impact and undo']);
17
- export const MAX_STEP_SENTENCES = 3;
18
- export const MAX_SUMMARY_PARAGRAPHS = 2;
15
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
16
+ export const PARTS_FILE = join(PACKAGE_ROOT, 'skills', 'sdlc-task', 'design', 'parts.yaml');
17
+ export const DESIGN_FILE = 'design.yaml';
18
+ export const NEEDS = Object.freeze(['needed', 'optional', 'none', 'shared']);
19
+ // design.yaml holds what a design is, never where it stands.
20
+ export const DESIGN_KEYS = Object.freeze(['title', 'item', 'product', 'kinds', 'shared', 'look']);
21
+ export const STATE_KEYS = Object.freeze(['status', 'state', 'stage', 'approved', 'locked', 'lock', 'shipped', 'delivered']);
22
+ export const PART_KEYS = Object.freeze(['part', 'title']);
19
23
 
20
- const PART_LINE = /^## Part (\d+) [—–-] (.+?)\s*$/;
21
- const STEP_LINE = /^### Step (\d+) [—–-] (.+?)\s*$/;
22
- const EXAMPLE_LINE = /^Example\b[^:\n]*:/;
23
- const PICTURE_LINE = /^Picture:\s*(.*)$/;
24
- const DIAGRAM_FENCE = /^```\s*(mermaid|svg|diagram|dot|graphviz)\b/i;
24
+ // A code: capitals then a number, such as M1, A1.7, US-3 or D-6. Common
25
+ // technical names that look like codes are not codes.
26
+ const CODE = /(?<![\w./-])[A-Z]{1,4}-?\d+(?:\.\d+)*(?![\w/-])/g;
27
+ export const NOT_CODES = Object.freeze(new Set(['UTF-8', 'UTF-16', 'UTF8', 'SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'S3', 'EC2', 'ARM64', 'X86', 'HTTP2', 'HTTP3', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P50', 'P90', 'P95', 'P99', 'ES2020', 'ES2022', 'ISO8601', 'RFC3339', 'OAUTH2', 'IPV4', 'IPV6', 'PDF2', 'MP3', 'MP4', 'K8S']));
28
+ // A state line, in three shapes: a status label with a state word
29
+ // ("Status: approved", "**State** — locked"), a state word with a date
30
+ // ("Approved 2026-10-09"), and a sentence that says the design is approved,
31
+ // locked or shipped.
32
+ const STATE_WORDS = 'draft|proposed|approved|locked|building|built|shipped|delivered|done|in progress|waiting(?: on [\\w ]+)?';
33
+ const STATE_LINE = new RegExp([
34
+ `^(?:[-*]\\s+)?(?:\\*\\*)?(?:status|state|stage)(?:\\*\\*)?\\s*(?::|—|–|\\s-\\s)\\s*(?:\\*\\*)?\\s*(?:${STATE_WORDS})\\b`,
35
+ '^(?:\\*\\*)?(?:approved|locked|shipped|delivered)(?:\\*\\*)?\\s+(?:on\\s+)?\\d{4}-\\d{2}-\\d{2}',
36
+ '\\b(?:this|the)\\s+design\\s+(?:is|was|has been)\\s+(?:now\\s+)?(?:approved|locked|shipped|delivered)\\b',
37
+ ].join('|'), 'i');
38
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
39
+ // HTML lets these close themselves, so the tag check skips them.
40
+ const OPTIONAL_CLOSE = new Set(['li', 'p', 'td', 'th', 'tr', 'thead', 'tbody', 'tfoot', 'dt', 'dd', 'option', 'colgroup']);
41
+ // A figure draws; it never reaches the network, and its style never reaches the page.
42
+ const FIGURE_NETWORK = /\bfetch\s*\(|XMLHttpRequest|WebSocket|EventSource|sendBeacon|\bimport\s*\(|<(?:script|img|iframe|link)\b[^>]*\b(?:src|href)\s*=\s*["']?(?:https?:)?\/\//i;
43
+ // Files a folder may hold beside its parts: an index, for a repo whose docs
44
+ // lint needs every page linked, and a readme.
45
+ export const INDEX_FILES = Object.freeze(['index.md', 'README.md']);
25
46
 
26
- // Code spans and links go first, so a dot in `atlas.yaml` or a URL never
27
- // ends a sentence. A sentence ends at . ! or ? before a space and a capital.
28
- export function sentenceCount(text) {
29
- const plain = text
30
- .replace(/`[^`]*`/g, ' Code ')
31
- .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
32
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
33
- .replace(/\s+/g, ' ')
34
- .trim();
35
- if (!plain) return 0;
36
- return plain.split(/(?<=[.!?])\s+(?=["“(]?[A-Z])/).filter((one) => /[A-Za-z0-9]/.test(one)).length;
47
+ export function loadRegistry(path = PARTS_FILE) {
48
+ const data = parseYaml(readFileSync(path, 'utf8'));
49
+ const kinds = data.kinds.map((one) => one.id);
50
+ for (const part of data.parts) {
51
+ for (const kind of kinds) {
52
+ if (!NEEDS.includes(part[kind])) throw new Error(`${basename(path)}: part "${part.id}" has no valid "${kind}" value; use ${NEEDS.join(', ')}`);
53
+ }
54
+ }
55
+ return { ...data, kindIds: kinds, byId: new Map(data.parts.map((part) => [part.id, part])) };
37
56
  }
38
57
 
39
- // Prose lines only: not a list item, a table row, a quote or a code block.
40
- function proseOf(lines) {
41
- const out = [];
42
- let fenced = false;
43
- for (const line of lines) {
44
- if (/^```/.test(line.trim())) { fenced = !fenced; continue; }
45
- if (fenced) continue;
46
- const trimmed = line.trim();
47
- if (!trimmed || /^([-*+]|\d+[.)])\s/.test(trimmed) || trimmed.startsWith('|') || trimmed.startsWith('>')) { out.push(''); continue; }
48
- if (/^\s{2,}\S/.test(line) && out.length && out[out.length - 1] === '') { out.push(''); continue; }
49
- out.push(trimmed);
58
+ // What one design needs: each part id with "needed" or "optional", and the
59
+ // kinds that need it. A part that two kinds use appears once.
60
+ export function needsOf(meta, registry) {
61
+ const kinds = Array.isArray(meta.kinds) ? meta.kinds : [];
62
+ const out = new Map();
63
+ for (const part of registry.parts) {
64
+ let need = null;
65
+ const by = [];
66
+ for (const kind of kinds) {
67
+ let value = part[kind];
68
+ if (value === 'shared') value = meta.shared === true ? 'needed' : 'optional';
69
+ if (value === 'needed') { need = 'needed'; by.push(kind); }
70
+ else if (value === 'optional' && need !== 'needed') need = 'optional';
71
+ }
72
+ if (need) out.set(part.id, { need, by });
50
73
  }
51
74
  return out;
52
75
  }
53
76
 
54
- function paragraphs(lines) {
55
- return proseOf(lines).join('\n').split(/\n\s*\n/).map((one) => one.trim()).filter(Boolean);
77
+ export function frontMatter(source) {
78
+ const text = String(source).replace(/\r\n?/g, '\n');
79
+ const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
80
+ if (!match) return { meta: {}, body: text, bodyLine: 1 };
81
+ return { meta: parseYaml(match[1]) ?? {}, body: text.slice(match[0].length), bodyLine: match[0].split('\n').length };
56
82
  }
57
83
 
58
- // Split the doc into level-2 sections, each with its start line and body.
59
- function sectionsOf(lines) {
60
- const sections = [];
61
- let current = null;
62
- let fenced = false;
63
- lines.forEach((line, index) => {
64
- if (/^```/.test(line.trim())) fenced = !fenced;
65
- if (!fenced && line.startsWith('## ')) {
66
- current = { heading: line.slice(3).trim(), line: index + 1, body: [] };
67
- sections.push(current);
68
- } else if (current) current.body.push({ text: line, line: index + 1 });
84
+ export function readDesign(folder) {
85
+ const root = resolve(folder);
86
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`${folder} is not a folder. A design is a folder with ${DESIGN_FILE} and one file for each part.`);
87
+ const metaPath = join(root, DESIGN_FILE);
88
+ const meta = existsSync(metaPath) ? (parseYaml(readFileSync(metaPath, 'utf8')) ?? {}) : null;
89
+ const names = readdirSync(root).filter((name) => name.endsWith('.md')).sort();
90
+ const index = names.filter((name) => INDEX_FILES.includes(name)).map((name) => ({ file: name, text: readFileSync(join(root, name), 'utf8') }));
91
+ const parts = names.filter((name) => !INDEX_FILES.includes(name)).map((name) => {
92
+ const { meta: head, body, bodyLine } = frontMatter(readFileSync(join(root, name), 'utf8'));
93
+ return { file: name, head, part: head.part ?? null, title: head.title ?? null, body, bodyLine };
69
94
  });
70
- return sections;
95
+ return { root, meta, parts, index };
71
96
  }
72
97
 
73
- function stepsOf(section) {
74
- const steps = [];
75
- let current = null;
76
- let fenced = false;
77
- for (const row of section.body) {
78
- if (/^```/.test(row.text.trim())) fenced = !fenced;
79
- const match = !fenced && row.text.match(STEP_LINE);
80
- if (match) { current = { number: Number(match[1]), title: match[2], line: row.line, body: [] }; steps.push(current); continue; }
81
- if (!fenced && row.text.startsWith('### ')) { current = null; continue; }
82
- if (current) current.body.push(row);
83
- }
84
- return steps;
98
+ // Lines outside code fences, with their line numbers; `figure` fences are
99
+ // returned apart, so the check can read their tags.
100
+ function scanLines(body, firstLine) {
101
+ const prose = [];
102
+ const figures = [];
103
+ let fence = null;
104
+ body.split('\n').forEach((text, index) => {
105
+ const line = firstLine + index;
106
+ const open = text.match(/^\s*```+\s*(\S*)(?:\s+(\S+))?/);
107
+ if (open && !fence) { fence = { info: open[1], type: open[2] ?? null, line, lines: [] }; return; }
108
+ if (fence && /^\s*```+\s*$/.test(text)) { if (fence.info === 'figure' || fence.info === 'woodcut') figures.push(fence); fence = null; return; }
109
+ if (fence) { fence.lines.push(text); return; }
110
+ prose.push({ text, line });
111
+ });
112
+ if (fence) figures.push({ ...fence, open: true });
113
+ return { prose, figures };
85
114
  }
86
115
 
87
- function checkStep(step, finding) {
88
- const label = `step "${step.title}"`;
89
- const exampleIndex = step.body.findIndex((row) => EXAMPLE_LINE.test(row.text.trim()));
90
- if (exampleIndex === -1) finding(step.line, 'example', `${label} has no Example block`);
91
- const before = (exampleIndex === -1 ? step.body : step.body.slice(0, exampleIndex)).map((row) => row.text);
92
- const count = paragraphs(before).reduce((sum, one) => sum + sentenceCount(one), 0);
93
- if (count > MAX_STEP_SENTENCES) finding(step.line, 'step-text', `${label} has ${count} sentences before its example; the limit is ${MAX_STEP_SENTENCES}`);
94
- const pictures = step.body.filter((row) => PICTURE_LINE.test(row.text.trim()));
95
- const sketched = pictures.some((row) => {
96
- const sketch = row.text.trim().match(PICTURE_LINE)[1];
97
- return /→|->/.test(sketch) && !/\bTBD\b/i.test(sketch);
98
- });
99
- const drawn = step.body.some((row) => DIAGRAM_FENCE.test(row.text.trim()) || /<svg[\s>]/i.test(row.text) || /!\[[^\]]*\]\([^)]+\)/.test(row.text));
100
- if (!sketched && !drawn) {
101
- const why = pictures.length ? 'its Picture line has no arrow (→), or says TBD' : 'it has no Picture line and no diagram block';
102
- finding(step.line, 'picture', `${label} has no picture: ${why}`);
116
+ const isEmpty = (body) => !body.replace(/<!--[\s\S]*?-->/g, '').split('\n').some((line) => line.trim() && !/^#{1,6}\s/.test(line.trim()));
117
+
118
+ // Code spans, links and quotes are not checked for codes: a quote is the
119
+ // owner's own words, and a link target is not text.
120
+ function codesIn(text) {
121
+ if (/^\s*>/.test(text)) return [];
122
+ const plain = text.replace(/`[^`]*`/g, ' ').replace(/\]\([^)]*\)/g, ']').replace(/https?:\/\/\S+/g, ' ');
123
+ return plain.match(CODE) ?? [];
124
+ }
125
+
126
+ export function figureFault(html) {
127
+ const text = html.replace(/<!--[\s\S]*?-->/g, '');
128
+ if (/<style\b/i.test(text)) return 'a figure may not hold a <style> block: it would reach the whole page; use style attributes';
129
+ if (FIGURE_NETWORK.test(text)) return 'a figure may not reach the network: no fetch, no dynamic import and no remote src or href';
130
+ const scripts = text.match(/<script\b/gi)?.length ?? 0;
131
+ const closed = text.match(/<\/script>/gi)?.length ?? 0;
132
+ if (scripts !== closed) return 'a script block does not close';
133
+ const plain = text.replace(/<script\b[\s\S]*?<\/script>/gi, '');
134
+ const stack = [];
135
+ for (const match of plain.matchAll(/<(\/?)([a-zA-Z][\w:-]*)\b[^>]*?(\/?)>/g)) {
136
+ const [, closing, rawName, self] = match;
137
+ const name = rawName.toLowerCase();
138
+ if (self || VOID.has(name) || OPTIONAL_CLOSE.has(name)) continue;
139
+ if (!closing) { stack.push(name); continue; }
140
+ if (stack[stack.length - 1] !== name) return `</${name}> closes ${stack.length ? `<${stack[stack.length - 1]}>` : 'nothing'}`;
141
+ stack.pop();
103
142
  }
143
+ return stack.length ? `<${stack[stack.length - 1]}> does not close` : null;
104
144
  }
105
145
 
106
- export function checkDesign(text) {
146
+ export function checkDesign(design, registry = loadRegistry()) {
107
147
  const findings = [];
108
- const finding = (line, rule, why) => findings.push({ line, rule, why });
109
- const lines = String(text).split(/\r?\n/);
110
- const sections = sectionsOf(lines);
111
- const named = (name) => sections.find((section) => section.heading.toLowerCase() === name.toLowerCase());
112
-
113
- const summary = named('Summary');
114
- if (!summary) finding(1, 'summary', 'the doc has no "## Summary" section');
115
- else {
116
- const count = paragraphs(summary.body.map((row) => row.text)).length;
117
- if (count === 0) finding(summary.line, 'summary', 'the Summary is empty');
118
- if (count > MAX_SUMMARY_PARAGRAPHS) finding(summary.line, 'summary', `the Summary has ${count} paragraphs; the limit is ${MAX_SUMMARY_PARAGRAPHS}`);
148
+ const finding = (file, line, rule, why) => findings.push({ file, line, rule, why });
149
+ const { meta } = design;
150
+ if (!meta) {
151
+ finding(DESIGN_FILE, 1, 'design-yaml', `the folder has no ${DESIGN_FILE}; it names the title and the kinds`);
152
+ return findings;
119
153
  }
120
-
121
- const parts = sections.map((section) => ({ section, match: section.heading.match(/^Part (\d+) [—–-] (.+?)\s*$/) })).filter((one) => one.match);
122
- for (const name of PARTS) {
123
- const part = parts.find((one) => one.match[2].toLowerCase() === name.toLowerCase());
124
- if (!part) { finding(1, 'part', `the doc has no part "${name}" (a line "## Part N — ${name}")`); continue; }
125
- const steps = stepsOf(part.section);
126
- if (!steps.length) finding(part.section.line, 'part', `part "${name}" has no "### Step N — <title>" line`);
127
- for (const step of steps) checkStep(step, finding);
154
+ if (!meta.title) finding(DESIGN_FILE, 1, 'design-yaml', 'design.yaml has no title');
155
+ for (const key of Object.keys(meta)) {
156
+ if (STATE_KEYS.includes(key.toLowerCase())) finding(DESIGN_FILE, 1, 'state', `design.yaml has "${key}"; state lives in the tracker`);
157
+ else if (!DESIGN_KEYS.includes(key)) finding(DESIGN_FILE, 1, 'design-yaml', `design.yaml has an unknown key "${key}"; the keys are ${DESIGN_KEYS.join(', ')}`);
128
158
  }
129
-
130
- for (const name of REQUIRED_SECTIONS) {
131
- if (!named(name)) finding(1, 'section', `the doc has no "## ${name}" section`);
159
+ const kinds = Array.isArray(meta.kinds) ? meta.kinds : [];
160
+ if (!kinds.length) finding(DESIGN_FILE, 1, 'kind', `design.yaml names no kinds; pick one or more of ${registry.kindIds.join(', ')}`);
161
+ for (const kind of kinds) {
162
+ if (!registry.kindIds.includes(kind)) finding(DESIGN_FILE, 1, 'kind', `"${kind}" is not a kind; the kinds are ${registry.kindIds.join(', ')}`);
132
163
  }
133
164
 
134
- const reference = parts.find((one) => one.match[2].toLowerCase() === REFERENCE.name.toLowerCase());
135
- if (!reference) finding(1, 'reference', `the doc has no part "${REFERENCE.name}" (a line "## Part N — ${REFERENCE.name}")`);
136
- else {
137
- const headings = reference.section.body.filter((row) => row.text.startsWith('### ')).map((row) => row.text.slice(4).trim().toLowerCase());
138
- for (const name of REFERENCE.sections) {
139
- if (!headings.includes(name.toLowerCase())) finding(reference.section.line, 'reference', `the Reference part has no "### ${name}"`);
165
+ const needs = needsOf(meta, registry);
166
+ const seen = new Map();
167
+ const keyText = [];
168
+ const codes = [];
169
+ for (const one of design.parts) {
170
+ if (!one.part) { finding(one.file, 1, 'unknown-part', `${one.file} has no "part:" line in its front matter`); continue; }
171
+ for (const key of Object.keys(one.head)) {
172
+ if (STATE_KEYS.includes(key.toLowerCase())) finding(one.file, 1, 'state', `the front matter has "${key}"; state lives in the tracker`);
173
+ else if (!PART_KEYS.includes(key)) finding(one.file, 1, 'unknown-part', `the front matter has an unknown key "${key}"; the keys are ${PART_KEYS.join(', ')}`);
174
+ }
175
+ if (!registry.byId.has(one.part)) {
176
+ const tracker = registry.tracker.some((row) => row.id === one.part);
177
+ finding(one.file, 1, tracker ? 'state' : 'unknown-part', tracker
178
+ ? `"${one.part}" is read from the tracker; a design keeps no copy of it`
179
+ : `"${one.part}" is not a part in parts.yaml`);
180
+ continue;
181
+ }
182
+ if (seen.has(one.part)) { finding(one.file, 1, 'duplicate', `part "${one.part}" is also in ${seen.get(one.part)}`); continue; }
183
+ seen.set(one.part, one.file);
184
+ if (!needs.has(one.part)) finding(one.file, 1, 'unknown-part', `part "${one.part}" is not used by the kinds ${kinds.join(', ') || '(none)'}`);
185
+ if (isEmpty(one.body)) finding(one.file, one.bodyLine, 'empty', `part "${one.part}" is empty; write what it holds, or "No change" and why`);
186
+ const { prose, figures } = scanLines(one.body, one.bodyLine);
187
+ for (const row of prose) {
188
+ const trimmed = row.text.trim();
189
+ if (!trimmed.startsWith('|') && !trimmed.startsWith('>') && STATE_LINE.test(trimmed)) {
190
+ finding(one.file, row.line, 'state', `"${trimmed.slice(0, 60)}" is a state line; state lives in the tracker`);
191
+ }
192
+ if (one.part === 'key') keyText.push(row.text);
193
+ else for (const code of codesIn(row.text)) codes.push({ code, file: one.file, line: row.line });
194
+ }
195
+ for (const figure of figures) {
196
+ let fault = figure.open ? 'the figure block does not close' : null;
197
+ if (!fault && figure.info === 'woodcut') {
198
+ if (!/^wc-[a-z-]+$/.test(figure.type ?? '')) fault = 'a woodcut block names its figure type, such as "woodcut wc-flowchart"';
199
+ else { try { JSON.parse(figure.lines.join('\n')); } catch (error) { fault = `the woodcut data is not JSON: ${error.message}`; } }
200
+ } else if (!fault) fault = figureFault(figure.lines.join('\n'));
201
+ if (fault) finding(one.file, figure.line, 'figure', fault);
140
202
  }
141
203
  }
142
-
143
- return findings.sort((a, b) => a.line - b.line);
204
+ for (const [id, { need, by }] of needs) {
205
+ if (need === 'needed' && !seen.has(id)) {
206
+ const part = registry.byId.get(id);
207
+ const shared = by.some((kind) => part[kind] === 'shared') ? ', because the design is shared' : '';
208
+ finding(DESIGN_FILE, 1, 'missing', `the design has no part "${id}" (${part.name}), needed by ${by.join(', ')}${shared}`);
209
+ }
210
+ }
211
+ const key = keyText.join('\n');
212
+ const reported = new Set();
213
+ for (const { code, file, line } of codes) {
214
+ if (NOT_CODES.has(code.toUpperCase()) || reported.has(code) || new RegExp(`(?<![\\w-])${code.replace(/\./g, '\\.')}(?![\\w-])`).test(key)) continue;
215
+ reported.add(code);
216
+ finding(file, line, 'key', `the code "${code}" has no row in the Key`);
217
+ }
218
+ for (const one of design.index ?? []) {
219
+ for (const part of design.parts) {
220
+ if (!one.text.includes(`(${part.file})`) && !one.text.includes(`(./${part.file})`)) finding(one.file, 1, 'index', `${one.file} does not link ${part.file}`);
221
+ }
222
+ }
223
+ const order = (one) => (one.file === DESIGN_FILE ? '' : one.file);
224
+ return findings.sort((a, b) => order(a).localeCompare(order(b)) || a.line - b.line);
144
225
  }
145
226
 
146
- export function checkDesignFile(path) {
147
- return checkDesign(readFileSync(path, 'utf8'));
227
+ export function checkDesignFolder(folder, registry) {
228
+ return checkDesign(readDesign(folder), registry);
148
229
  }
@@ -0,0 +1,160 @@
1
+ // A small markdown renderer for design pages. It grew from the renderer of
2
+ // the dev-team manual, docs/manual/build.mjs, and reads the same blocks:
3
+ // headings, paragraphs, lists, tables, quotes, code, and woodcut figures.
4
+ // It adds one block: a fence whose info string is `figure` holds the HTML of
5
+ // a custom figure, and goes into the page as it is.
6
+ //
7
+ // No dependencies. The text is the repo's own; a custom figure is trusted
8
+ // the same way a page in the repo is.
9
+ export const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10
+ export const slug = (text) => String(text).toLowerCase().replace(/<[^>]+>/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
11
+
12
+ // ctx: { page, link(href) -> { href, part } | null, figures: [] }
13
+ export function inline(text, ctx) {
14
+ const codes = [];
15
+ let s = text.replace(/`([^`]+)`/g, (_, code) => {
16
+ codes.push(`<code>${esc(code)}</code>`);
17
+ return `\u0000${codes.length - 1}\u0000`;
18
+ });
19
+ s = esc(s);
20
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => link(label, href.replace(/&amp;/g, '&'), ctx));
21
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
22
+ s = s.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>');
23
+ s = s.replace(/(^|[\s(])_([^_\s][^_]*)_(?=[\s).,;:]|$)/g, '$1<em>$2</em>');
24
+ return s.replace(/\u0000(\d+)\u0000/g, (_, index) => codes[index]);
25
+ }
26
+
27
+ function link(label, href, ctx) {
28
+ if (/^https?:/.test(href)) return `<a href="${esc(href)}" target="_blank" rel="noopener">${label}</a>`;
29
+ const part = ctx.partOf ? ctx.partOf(href) : null;
30
+ if (part) return `<a href="#${esc(part)}" data-part="${esc(part)}">${label}</a>`;
31
+ if (href.startsWith('#')) return `<a href="${esc(href)}">${label}</a>`;
32
+ // A repo file: show it as a path, not a live link.
33
+ return `<span class="path" title="${esc(href)}">${label}</span>`;
34
+ }
35
+
36
+ const cellsOf = (row) => {
37
+ const s = row.trim().replace(/^\|/, '').replace(/\|$/, '');
38
+ const out = [''];
39
+ let inCode = false;
40
+ for (let k = 0; k < s.length; k += 1) {
41
+ const ch = s[k];
42
+ if (ch === '\\' && s[k + 1] === '|') { out[out.length - 1] += '|'; k += 1; continue; }
43
+ if (ch === '`') inCode = !inCode;
44
+ if (ch === '|' && !inCode) { out.push(''); continue; }
45
+ out[out.length - 1] += ch;
46
+ }
47
+ return out.map((cell) => cell.trim());
48
+ };
49
+
50
+ export function render(body, ctx) {
51
+ const lines = body.replace(/\r\n?/g, '\n').split('\n');
52
+ const out = [];
53
+ const para = [];
54
+ let title = null;
55
+ let i = 0;
56
+ const flush = () => {
57
+ if (!para.length) return;
58
+ out.push(`<p>${inline(para.join(' ').trim(), ctx)}</p>`);
59
+ para.length = 0;
60
+ };
61
+ while (i < lines.length) {
62
+ const line = lines[i];
63
+ const fence = line.match(/^\s*```+\s*(.*)$/);
64
+ if (fence) {
65
+ flush();
66
+ const info = fence[1].trim();
67
+ const buf = [];
68
+ i += 1;
69
+ while (i < lines.length && !/^\s*```+\s*$/.test(lines[i])) buf.push(lines[i++]);
70
+ i += 1;
71
+ const woodcut = info.match(/^woodcut\s+(wc-[a-z-]+)/);
72
+ if (woodcut) {
73
+ let data;
74
+ try { data = JSON.parse(buf.join('\n')); } catch (error) { throw new Error(`${ctx.file}: bad woodcut JSON in ${woodcut[1]}: ${error.message}`); }
75
+ ctx.figures.push(woodcut[1]);
76
+ out.push(`<figure class="fig"><${woodcut[1]}><script type="application/json">${JSON.stringify(data).replace(/<\//g, '<\\/')}</script></${woodcut[1]}></figure>`);
77
+ } else if (info === 'figure') {
78
+ ctx.figures.push('custom');
79
+ out.push(`<figure class="fig custom">${buf.join('\n')}</figure>`);
80
+ } else {
81
+ out.push(`<pre class="code"${info ? ` data-lang="${esc(info)}"` : ''}><code>${esc(buf.join('\n'))}</code></pre>`);
82
+ }
83
+ continue;
84
+ }
85
+ const heading = line.match(/^(#{1,4})\s+(.*)$/);
86
+ if (heading) {
87
+ flush();
88
+ const level = heading[1].length;
89
+ const text = heading[2].trim();
90
+ i += 1;
91
+ // The first level-one heading is the part's title; the page draws it.
92
+ if (level === 1 && title === null && !out.length) { title = text; continue; }
93
+ const tag = Math.min(level + 1, 4);
94
+ out.push(`<h${tag} id="${esc(`${ctx.page}--${slug(text)}`)}">${inline(text, ctx)}</h${tag}>`);
95
+ continue;
96
+ }
97
+ if (/^\s*>/.test(line)) {
98
+ flush();
99
+ const buf = [];
100
+ while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, ''));
101
+ out.push(`<blockquote>${render(buf.join('\n'), { ...ctx, page: `${ctx.page}-q` }).html}</blockquote>`);
102
+ continue;
103
+ }
104
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
105
+ flush();
106
+ const head = cellsOf(line);
107
+ i += 2;
108
+ const rows = [];
109
+ while (i < lines.length && /^\s*\|/.test(lines[i])) rows.push(cellsOf(lines[i++]));
110
+ out.push(`<div class="tablewrap"><table><thead><tr>${head.map((cell) => `<th>${inline(cell, ctx)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${inline(cell, ctx)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
111
+ continue;
112
+ }
113
+ if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
114
+ flush();
115
+ const result = list(lines, i, ctx);
116
+ out.push(result.html);
117
+ i = result.next;
118
+ continue;
119
+ }
120
+ if (/^\s*$/.test(line)) { flush(); i += 1; continue; }
121
+ para.push(line.trim());
122
+ i += 1;
123
+ }
124
+ flush();
125
+ return { title, html: out.join('\n') };
126
+ }
127
+
128
+ function list(lines, start, ctx) {
129
+ const indentOf = (line) => line.match(/^(\s*)/)[1].length;
130
+ const base = indentOf(lines[start]);
131
+ const ordered = /^\s*\d+\./.test(lines[start]);
132
+ const items = [];
133
+ let i = start;
134
+ while (i < lines.length) {
135
+ const line = lines[i];
136
+ if (/^\s*$/.test(line)) {
137
+ const next = lines[i + 1];
138
+ if (next !== undefined && indentOf(next) >= base && /^\s*([-*]|\d+\.)\s+/.test(next)) { i += 1; continue; }
139
+ if (next !== undefined && indentOf(next) > base) { i += 1; continue; }
140
+ break;
141
+ }
142
+ const indent = indentOf(line);
143
+ const match = line.match(/^\s*([-*]|\d+\.)\s+(.*)$/);
144
+ if (match && indent === base) { items.push({ text: [match[2]], sub: [] }); i += 1; continue; }
145
+ if (indent > base && items.length) {
146
+ if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
147
+ const result = list(lines, i, ctx);
148
+ items[items.length - 1].sub.push(result.html);
149
+ i = result.next;
150
+ continue;
151
+ }
152
+ items[items.length - 1].text.push(line.trim());
153
+ i += 1;
154
+ continue;
155
+ }
156
+ break;
157
+ }
158
+ const tag = ordered ? 'ol' : 'ul';
159
+ return { html: `<${tag}>${items.map((item) => `<li>${inline(item.text.join(' '), ctx)}${item.sub.join('')}</li>`).join('')}</${tag}>`, next: i };
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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",
@@ -101,7 +101,7 @@ set. A test keeps the two lists equal.
101
101
  | `atlas code-read` | To resolve the citations of a code digest |
102
102
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
103
103
  | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof` prints the proof table of one run |
104
- | `atlas design` | Before you render a design doc made from the walkthrough template |
104
+ | `atlas design` | `check` before you show a design; `build` to make its page |
105
105
 
106
106
  A person merges every file that `atlas tooling` writes.
107
107
  A person merges every file that `atlas tests write` writes.