@nodii/dev 0.5.0 → 0.6.2

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/dist/docs.js ADDED
@@ -0,0 +1,204 @@
1
+ // Local checks for AD documents (M-05) before anything is sent to Dev. They
2
+ // mirror Dev's structure checks (AD-era contract §1.3) rule for rule, so a
3
+ // document that passes here fails in Dev only on checks that need Dev's data:
4
+ // `pre:` and DEC resolution, versions, retired clause ids. Dev stays the
5
+ // authority; this exists so an upload fails fast, offline, with every problem
6
+ // in one report, using Dev's error codes. Warnings name text Dev reads
7
+ // differently from what its author probably meant.
8
+
9
+ export const CHANGE_CLASSES = ["ruling", "conformance", "editorial"];
10
+
11
+ // Contract §1.4.
12
+ export const RULEBOOK_SECTIONS = ["Summary", "Scope", "Rules", "Rationale", "Edge cases and examples", "Enforcement", "Relations", "Sources", "Open questions for ratification", "Change history"];
13
+ export const FEATURE_SECTIONS = ["Summary", "Scope and non-goals", "Actors and permissions", "Rules", "Behaviour", "Data and contracts", "Edge cases and failure modes", "Rationale", "Enforcement and tests", "Relations", "Sources", "Known gaps", "Open questions for ratification", "Change history"];
14
+ export const OVERVIEW_SECTIONS = ["Summary", "Purpose and users", "Capability map", "Component map", "Peer services", "Deployment and environments", "State of the service", "Known gaps", "Open questions for ratification", "Change history"];
15
+
16
+ // Contract §1.2: front matter `kind` as written → Dev's kind.
17
+ const KINDS = {
18
+ principles: "principles", methodology: "methodology", standard: "standard", standards: "standard",
19
+ doctrine: "doctrine", doctrines: "doctrine", "ux-guideline": "ux-guideline", "ux guideline": "ux-guideline",
20
+ "ux-guidelines": "ux-guideline", "ux guidelines": "ux-guideline", ux: "ux-guideline", "know-how": "know-how",
21
+ feature: "feature-doc", "feature-doc": "feature-doc",
22
+ "feature-overview": "service-overview", "feature-doc-overview": "service-overview", "service-overview": "service-overview", overview: "service-overview",
23
+ "capability-plan": "capability-plan", "feature-plan": "capability-plan", "feature-doc-plan": "capability-plan",
24
+ };
25
+ const ANATOMY = { "feature-doc": FEATURE_SECTIONS, "service-overview": OVERVIEW_SECTIONS, "capability-plan": ["Change history"] };
26
+ const FRONT_MATTER = ["id", "code", "title", "kind", "era", "status", "version", "owner", "incorporates"];
27
+ const CODE_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$/;
28
+ const GROUNDED_AT = /^[a-z0-9][a-z0-9._-]*@[0-9a-f]{7,40}$/;
29
+ const VERSION = /^(\d{1,4})(?:\.(\d{1,4}))?$/;
30
+
31
+ export const devKind = (label) => KINDS[String(label ?? "").trim().toLowerCase()] ?? null;
32
+
33
+ const unquote = (raw) => (raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) ? raw.slice(1, -1) : raw);
34
+ function flowList(raw) {
35
+ const items = [];
36
+ let depth = 0;
37
+ let current = "";
38
+ for (const ch of raw.slice(1, -1)) {
39
+ if (ch === "(" || ch === "[") depth++;
40
+ if (ch === ")" || ch === "]") depth--;
41
+ if (ch === "," && depth === 0) { items.push(current); current = ""; continue; }
42
+ current += ch;
43
+ }
44
+ items.push(current);
45
+ return items.map((x) => unquote(x.trim())).filter(Boolean);
46
+ }
47
+
48
+ /** Flat `key: value` front matter with `[a, b]` flow lists, as Dev reads it. */
49
+ export function parseFrontMatter(content) {
50
+ const lines = content.split("\n");
51
+ if (lines[0]?.trim() !== "---") return null;
52
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
53
+ if (end === -1) return null;
54
+ const fields = {};
55
+ const blockLists = [];
56
+ for (const [i, line] of lines.slice(1, end).entries()) {
57
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
58
+ if (!m) {
59
+ if (/^\s+-\s/.test(line) && i > 0) blockLists.push(i + 2);
60
+ continue;
61
+ }
62
+ const value = m[2].trim();
63
+ fields[m[1]] = value.startsWith("[") && value.endsWith("]") ? flowList(value) : unquote(value);
64
+ }
65
+ return { fields, bodyStartLine: end + 2, blockLists };
66
+ }
67
+
68
+ const sectionTitle = (heading) => heading.replace(/^\d+(\.\d+)*\.?\s+/, "").trim();
69
+ // Dev's definitions (src/lib/ad/markdown.ts), exactly.
70
+ // A clause id: `<code>-<n>`, a Scope clause `<code>-S<n>`, sub-clauses `.m`.
71
+ const CLAUSE_ID = "[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-S?\\d+(?:\\.\\d+)*";
72
+ const CLAUSE_DEF = new RegExp(`^\\s*[-*+]\\s+\\*\\*(${CLAUSE_ID})\\*\\*(?=\\s|$)(.*)$`);
73
+ // A parked item (M05-11.1): Undecided: … · **Undecided:** … · **Undecided** …
74
+ // · **Undecided (PAYC-26):** … · **Undecided: … (MW1-12.4).** · - **TELE-32** Undecided: …
75
+ const PARKED = new RegExp(`^\\s*(?:[-*+]\\s+|\\d+\\.\\s+)?(?:\\*\\*(${CLAUSE_ID})\\*\\*\\s+)?(\\*\\*)?Undecided(?:\\s*\\(([^)]*)\\))?(?:\\*\\*\\s*:|:\\s*\\*\\*|:|\\*\\*(?=\\s))\\s*(.*)$`);
76
+ // A bare `D` id needs two or more digits (a lone `D1` is Cloudflare's
77
+ // database); namespaced ids (`billing-D7`) are always decisions.
78
+ const DECISION_ID = /\b(?:[a-z][a-z0-9_]*(?:-[a-z0-9_]+)*-D\d+|D\d{2,})\b/g;
79
+
80
+ /** One line as a parked item, as Dev stores it; null when it is not one. */
81
+ export function parkedItem(line) {
82
+ const m = PARKED.exec(line);
83
+ if (!m) return null;
84
+ return `${m[3] ? `(${m[3]}) ` : ""}${(m[4] ?? "").trim()}`.replace(/\*\*\s*$/, "").trim();
85
+ }
86
+
87
+ /** Which lines are fenced code (the fence lines included). */
88
+ function fencedLines(lines) {
89
+ let open = false;
90
+ return lines.map((line) => {
91
+ if (/^\s*(```|~~~)/.test(line)) { open = !open; return true; }
92
+ return open;
93
+ });
94
+ }
95
+
96
+ /** Sections, clause definitions and parked items, skipping fenced code, with 1-based lines of the whole file. */
97
+ export function parseBody(content, bodyStartLine = 1) {
98
+ const lines = content.split("\n");
99
+ const sections = [];
100
+ const clauses = [];
101
+ const parked = [];
102
+ const fenced = fencedLines(lines);
103
+ let section = "";
104
+ for (let i = bodyStartLine - 1; i < lines.length; i++) {
105
+ const line = lines[i] ?? "";
106
+ if (fenced[i]) continue;
107
+ const h2 = /^##\s+(.+?)\s*#*\s*$/.exec(line);
108
+ if (h2 && !line.startsWith("###")) {
109
+ section = sectionTitle(h2[1]);
110
+ sections.push({ title: section, line: i + 1 });
111
+ continue;
112
+ }
113
+ // `- **TELE-32** Undecided: …` parks that clause; it is not a second definition of it.
114
+ const p = parkedItem(line);
115
+ if (p !== null) { parked.push({ text: p, line: i + 1 }); continue; }
116
+ const clause = CLAUSE_DEF.exec(line);
117
+ if (clause) clauses.push({ id: clause[1], section, line: i + 1 });
118
+ }
119
+ return { sections, clauses, parked };
120
+ }
121
+
122
+ /**
123
+ * Bare pre-dev decision ids (M05-5): a `D12`+ or `billing-D7` in prose, not
124
+ * right after `pre:`. Fenced code and inline code spans are skipped, as Dev does.
125
+ */
126
+ export function bareCitations(content) {
127
+ const out = [];
128
+ const lines = content.split("\n");
129
+ const fenced = fencedLines(lines);
130
+ lines.forEach((line, i) => {
131
+ if (fenced[i]) return;
132
+ const prose = line.replace(/`[^`]*`/g, (span) => " ".repeat(span.length));
133
+ for (const m of prose.matchAll(DECISION_ID))
134
+ if (prose.slice(Math.max(0, m.index - 4), m.index) !== "pre:") out.push({ citation: m[0], line: i + 1 });
135
+ });
136
+ return out;
137
+ }
138
+
139
+ // "The capability map" is the "Capability map" section.
140
+ const startsWithTitle = (actual, expected) => actual.replace(/^the\s+/i, "").toLowerCase().startsWith(expected.toLowerCase());
141
+
142
+ function sectionErrors(sections, kind) {
143
+ const titles = sections.map((s) => s.title);
144
+ if (kind === "feature-doc" && titles.some((t) => startsWithTitle(t, "Capability map")) && titles.some((t) => startsWithTitle(t, "Purpose and users")))
145
+ return [{ code: "LOOKS_LIKE_OVERVIEW", message: "this document has the service-overview sections; set `kind: service-overview`", line: 1 }];
146
+ const errors = [];
147
+ const required = ANATOMY[kind] ?? RULEBOOK_SECTIONS;
148
+ let cursor = 0;
149
+ for (const expected of required) {
150
+ const at = sections.findIndex((s) => startsWithTitle(s.title, expected));
151
+ if (at === -1) { errors.push({ code: "SECTION_MISSING", message: `section \`## ${expected}\` is required for kind \`${kind}\`` }); continue; }
152
+ if (at < cursor) { errors.push({ code: "SECTION_ORDER", message: `section \`## ${expected}\` is out of order (expected: ${required.join(" · ")})`, line: sections[at].line }); continue; }
153
+ cursor = at;
154
+ }
155
+ return errors;
156
+ }
157
+
158
+ const scalar = (v) => (v === undefined ? "" : Array.isArray(v) ? v.join(", ") : v);
159
+
160
+ /**
161
+ * Validate one AD document. Returns { meta, errors, warnings, parked, clauses };
162
+ * each error is { code, message, line? } with Dev's codes (contract §1.3).
163
+ */
164
+ export function validateDocument(content) {
165
+ const text = content.replace(/\r\n/g, "\n");
166
+ const errors = [];
167
+ const warnings = [];
168
+ const parsed = parseFrontMatter(text);
169
+ if (!parsed) {
170
+ errors.push({ code: "FRONT_MATTER_MISSING", message: "the document must open with a `---` front matter block (M05-15)", line: 1 });
171
+ return { meta: {}, errors, warnings, parked: [], clauses: [] };
172
+ }
173
+ const { fields, bodyStartLine, blockLists } = parsed;
174
+ for (const key of FRONT_MATTER)
175
+ if (fields[key] === undefined || (scalar(fields[key]).length === 0 && !(key === "incorporates" && Array.isArray(fields[key]))))
176
+ errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: `front matter field \`${key}\` is required (M05-15)`, line: 1 });
177
+ if (blockLists.length)
178
+ warnings.push({ message: "front matter uses a YAML block list (` - item`); Dev reads only `key: value` and `[a, b]` lists, so write it as `[a, b]`", line: blockLists[0] });
179
+ const kind = scalar(fields.kind) ? devKind(scalar(fields.kind)) : null;
180
+ if (scalar(fields.kind) && !kind) errors.push({ code: "KIND_UNKNOWN", message: `kind \`${scalar(fields.kind)}\` is not a rulebook kind or a feature-doc kind`, line: 1 });
181
+ const code = scalar(fields.code);
182
+ if (code && !CODE_PATTERN.test(code)) errors.push({ code: "CODE_INVALID", message: `code \`${code}\` must be upper-case letters, digits and hyphens (e.g. PII, M05, AUTH-SES)`, line: 1 });
183
+ const version = scalar(fields.version);
184
+ if (version && !VERSION.test(version.trim())) errors.push({ code: "VERSION_INVALID", message: `version \`${version}\` must be \`major.minor\` (e.g. 0.2, 1.0)`, line: 1 });
185
+ const groundedAt = fields.grounded_at === undefined ? null : scalar(fields.grounded_at);
186
+ if ((kind === "feature-doc" || kind === "service-overview") && !groundedAt)
187
+ errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: "front matter field `grounded_at` (`<repo>@<sha>`) is required on feature docs (M05-15)", line: 1 });
188
+ else if (groundedAt && !GROUNDED_AT.test(groundedAt))
189
+ errors.push({ code: "FRONT_MATTER_FIELD_MISSING", message: `grounded_at \`${groundedAt}\` must be \`<repo>@<sha>\``, line: 1 });
190
+
191
+ const body = parseBody(text, bodyStartLine);
192
+ if (kind) errors.push(...sectionErrors(body.sections, kind));
193
+ if (code) {
194
+ const seen = new Map();
195
+ for (const c of body.clauses) {
196
+ if (!c.id.startsWith(`${code}-`)) { errors.push({ code: "CLAUSE_CODE_MISMATCH", message: `clause \`${c.id}\` does not start with the document code \`${code}-\` (M05-6)`, line: c.line }); continue; }
197
+ if (seen.has(c.id)) errors.push({ code: "CLAUSE_ID_DUPLICATE", message: `clause \`${c.id}\` is defined twice (first at line ${seen.get(c.id)}) (M05-6)`, line: c.line });
198
+ else seen.set(c.id, c.line);
199
+ }
200
+ }
201
+ for (const b of bareCitations(text))
202
+ errors.push({ code: "BARE_DECISION_ID", message: `\`${b.citation}\` is a bare pre-dev decision id; cite it as \`pre:${b.citation}\` (M05-5)`, line: b.line });
203
+ return { meta: { ...fields, devKind: kind }, errors, warnings, parked: body.parked, clauses: body.clauses };
204
+ }
@@ -0,0 +1,223 @@
1
+ // `nodii-dev design guideline list|show|propose` and `design changeset
2
+ // list|show`: design-system guideline edits (A-032, DEV-DS-GUIDELINES-EDIT-1).
3
+ // A proposal is a guideline change set in Dev. Approving or rejecting one is a
4
+ // person's action in Dev, and nobody decides their own, so there is no command
5
+ // for it here.
6
+ //
7
+ // The Markdown form is what `guideline show` prints and `propose --file` reads:
8
+ //
9
+ // # Button (`button`)
10
+ // ## dos
11
+ // <!-- sourced; refs: … -->
12
+ // - Use one primary button per view.
13
+ // ## anatomy (drafted)
14
+ // - …
15
+ //
16
+ // One `## <list>` heading per statement list, `(drafted)` when the text still
17
+ // awaits a real source, one bullet per item (a line break inside an item reads
18
+ // back as a space). Lines that are HTML comments are ignored; the `#` title
19
+ // names the entry, and `propose` refuses a file titled for another one. Only
20
+ // the lists that differ from Dev's current guideline are sent.
21
+ import { readFile } from "node:fs/promises";
22
+ import { CliError, devRequest, encode, query } from "./core.js";
23
+
24
+ // Dev's editable statement lists (the request schema's `edits[].list` enum).
25
+ // A parked guideline has only the first four.
26
+ export const BASE_LISTS = ["whenToUse", "whenNotToUse", "dos", "donts"];
27
+ export const GUIDELINE_LISTS = [...BASE_LISTS, "anatomy", "states.states", "states.loading", "states.empty", "states.error", "copy", "composition", "data.notes"];
28
+ export const CHANGE_SET_STATES = ["in_review", "approved", "pr_open", "merged", "closed", "rejected", "conflict"];
29
+
30
+ const getList = (guideline, path) => path.split(".").reduce((node, key) => node?.[key], guideline);
31
+ /** The lists this guideline has that Dev lets a change set edit. */
32
+ export const editableLists = (guideline) =>
33
+ (guideline?.status === "active" ? GUIDELINE_LISTS : BASE_LISTS).filter((path) => getList(guideline, path) !== undefined);
34
+
35
+ /** A guideline as the Markdown `propose --file` reads back. */
36
+ export function guidelineMarkdown(entry) {
37
+ const lines = [`# ${entry.title} (\`${entry.key}\`)`, "", `<!-- guideline: ${entry.guideline.status}. Edit the items, then: nodii-dev design guideline propose ${entry.key} --file FILE -->`];
38
+ for (const path of editableLists(entry.guideline)) {
39
+ const list = getList(entry.guideline, path);
40
+ lines.push("", `## ${path}${list.provenance === "drafted" ? " (drafted)" : ""}`, `<!-- ${list.provenance}${list.refs.length ? `; refs: ${list.refs.join(", ")}` : ""} -->`, ...list.items.map((item) => `- ${item}`));
41
+ }
42
+ return lines.join("\n");
43
+ }
44
+
45
+ const problem = (line, message) => `line ${line}: ${message}`;
46
+ /** Parse the Markdown form into Dev's `edits` and the entry key its title names. Throws one error listing every problem. */
47
+ export function parseGuidelineMarkdown(text) {
48
+ const edits = [];
49
+ let entryKey = null;
50
+ const problems = [];
51
+ // undefined before the first heading; null under a heading that is not a list (its items are skipped).
52
+ let current;
53
+ let comment = false;
54
+ let last = null;
55
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
56
+ lines.forEach((raw, i) => {
57
+ const n = i + 1;
58
+ let line = raw;
59
+ if (comment) {
60
+ const end = line.indexOf("-->");
61
+ if (end === -1) return;
62
+ comment = false;
63
+ line = line.slice(end + 3);
64
+ }
65
+ // A comment is a line of its own; `<!--` inside an item is the item's text.
66
+ while (/^\s*<!--/.test(line)) {
67
+ const end = line.indexOf("-->");
68
+ if (end === -1) { comment = true; return; }
69
+ line = line.slice(end + 3);
70
+ }
71
+ if (!line.trim()) { last = null; return; }
72
+ const title = /^#\s+(.*)$/.exec(line);
73
+ if (title && current === undefined && entryKey === null) {
74
+ entryKey = /\(`([^`]+)`\)\s*$/.exec(title[1])?.[1] ?? "";
75
+ last = null;
76
+ return;
77
+ }
78
+ const heading = /^##\s+(\S+)(\s+\(drafted\))?\s*$/.exec(line);
79
+ if (heading) {
80
+ last = null;
81
+ const [, list, drafted] = heading;
82
+ if (!GUIDELINE_LISTS.includes(list)) {
83
+ problems.push(problem(n, `\`${list}\` is not a guideline list (${GUIDELINE_LISTS.join(", ")})`));
84
+ current = null;
85
+ return;
86
+ }
87
+ if (edits.some((e) => e.list === list)) problems.push(problem(n, `${list} appears twice`));
88
+ current = { list, items: [], drafted: Boolean(drafted), line: n };
89
+ edits.push(current);
90
+ return;
91
+ }
92
+ if (/^#/.test(line)) { problems.push(problem(n, `expected \`## <list>\`, got: ${line.trim()}`)); return; }
93
+ const item = /^\s{0,3}(?:[-*+]|\d+\.)\s+(.*)$/.exec(line);
94
+ if (item) {
95
+ if (current === undefined) problems.push(problem(n, "a list item before any `## <list>` heading"));
96
+ if (!current) return;
97
+ last = { text: item[1].trim() };
98
+ current.items.push(last);
99
+ return;
100
+ }
101
+ // An indented line continues the item above it.
102
+ if (/^\s/.test(line) && last) { last.text = `${last.text} ${line.trim()}`; return; }
103
+ if (current !== null) problems.push(problem(n, `expected a \`- \` list item, got: ${line.trim()}`));
104
+ });
105
+ if (comment) problems.push("an HTML comment is not closed");
106
+ for (const edit of edits) {
107
+ edit.items = edit.items.map((x) => x.text);
108
+ // The checks Dev (and the design-system validator) apply to a list's items.
109
+ if (!edit.items.length) problems.push(problem(edit.line, `${edit.list} needs at least one item`));
110
+ if (edit.items.some((x) => x === "")) problems.push(problem(edit.line, `${edit.list} has an empty item`));
111
+ const seen = new Set();
112
+ for (const x of edit.items) if (seen.has(x)) problems.push(problem(edit.line, `${edit.list} repeats: ${x}`)); else seen.add(x);
113
+ }
114
+ if (!edits.length && !problems.length) problems.push("no `## <list>` sections");
115
+ if (problems.length) throw new CliError("GUIDELINE_FILE_INVALID", `The guideline file has problems:\n ${problems.join("\n ")}`, 2);
116
+ return { entryKey: entryKey || null, edits: edits.map(({ list, items, drafted }) => ({ list, items, drafted })) };
117
+ }
118
+
119
+ /** The edits that change Dev's current guideline; refuses lists it cannot edit. */
120
+ export function changedEdits(entry, edits) {
121
+ if (!entry.guideline) throw new CliError("GUIDELINE_MISSING", `${entry.key} has no synced guideline to edit`, 1);
122
+ const allowed = editableLists(entry.guideline);
123
+ const refused = edits.filter((e) => !allowed.includes(e.list)).map((e) => e.list);
124
+ if (refused.length)
125
+ throw new CliError("GUIDELINE_LIST_NOT_EDITABLE", `${refused.join(", ")}: not a list of ${entry.key}'s ${entry.guideline.status} guideline (it has ${allowed.join(", ")})`, 2);
126
+ return edits.filter((e) => {
127
+ const now = getList(entry.guideline, e.list);
128
+ return JSON.stringify(now.items) !== JSON.stringify(e.items) || (now.provenance === "drafted") !== e.drafted;
129
+ });
130
+ }
131
+
132
+ const person = (p) => (p ? (p.displayName?.value ?? p.displayName?.hint ?? p.id) : "—");
133
+ const changeSetLine = (x) => `${x.id}\t${x.state}\t${x.entryKey}\t${x.edits.map((e) => e.list).join(",")}\t${x.summary ?? ""}`;
134
+ function changeSetText(x) {
135
+ const out = [
136
+ `${x.id} ${x.state} — ${x.entryTitle} (${x.entryKey})`,
137
+ ...(x.summary ? [x.summary] : []),
138
+ `proposed by ${person(x.createdBy)} at ${x.createdAt}`,
139
+ ...(x.decidedAt ? [`decided by ${person(x.decidedBy)} at ${x.decidedAt}${x.decisionComment ? `: ${x.decisionComment}` : ""}`] : []),
140
+ ...(x.pr ? [`PR #${x.pr.number} ${x.pr.url}${x.mergedAt ? ` (merged ${x.mergedAt})` : ""}`] : []),
141
+ ...(x.error ? [`error: ${x.error}`] : []),
142
+ ];
143
+ for (const edit of x.edits) {
144
+ const before = x.before?.[edit.list];
145
+ out.push("", `## ${edit.list}${edit.drafted ? " (drafted)" : ""}`);
146
+ // Removed items first, then the new list: `-` removed, `+` added, blank kept.
147
+ for (const item of before?.items ?? []) if (!edit.items.includes(item)) out.push(`- ${item}`);
148
+ for (const item of edit.items) out.push(`${before?.items.includes(item) ? " " : "+"} ${item}`);
149
+ }
150
+ return out.join("\n");
151
+ }
152
+
153
+ const usage = (text) => new CliError("USAGE", text, 2);
154
+ const flagValue = (flags, name, example) => {
155
+ if (flags[name] === true) throw usage(`--${name} needs a value, e.g. --${name} ${example}`);
156
+ if (Array.isArray(flags[name])) throw usage(`--${name} is given more than once`);
157
+ return flags[name];
158
+ };
159
+
160
+ export async function guidelineCommand(action, args, flags, { request = devRequest, read = (path) => readFile(path, "utf8") } = {}) {
161
+ if (action === "list") {
162
+ const params = { kind: flagValue(flags, "kind", "component"), group: flagValue(flags, "group", "inputs"), q: flagValue(flags, "q", "button"), guidelineDrafted: flags.drafted ? "true" : undefined, limit: flagValue(flags, "limit", "100") ?? 100 };
163
+ let cursor = flagValue(flags, "cursor", "CURSOR");
164
+ const data = [];
165
+ let nextCursor;
166
+ do {
167
+ const page = await request("GET", `/api/v1/design/entries${query({ ...params, cursor })}`);
168
+ data.push(...page.data);
169
+ nextCursor = page.nextCursor ?? null;
170
+ cursor = nextCursor;
171
+ } while (flags.all && cursor);
172
+ return {
173
+ payload: { data, nextCursor },
174
+ // Dev lists root entries with their parts nested; a part is shown indented under its root.
175
+ human: (p) => [p.data.flatMap((x) => [x, ...(x.parts ?? []).map((part) => ({ ...part, key: ` ${part.key}` }))]).map((x) => `${x.key}\t${x.kind}\t${x.status}\tdrafted=${x.guidelineDraftedCount}\t${x.title}`).join("\n") || "No design entries.", ...(p.nextCursor ? [`more: --cursor ${p.nextCursor} (or --all)`] : [])].join("\n"),
176
+ };
177
+ }
178
+ const key = args[0];
179
+ if (action === "show") {
180
+ if (!key || args.length > 1) throw usage("design guideline show ENTRY");
181
+ const payload = await request("GET", `/api/v1/design/entries/${encode(key)}`);
182
+ if (!payload.data.guideline && !flags.json) throw new CliError("GUIDELINE_MISSING", `${key} has no synced guideline`, 1);
183
+ return { payload, human: (p) => guidelineMarkdown(p.data) };
184
+ }
185
+ if (action === "propose") {
186
+ if (!key || args.length > 1) throw usage("design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]");
187
+ const file = flagValue(flags, "file", "ENTRY.md");
188
+ if (!file) throw usage("Missing --file FILE.md (start from `nodii-dev design guideline show ENTRY > FILE.md`)");
189
+ const summary = flagValue(flags, "summary", "'Clarify when to use'")?.trim();
190
+ if (summary !== undefined && (summary === "" || summary.length > 500)) throw usage("--summary must be 1 to 500 characters");
191
+ let text;
192
+ try { text = await read(file); } catch (error) { throw new CliError("NOT_FOUND", `Cannot read ${file}: ${error.message}`, 2); }
193
+ const { entryKey, edits } = parseGuidelineMarkdown(text);
194
+ if (entryKey && entryKey !== key) throw usage(`${file} is titled for ${entryKey}, not ${key}`);
195
+ const entry = (await request("GET", `/api/v1/design/entries/${encode(key)}`)).data;
196
+ const changed = changedEdits(entry, edits);
197
+ if (!changed.length) throw new CliError("NO_CHANGES", `${file} matches ${key}'s current guideline; nothing to propose`, 1);
198
+ const body = { ...(summary ? { summary } : {}), edits: changed };
199
+ if (flags["dry-run"])
200
+ return { payload: { dryRun: true, entryKey: key, body }, human: () => `Would propose ${changed.map((e) => e.list).join(", ")} for ${key} (dry run; nothing sent).` };
201
+ const payload = await request("POST", `/api/v1/design/entries/${encode(key)}/guideline-change-sets`, body);
202
+ return { payload, human: (p) => `Proposed ${p.data.id} (${p.data.state}) for ${p.data.entryKey}: ${p.data.edits.map((e) => e.list).join(", ")}. A person approves or rejects it in Dev.` };
203
+ }
204
+ throw usage("design guideline list|show ENTRY|propose ENTRY --file FILE.md");
205
+ }
206
+
207
+ export async function changeSetCommand(action, args, flags, { request = devRequest } = {}) {
208
+ if (action === "list") {
209
+ const states = flags.state === undefined ? undefined : [flags.state].flat();
210
+ for (const state of states ?? [])
211
+ if (!CHANGE_SET_STATES.includes(state)) throw usage(`--state is one of ${CHANGE_SET_STATES.join(", ")}`);
212
+ const payload = await request("GET", `/api/v1/design/guideline-change-sets${query({ entryKey: flagValue(flags, "entry", "button"), state: states, limit: flagValue(flags, "limit", "100") })}`);
213
+ return { payload, human: (p) => [p.data.map(changeSetLine).join("\n") || "No guideline change sets.", ...(p.total > p.data.length ? [`showing ${p.data.length} of ${p.total}; raise --limit (up to 200) or narrow with --entry/--state`] : [])].join("\n") };
214
+ }
215
+ if (action === "show") {
216
+ if (!args[0] || args.length > 1) throw usage("design changeset show ID");
217
+ const payload = await request("GET", `/api/v1/design/guideline-change-sets/${encode(args[0])}`);
218
+ return { payload, human: (p) => changeSetText(p.data) };
219
+ }
220
+ if (["approve", "reject"].includes(action))
221
+ throw usage(`Approving or rejecting a guideline change set is a person's action in Dev; nodii-dev has no ${action}.`);
222
+ throw usage("design changeset list [--entry KEY] [--state STATE] | show ID");
223
+ }