diff --git a/bin/gstack-design-md.ts b/bin/gstack-design-md.ts index 67a91273b..23f8c7335 100755 --- a/bin/gstack-design-md.ts +++ b/bin/gstack-design-md.ts @@ -14,8 +14,10 @@ * ambiguous file (DESIGN_MD_CONVERT_REFUSED, exit 2) and a non-legacy one (exit 1). * tokens Flat token map as JSON ({"colors.primary": "#F59E0B", ...}); {path} refs resolved; * invalid refs listed under "errors" (DESIGN_MD_TOKEN_REF_INVALID). Exit 0. - * mark Persist the user's one-time format choice inside the file: spec files get a YAML - * comment on line 2, legacy files an HTML comment on line 1. Body bytes untouched. + * mark Persist the user's one-time format choice inside the file: a file that opens with + * front matter gets a YAML comment on line 2, any other file an HTML comment on + * line 1. A text-level splice: every other byte is untouched. Refuses a choice that + * contradicts the file (spec on a non-spec file, legacy-keep on a spec file), exit 2. * * Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug. YAML errors never propagate: a file whose * front matter does not parse is `unknown` with a reason. @@ -23,25 +25,20 @@ import * as fs from 'fs'; import * as path from 'path'; import { SENTINEL } from '../lib/design-detect-contract'; +import { atomicWriteSync } from '../lib/fs-atomic'; import { - parseDesignMd, detectFormat, convertLegacy, renderDesignMd, tokensFlat, setMarker, + parseDesignMd, detectFormat, convertLegacy, renderDesignMd, tokensFlat, insertMarker, type DesignMdDoc, type FormatChoice, } from '../lib/design-md'; function resolveFile(arg?: string): string { - return path.resolve(arg && !arg.startsWith('--') ? arg : 'DESIGN.md'); + return path.resolve(arg ?? 'DESIGN.md'); } function load(file: string): { text: string; doc: DesignMdDoc } | null { try { const text = fs.readFileSync(file, 'utf-8'); return { text, doc: parseDesignMd(text) }; } catch { return null; } } -function writeAtomic(file: string, content: string) { - const tmp = `${file}.tmp-${process.pid}`; - fs.writeFileSync(tmp, content); - fs.renameSync(tmp, file); -} - export function main(argv = process.argv.slice(2)): number { const verb = argv[0] ?? ''; const flags = new Set(argv.filter(a => a.startsWith('--'))); @@ -60,8 +57,8 @@ export function main(argv = process.argv.slice(2)): number { case 'convert': { const file = resolveFile(positional[0]); const loaded = load(file); - const { format, reason } = detectFormat(loaded?.doc ?? null); - if (format === 'unknown' && reason?.startsWith('ambiguous')) { + const { format, code, reason } = detectFormat(loaded?.doc ?? null); + if (code === 'ambiguous') { process.stderr.write(`${SENTINEL.DESIGN_MD_CONVERT_REFUSED}: ${reason}\n`); return 2; } @@ -72,7 +69,7 @@ export function main(argv = process.argv.slice(2)): number { const out = renderDesignMd(convertLegacy(loaded.doc), { emitFrontmatter: true }); if (flags.has('--write')) { fs.writeFileSync(`${file}.legacy.bak`, loaded.text); - writeAtomic(file, out); + atomicWriteSync(file, out); process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: spec\n${SENTINEL.DESIGN_MD_WRITTEN}: ${file}\n${SENTINEL.DESIGN_MD_BACKUP}: ${file}.legacy.bak\n`); } else { process.stdout.write(out); @@ -96,7 +93,12 @@ export function main(argv = process.argv.slice(2)): number { const file = resolveFile(positional[1]); const loaded = load(file); if (!loaded) { process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: missing\n`); return 1; } - writeAtomic(file, renderDesignMd(setMarker(loaded.doc, choice))); + const { format } = detectFormat(loaded.doc); + if ((choice === 'spec' && format !== 'spec') || (choice === 'legacy-keep' && format === 'spec')) { + process.stderr.write(`${SENTINEL.DESIGN_MD_CONVERT_REFUSED}: mark ${choice} contradicts the file's format (${format}); file unchanged\n`); + return 2; + } + atomicWriteSync(file, insertMarker(loaded.text, choice)); process.stdout.write(`${SENTINEL.DESIGN_MD_MARKER}: ${choice}\n`); return 0; } diff --git a/design/src/memory.ts b/design/src/memory.ts index 8b0112f2b..7c845c89d 100644 --- a/design/src/memory.ts +++ b/design/src/memory.ts @@ -15,7 +15,11 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; import { receiptedFetch } from "./receipted-fetch"; -import { parseDesignMd, detectFormat, renderDesignMd, upsertSection, specSkeleton, tokensFlat } from "../../lib/design-md"; +import { parseDesignMd, detectFormat, renderDesignMd, spliceSection, specSkeleton, tokensFlat, slug } from "../../lib/design-md"; +import { atomicWriteSync } from "../../lib/fs-atomic"; + +/** The section the extraction owns in DESIGN.md (replaced on every run). */ +export const EXTRACTED_SECTION_HEADING = "Extracted Design Language"; export interface ExtractedDesign { colors: { name: string; hex: string; usage: string }[]; @@ -102,12 +106,12 @@ function defaultDesign(): ExtractedDesign { /** * Write or update DESIGN.md with extracted design patterns. * - * Existing file (spec, legacy, or anything with `##` sections): the - * "## Extracted Design Language" section is upserted through lib/design-md.ts, - * which splices the body only — front matter bytes are never re-emitted, and a - * spec file keeps its canonical section order (the extracted section is an extra - * after them). New file: a spec-format skeleton whose tokens come from the - * extraction (colors by name, typography by role) plus the extracted section. + * Existing file (spec, legacy, or anything at all): the "## Extracted Design + * Language" section is spliced in at the text level through lib/design-md.ts, + * so every other byte of the user's file (front matter, section order, prose) + * is untouched; the section is replaced in place on a rerun, appended otherwise. + * New file: a spec-format skeleton whose tokens come from the extraction + * (colors by name, typography by role) plus the extracted section. */ export function updateDesignMd( repoRoot: string, @@ -116,32 +120,23 @@ export function updateDesignMd( ): void { const designPath = path.join(repoRoot, "DESIGN.md"); const timestamp = new Date().toISOString().split("T")[0]; - const section = formatExtractedSection(extracted, sourceMockup, timestamp); - const heading = "Extracted Design Language"; - const body = section.split("\n").slice(1).join("\n"); // drop the "## Extracted Design Language" line - - const write = (content: string) => { - const tmp = `${designPath}.tmp-${process.pid}`; - fs.writeFileSync(tmp, content); - fs.renameSync(tmp, designPath); - }; + const body = formatExtractedSection(extracted, sourceMockup, timestamp); if (fs.existsSync(designPath)) { - const doc = parseDesignMd(fs.readFileSync(designPath, "utf-8")); - write(renderDesignMd(upsertSection(doc, heading, body))); + atomicWriteSync(designPath, spliceSection(fs.readFileSync(designPath, "utf-8"), EXTRACTED_SECTION_HEADING, body)); console.error(`Updated DESIGN.md with extracted design language`); return; } const colors: Record = {}; for (const c of extracted.colors) { - const key = c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); - if (key && /^#[0-9a-fA-F]{3,8}$/.test(c.hex) && !(key in colors)) colors[key] = c.hex; + const key = slug(c.name); + if (key !== "token" && /^#[0-9a-fA-F]{3,8}$/.test(c.hex) && !(key in colors)) colors[key] = c.hex; } const typography: Record> = {}; for (const t of extracted.typography) { - const role = t.role.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); - if (!role || typography[role]) continue; + const role = slug(t.role); + if (role === "token" || typography[role]) continue; const entry: Record = { fontFamily: t.family }; if (t.size) entry.fontSize = t.size; if (t.weight) entry.fontWeight = t.weight; @@ -152,9 +147,9 @@ export function updateDesignMd( if (Object.keys(typography).length) frontmatter.typography = typography; const doc = specSkeleton("Design System", frontmatter, [ { heading: "Overview", body: `${extracted.mood}\n\nCreated by the gstack designer from an approved mockup (${path.basename(sourceMockup)}) on ${timestamp}.` }, - { heading, body }, + { heading: EXTRACTED_SECTION_HEADING, body }, ]); - write(renderDesignMd(doc)); + atomicWriteSync(designPath, renderDesignMd(doc)); console.error(`Created DESIGN.md with extracted design language`); } @@ -164,7 +159,6 @@ function formatExtractedSection( date: string, ): string { const lines: string[] = [ - "## Extracted Design Language", `*Auto-extracted from approved mockup on ${date}*`, `*Source: ${path.basename(sourceMockup)}*`, "", diff --git a/lib/design-md.ts b/lib/design-md.ts index 14785683c..e19875051 100644 --- a/lib/design-md.ts +++ b/lib/design-md.ts @@ -44,8 +44,20 @@ export const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'comp export type TokenGroup = (typeof TOKEN_GROUPS)[number]; export const FORMAT_MARKER_PREFIX = 'gstack: design-md-format='; -export type FormatChoice = 'spec' | 'legacy-keep'; +export const FORMAT_CHOICES = ['spec', 'legacy-keep'] as const; +export type FormatChoice = (typeof FORMAT_CHOICES)[number]; +const MARKER_RE_BODY = FORMAT_MARKER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(' + FORMAT_CHOICES.join('|') + ')'; +/** `` on line 1 (legacy files) */ +const LEGACY_MARKER_RE = new RegExp('^\\n?'); +/** `# gstack: design-md-format=...` as a YAML comment inside the front matter (spec files) */ +const YAML_MARKER_RE = new RegExp('^# ' + MARKER_RE_BODY + '\\s*$', 'm'); +/** Maximum `{path}` reference hops before a chain counts as a cycle. */ +export const TOKEN_REF_MAX_HOPS = 8; +/** Headings that mark gstack's pre-spec file by themselves (either one is enough evidence of a legacy shape). */ +export const LEGACY_IDENTITY_HEADINGS = ['Product Context', 'Aesthetic Direction'] as const; export type DesignMdFormat = 'spec' | 'legacy' | 'unknown' | 'missing'; +/** Machine-readable reason for an `unknown` (or `missing`) verdict; `reason` is the prose. */ +export type FormatCode = 'spec' | 'legacy' | 'missing' | 'frontmatter-unparsable' | 'ambiguous' | 'no-token-groups' | 'no-shape'; /** Headings that identify gstack's pre-spec DESIGN.md. */ export const LEGACY_HEADINGS = ['Product Context', 'Aesthetic Direction', 'Color', 'Spacing', 'Decisions Log']; @@ -97,7 +109,7 @@ export function parseDesignMd(text: string): DesignMdDoc { let frontmatter: Record | null = null; let frontmatterError: string | undefined; - const legacyMarker = rest.match(/^\n?/); + const legacyMarker = rest.match(LEGACY_MARKER_RE); if (legacyMarker) { marker = legacyMarker[1] as FormatChoice; rest = rest.slice(legacyMarker[0].length); @@ -106,7 +118,7 @@ export function parseDesignMd(text: string): DesignMdDoc { const end = rest.indexOf('\n---', 4); if (end !== -1 && (rest[end + 4] === '\n' || end + 4 === rest.length)) { frontmatterText = rest.slice(4, end + 1); - const m = frontmatterText.match(/^# gstack: design-md-format=(spec|legacy-keep)\s*$/m); + const m = frontmatterText.match(YAML_MARKER_RE); if (m) marker = m[1] as FormatChoice; const parsed = parseYaml(frontmatterText); frontmatter = parsed.value; @@ -154,18 +166,19 @@ export function hasSpecFrontmatter(doc: DesignMdDoc): boolean { return TOKEN_GROUPS.some(g => g in doc.frontmatter!) || 'name' in doc.frontmatter; } -export function detectFormat(doc: DesignMdDoc | null): { format: DesignMdFormat; reason?: string } { - if (!doc) return { format: 'missing' }; +export function detectFormat(doc: DesignMdDoc | null): { format: DesignMdFormat; code: FormatCode; reason?: string } { + if (!doc) return { format: 'missing', code: 'missing' }; if (doc.frontmatterText !== null && doc.frontmatter === null) { - return { format: 'unknown', reason: `front matter does not parse: ${doc.frontmatterError ?? 'unknown error'}` }; + return { format: 'unknown', code: 'frontmatter-unparsable', reason: `front matter does not parse: ${doc.frontmatterError ?? 'unknown error'}` }; } const spec = hasSpecFrontmatter(doc); - const legacyHeadings = doc.sections.filter(s => ['product context', 'aesthetic direction'].includes(s.heading.trim().toLowerCase())).length > 0; - if (spec && legacyHeadings) return { format: 'unknown', reason: 'ambiguous (legacy headings and front matter both present)' }; - if (spec) return { format: 'spec' }; - if (isLegacyGstackFormat(doc)) return { format: 'legacy' }; - if (doc.frontmatterText !== null) return { format: 'unknown', reason: 'front matter carries none of the five token groups' }; - return { format: 'unknown', reason: 'no front matter and no gstack legacy headings' }; + const identity = new Set(LEGACY_IDENTITY_HEADINGS.map(h => h.toLowerCase())); + const legacyHeadings = doc.sections.some(s => identity.has(s.heading.trim().toLowerCase())); + if (spec && legacyHeadings) return { format: 'unknown', code: 'ambiguous', reason: 'ambiguous (legacy headings and front matter both present)' }; + if (spec) return { format: 'spec', code: 'spec' }; + if (isLegacyGstackFormat(doc)) return { format: 'legacy', code: 'legacy' }; + if (doc.frontmatterText !== null) return { format: 'unknown', code: 'no-token-groups', reason: 'front matter carries none of the five token groups' }; + return { format: 'unknown', code: 'no-shape', reason: 'no front matter and no gstack legacy headings' }; } // ── YAML block emitter ─────────────────────────────────────────────────────── @@ -211,7 +224,7 @@ export function renderDesignMd(doc: DesignMdDoc, opts: RenderOptions = {}): stri const parts: string[] = []; const fm = opts.emitFrontmatter && doc.frontmatter ? emitYamlBlock(doc.frontmatter) + '\n' : doc.frontmatterText; if (fm !== null) { - const body = fm.replace(/^# gstack: design-md-format=(spec|legacy-keep)\s*\n/m, ''); + const body = fm.replace(new RegExp(YAML_MARKER_RE.source + '\\n', 'm'), ''); parts.push('---'); if (doc.marker) parts.push(`# ${FORMAT_MARKER_PREFIX}${doc.marker}`); parts.push(body.replace(/\n$/, '')); @@ -221,17 +234,71 @@ export function renderDesignMd(doc: DesignMdDoc, opts: RenderOptions = {}): stri if (doc.marker) parts.push(``); if (doc.preamble) parts.push(doc.preamble); } - const canonical = CANONICAL_SECTIONS - .map(c => doc.sections.find(s => s.canonical === c)) - .filter((s): s is Section => Boolean(s)); - const extras = doc.sections.filter(s => !s.canonical); - for (const s of [...canonical, ...extras]) { - parts.push('', `## ${s.canonical ?? s.heading}`); + // Spec order is a spec-file property. A legacy or unknown file keeps its own + // order (Typography and Layout are canonical names, but re-sorting a file the + // user chose to keep legacy would rewrite it behind their back). + const specShaped = fm !== null; + const ordered = specShaped + ? [ + ...CANONICAL_SECTIONS.map(c => doc.sections.find(s => s.canonical === c)).filter((s): s is Section => Boolean(s)), + ...doc.sections.filter(s => !s.canonical), + ] + : doc.sections; + for (const s of ordered) { + parts.push('', `## ${specShaped ? (s.canonical ?? s.heading) : s.heading}`); if (s.body.trim()) parts.push('', s.body.trim()); } return parts.join('\n').replace(/^\n+/, '') + '\n'; } +/** + * Text-level section splice: replace the body of `## ` (matched by + * canonical name or exact heading) or append the section at the end. Every other + * byte of the file, front matter included, is untouched. This is what a tool + * that edits a file the user owns should use; renderDesignMd is for files gstack + * writes from scratch (convert, skeletons). + */ +export function spliceSection(text: string, heading: string, body: string): string { + const src = text.replace(/\r\n/g, '\n'); + const canonical = canonicalFor(heading); + const lines = src.split('\n'); + let inFence = false; + let start = -1; + let end = lines.length; + for (let i = 0; i < lines.length; i++) { + if (/^```/.test(lines[i])) inFence = !inFence; + if (inFence) continue; + const h = lines[i].match(/^## (.+?)\s*$/); + if (!h) continue; + if (start === -1) { + const matches = canonical ? canonicalFor(h[1]) === canonical : h[1].trim().toLowerCase() === heading.trim().toLowerCase(); + if (matches) start = i; + } else { end = i; break; } + } + const block = `## ${canonical ?? heading}\n\n${body.replace(/\s+$/, '')}\n`; + if (start === -1) { + return src.replace(/\s*$/, '') + '\n\n' + block; + } + const before = lines.slice(0, start).join('\n'); + const after = lines.slice(end).join('\n'); + return before + (before ? '\n' : '') + block + (after.trim() ? '\n' + after.replace(/^\n+/, '') : (after.endsWith('\n') ? '' : '')); +} + +/** + * Text-level marker insertion: a YAML comment on line 2 of a file that opens + * with front matter, an HTML comment on line 1 otherwise. Replaces an existing + * marker; every other byte is untouched. + */ +export function insertMarker(text: string, choice: FormatChoice): string { + const src = text.replace(/\r\n/g, '\n'); + const stripped = src.replace(LEGACY_MARKER_RE, ''); + if (stripped.startsWith('---\n')) { + const withoutOld = stripped.replace(new RegExp('^---\\n' + YAML_MARKER_RE.source.replace(/^\^/, '') + '\\n', 'm'), '---\n'); + return withoutOld.replace(/^---\n/, `---\n# ${FORMAT_MARKER_PREFIX}${choice}\n`); + } + return `\n` + stripped; +} + /** Replace or add a section; canonical names slot into spec order, extras append. Body-only: front matter bytes untouched. */ export function upsertSection(doc: DesignMdDoc, heading: string, body: string): DesignMdDoc { const canonical = canonicalFor(heading); @@ -278,7 +345,7 @@ export function tokensFlat(frontmatter: Record | null): FlatTok let seen = 0; let cur: unknown = raw[target]; let curKey = target; - while (typeof cur === 'string' && /^\{[a-zA-Z0-9_.-]+\}$/.test(cur) && seen < 8) { + while (typeof cur === 'string' && /^\{[a-zA-Z0-9_.-]+\}$/.test(cur) && seen < TOKEN_REF_MAX_HOPS) { curKey = cur.slice(1, -1); cur = raw[curKey]; seen++; @@ -292,7 +359,8 @@ export function tokensFlat(frontmatter: Record | null): FlatTok // ── Legacy conversion ──────────────────────────────────────────────────────── -function slug(s: string): string { +/** kebab-case token key from a human label */ +export function slug(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'token'; } @@ -395,7 +463,7 @@ export function convertLegacy(doc: DesignMdDoc, opts: { name?: string } = {}): D } if (Object.keys(rounded).length) fm.rounded = rounded; - const consumed = new Set(['product context', 'aesthetic direction', 'typography', 'color', 'colors', 'spacing', 'layout']); + const consumed = new Set([...LEGACY_IDENTITY_HEADINGS.map(h => h.toLowerCase()), 'typography', 'color', 'colors', 'spacing', 'layout']); const sections: Section[] = []; sections.push({ heading: 'Overview', canonical: 'Overview', body: overview.join('\n\n') || '(no product context recorded)' }); if (color) sections.push({ heading: 'Colors', canonical: 'Colors', body: color.trim() }); @@ -410,7 +478,7 @@ export function convertLegacy(doc: DesignMdDoc, opts: { name?: string } = {}): D frontmatterText: emitYamlBlock(fm) + '\n', frontmatter: fm, marker: 'spec', - preamble: title ? `# ${title}` : doc.preamble, + preamble: doc.preamble, // the title line and any intro prose under it survive verbatim sections, }; } diff --git a/test/design-md.test.ts b/test/design-md.test.ts index 5e4e25e7e..d2794ae5d 100644 --- a/test/design-md.test.ts +++ b/test/design-md.test.ts @@ -15,12 +15,16 @@ import * as path from 'path'; import { spawnSync } from 'child_process'; import { parseDesignMd, detectFormat, renderDesignMd, upsertSection, convertLegacy, tokensFlat, - emitYamlBlock, setMarker, specSkeleton, CANONICAL_SECTIONS, TOKEN_GROUPS, isLegacyGstackFormat, + emitYamlBlock, setMarker, specSkeleton, spliceSection, insertMarker, CANONICAL_SECTIONS, TOKEN_GROUPS, isLegacyGstackFormat, } from '../lib/design-md'; import { updateDesignMd, readDesignConstraints } from '../design/src/memory'; const ROOT = path.join(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-design-md.ts'); +const runBin = (args: string[], cwd: string) => { + const r = spawnSync(process.execPath, ['--no-env-file', 'run', BIN, ...args], { cwd, encoding: 'utf-8', timeout: 60_000 }); + return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' }; +}; // gstack's own DESIGN.md is now in the open format; its pre-conversion form is the legacy fixture. const LEGACY = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'design-md-legacy.md'), 'utf-8'); @@ -78,19 +82,19 @@ describe('parse + detect', () => { expect(doc.frontmatterText).toContain('primary: "#1A1C1E"'); expect(doc.preamble).toBe('# Heritage'); expect(doc.sections.map(s => s.canonical ?? s.heading)).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log']); - expect(detectFormat(doc)).toEqual({ format: 'spec' }); + expect(detectFormat(doc)).toEqual({ format: 'spec', code: 'spec' }); }); test("the legacy fixture is legacy, gstack's own DESIGN.md is spec; a fresh file is unknown; nothing is missing", () => { const doc = parseDesignMd(LEGACY); expect(isLegacyGstackFormat(doc)).toBe(true); - expect(detectFormat(doc)).toEqual({ format: 'legacy' }); + expect(detectFormat(doc)).toEqual({ format: 'legacy', code: 'legacy' }); const own = parseDesignMd(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf-8')); - expect(detectFormat(own)).toEqual({ format: 'spec' }); + expect(detectFormat(own)).toEqual({ format: 'spec', code: 'spec' }); expect(own.marker).toBe('spec'); expect(tokensFlat(own.frontmatter).errors).toEqual([]); expect(detectFormat(parseDesignMd('# Hello\n\nJust prose.\n')).format).toBe('unknown'); - expect(detectFormat(null)).toEqual({ format: 'missing' }); + expect(detectFormat(null)).toEqual({ format: 'missing', code: 'missing' }); }); test('malformed front matter is unknown with a reason, never a throw', () => { @@ -140,10 +144,14 @@ describe('render + upsert', () => { expect([...order].sort((a, b) => a - b)).toEqual(order); }); - test('aliases map to canonical names (Brand & Style → Overview, Elevation → Elevation & Depth)', () => { - const doc = parseDesignMd('## Brand & Style\n\nx\n\n## Elevation\n\ny\n'); - expect(doc.sections.map(s => s.canonical)).toEqual(['Overview', 'Elevation & Depth']); - expect(renderDesignMd(doc)).toContain('## Elevation & Depth'); + test('aliases map to canonical names (Brand & Style → Overview, Elevation → Elevation & Depth); a spec-shaped file renders them canonically, a plain file keeps its words', () => { + const plain = parseDesignMd('## Brand & Style\n\nx\n\n## Elevation\n\ny\n'); + expect(plain.sections.map(s => s.canonical)).toEqual(['Overview', 'Elevation & Depth']); + expect(renderDesignMd(plain)).toContain('## Elevation\n'); // no front matter: the user's headings stay + const spec = parseDesignMd('---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Elevation\n\ny\n\n## Brand & Style\n\nx\n'); + const out = renderDesignMd(spec); + expect(out).toContain('## Elevation & Depth'); + expect(out.indexOf('## Overview')).toBeLessThan(out.indexOf('## Elevation & Depth')); }); test('upsertSection splices the body only: front matter bytes are identical before and after', () => { @@ -157,6 +165,37 @@ describe('render + upsert', () => { expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log', 'Extracted Design Language']); }); + test('a legacy or unknown file renders in its own section order; only spec-shaped files sort canonically', () => { + const legacy = parseDesignMd(LEGACY); + const out = renderDesignMd(legacy); + const headings = (s: string) => [...s.matchAll(/^## (.+)$/gm)].map(x => x[1]); + expect(headings(out)).toEqual(headings(LEGACY)); + }); + + test('spliceSection replaces or appends one section and leaves every other byte alone', () => { + const once = spliceSection(SPEC, 'Extracted Design Language', 'from a mockup'); + expect(once.startsWith(SPEC.replace(/\s*$/, ''))).toBe(true); + expect(once.endsWith('## Extracted Design Language\n\nfrom a mockup\n')).toBe(true); + const twice = spliceSection(once, 'Extracted Design Language', 'second pass'); + expect(twice.split('## Extracted Design Language').length - 1).toBe(1); + expect(twice).toContain('second pass'); + expect(twice).not.toContain('from a mockup'); + expect(twice.slice(0, twice.indexOf('## Extracted'))).toBe(once.slice(0, once.indexOf('## Extracted'))); + // replacing a middle section keeps what follows + const mid = spliceSection(SPEC, 'Colors', 'Ink only.'); + expect(mid).toContain('## Colors\n\nInk only.\n\n## Typography'); + expect(mid).toContain('## Decisions Log'); + }); + + test('insertMarker adds or replaces the marker only: line 2 YAML comment for front matter, line 1 HTML comment otherwise', () => { + const noMarker = SPEC.replace('# gstack: design-md-format=spec\n', ''); + expect(insertMarker(noMarker, 'spec')).toBe(SPEC); + expect(insertMarker(SPEC, 'spec')).toBe(SPEC); + const kept = insertMarker(LEGACY, 'legacy-keep'); + expect(kept).toBe('\n' + LEGACY); + expect(insertMarker(kept, 'legacy-keep')).toBe(kept); + }); + test('setMarker on a spec file writes the YAML comment on line 2 and nothing else moves', () => { const noMarker = SPEC.replace('# gstack: design-md-format=spec\n', ''); const out = renderDesignMd(setMarker(parseDesignMd(noMarker), 'spec')); @@ -202,7 +241,7 @@ describe('convertLegacy on the legacy fixture (gstack\'s pre-conversion DESIGN.m test('produces a spec file with the marker on line 2 and only the five token groups plus name', () => { const doc = parseDesignMd(out); - expect(detectFormat(doc)).toEqual({ format: 'spec' }); + expect(detectFormat(doc)).toEqual({ format: 'spec', code: 'spec' }); expect(out.split('\n')[1]).toBe('# gstack: design-md-format=spec'); for (const k of Object.keys(doc.frontmatter!)) expect(['name', ...TOKEN_GROUPS]).toContain(k); expect(doc.frontmatter!.name).toBe('gstack'); @@ -226,6 +265,13 @@ describe('convertLegacy on the legacy fixture (gstack\'s pre-conversion DESIGN.m expect(tokens['rounded.full']).toBe('9999px'); }); + test('intro prose under the title survives conversion', () => { + const withIntro = LEGACY.replace('# Design System — gstack\n', '# Design System — gstack\n\nAn intro paragraph that must not vanish.\n'); + const out2 = renderDesignMd(convertLegacy(parseDesignMd(withIntro)), { emitFrontmatter: true }); + expect(out2).toContain('An intro paragraph that must not vanish.'); + expect(out2).toContain('# Design System — gstack'); + }); + test('folds Product Context and Aesthetic Direction into Overview; Motion, Grain Texture, Decisions Log survive as extras in order', () => { const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]); expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Layout', 'Motion', 'Grain Texture', 'Decisions Log']); @@ -240,11 +286,142 @@ describe('convertLegacy on the legacy fixture (gstack\'s pre-conversion DESIGN.m }); }); +describe('coverage: parser and token edges', () => { + test('CRLF input parses to the same document; front matter closing at EOF without a newline parses; an unclosed fence is body', () => { + const lf = parseDesignMd(SPEC); + const crlf = parseDesignMd(SPEC.replace(/\n/g, '\r\n')); + expect(crlf.frontmatter).toEqual(lf.frontmatter); + expect(crlf.sections.map(s => s.heading)).toEqual(lf.sections.map(s => s.heading)); + const eof = parseDesignMd('---\nname: x\ncolors:\n a: "#fff"\n---'); + expect(eof.frontmatter?.name).toBe('x'); + expect(eof.sections).toEqual([]); + const unclosed = parseDesignMd('---\nname: x\n\n## Overview\n\nbody\n'); + expect(unclosed.frontmatterText).toBeNull(); + expect(unclosed.sections.map(s => s.heading)).toEqual(['Overview']); + }); + + test('detectFormat: front matter without a token group is unknown with its reason; name-only is spec; one legacy heading is unknown', () => { + expect(detectFormat(parseDesignMd('---\nfoo: 1\n---\n\n## Overview\n\nx\n'))).toEqual({ format: 'unknown', code: 'no-token-groups', reason: 'front matter carries none of the five token groups' }); + expect(detectFormat(parseDesignMd('---\nname: X\n---\n\n## Overview\n\nx\n')).format).toBe('spec'); + expect(detectFormat(parseDesignMd('# T\n\n## Product Context\n\nx\n')).format).toBe('unknown'); + }); + + test('tokensFlat: reference cycles error, arrays are skipped, numbers stringify, deep chains resolve up to the hop limit', () => { + const cyc = tokensFlat({ colors: { a: '{colors.b}', b: '{colors.a}' } }); + expect(cyc.errors.join('\n')).toContain('(reference cycle)'); + const arr = tokensFlat({ colors: { list: ['#111', '#222'], a: '#333' }, spacing: { md: 16 } }); + expect(arr.tokens['colors.list']).toBeUndefined(); + expect(arr.tokens['colors.a']).toBe('#333'); + expect(arr.tokens['spacing.md']).toBe('16'); + const chain: Record = { base: '#000' }; + for (let i = 1; i <= 7; i++) chain[`c${i}`] = `{colors.${i === 1 ? 'base' : `c${i - 1}`}}`; + expect(tokensFlat({ colors: chain }).tokens['colors.c7']).toBe('#000'); + }); + + test('convertLegacy: no title → name "Design System"; opts.name wins; a doc without Color/Spacing/Layout gets Overview only plus extras; rem units survive; "## Colors" alias is consumed', () => { + const bare = parseDesignMd('## Product Context\n\n- **What this is:** x\n\n## Aesthetic Direction\n\n- **Direction:** y\n\n## Motion\n\n- **Approach:** z\n'); + const conv = convertLegacy(bare); + expect(conv.frontmatter?.name).toBe('Design System'); + expect(convertLegacy(bare, { name: 'Custom' }).frontmatter?.name).toBe('Custom'); + expect(conv.sections.map(s => s.canonical ?? s.heading)).toEqual(['Overview', 'Motion']); + expect(conv.sections[0].body).toContain('**What this is:** x'); + const rem = parseDesignMd('# T\n\n## Product Context\n\n- **What this is:** x\n\n## Colors\n\n- **Primary:** #111111\n\n## Spacing\n\n- **Scale:** sm(0.5rem) md(1rem) lg(2)\n'); + const t = tokensFlat(convertLegacy(rem).frontmatter); + expect(t.tokens['spacing.sm']).toBe('0.5rem'); + expect(t.tokens['spacing.lg']).toBe('2px'); + expect(t.tokens['colors.primary']).toBe('#111111'); + expect(convertLegacy(rem).sections.map(s => s.canonical ?? s.heading)).toEqual(['Overview', 'Colors', 'Layout']); + const empty = parseDesignMd('## Nothing\n\nx\n'); + expect(convertLegacy(empty).sections[0].body).toBe('(no product context recorded)'); + }); + + test('renderDesignMd with emitFrontmatter and unparsable front matter falls back to the preserved bytes', () => { + const doc = parseDesignMd('---\ncolors: [unclosed\n---\n\n## Overview\n\nx\n'); + expect(doc.frontmatter).toBeNull(); + const out = renderDesignMd(doc, { emitFrontmatter: true }); + expect(out).toContain('colors: [unclosed'); + }); + + test('YAML scalars: null → "", numeric-looking and empty strings quoted, hex quoted, dashed keys unquoted; all parse back', () => { + const obj = { a: null as unknown as string, b: '16', c: '', d: '#fff', 'on-primary': 'x', e: 'yes', f: 'plain text', g: 3 }; + const yaml = emitYamlBlock(obj as Record); + expect(yaml).toContain('a: ""'); + expect(yaml).toContain('b: "16"'); + expect(yaml).toContain('c: ""'); + expect(yaml).toContain('d: "#fff"'); + expect(yaml).toContain('on-primary: x'); + expect(yaml).toContain('e: "yes"'); + expect(yaml).toContain('f: plain text'); + expect(yaml).toContain('g: 3'); + const back = (Bun as any).YAML.parse(yaml); + expect(back.b).toBe('16'); + expect(back.d).toBe('#fff'); + expect(back.e).toBe('yes'); + expect(back.g).toBe(3); + }); +}); + +describe('coverage: bin verbs and the memory writer edges', () => { + const run = runBin; + + test('mark spec on an unmarked spec file; mark on a missing file; tokens on a missing file and with invalid refs; explicit path; no verb', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC.replace('# gstack: design-md-format=spec\n', '')); + expect(run(['check'], dir).out).toContain('DESIGN_MD_MARKER: none'); + expect(run(['mark', 'spec'], dir).code).toBe(0); + expect(fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8').split('\n')[1]).toBe('# gstack: design-md-format=spec'); + const missing = run(['mark', 'legacy-keep', 'nope.md'], dir); + expect(missing.code).toBe(1); + expect(missing.out).toContain('DESIGN_MD_FORMAT: missing'); + const t0 = JSON.parse(run(['tokens', 'nope.md'], dir).out); + expect(t0.format).toBe('missing'); + expect(t0.tokens).toEqual({}); + fs.writeFileSync(path.join(dir, 'other.md'), '---\nname: x\ncolors:\n a: "{colors}"\n b: "#000"\n---\n\n## Overview\n\nx\n'); + const t1 = run(['tokens', 'other.md'], dir); + expect(t1.code).toBe(0); + expect(t1.err).toContain('DESIGN_MD_TOKEN_REF_INVALID: {colors}'); + expect(JSON.parse(t1.out).tokens['colors.b']).toBe('#000'); + expect(run(['check', 'other.md'], dir).out).toContain('DESIGN_MD_FORMAT: spec'); + const usage = run([], dir); + expect(usage.code).toBe(2); + expect(usage.err).toContain('usage:'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('updateDesignMd: non-hex colors and duplicate roles are dropped on a new file; a headingless file gains the section; unparsable front matter is preserved byte-for-byte', () => { + const extracted = { + colors: [{ name: 'Primary', hex: 'rgb(1,2,3)', usage: 'x' }, { name: 'Surface', hex: '#141414', usage: 'y' }], + typography: [{ role: 'heading', family: 'Satoshi', size: '48px', weight: '900' }, { role: 'heading', family: 'Inter', size: '1px', weight: '100' }], + spacing: [], layout: [], mood: 'm', + }; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + updateDesignMd(dir, extracted, '/tmp/m.png'); + const fresh = parseDesignMd(fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8')); + const tokens = tokensFlat(fresh.frontmatter).tokens; + expect(tokens['colors.primary']).toBeUndefined(); + expect(tokens['colors.surface']).toBe('#141414'); + expect(tokens['typography.heading.fontFamily']).toBe('Satoshi'); + + fs.writeFileSync(path.join(dir, 'DESIGN.md'), '# Just a title\n\nSome prose without sections.\n'); + updateDesignMd(dir, extracted, '/tmp/m.png'); + const headless = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + expect(headless.startsWith('# Just a title')).toBe(true); + expect(headless).toContain('## Extracted Design Language'); + + const broken = '---\ncolors: [unclosed\n---\n\n## Overview\n\nx\n'; + fs.writeFileSync(path.join(dir, 'DESIGN.md'), broken); + updateDesignMd(dir, extracted, '/tmp/m.png'); + const after = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + expect(after.startsWith('---\ncolors: [unclosed\n---\n')).toBe(true); + expect(after).toContain('## Extracted Design Language'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + describe('bin/gstack-design-md.ts', () => { - const run = (args: string[], cwd: string) => { - const r = spawnSync(process.execPath, ['--no-env-file', 'run', BIN, ...args], { cwd, encoding: 'utf-8', timeout: 60_000 }); - return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' }; - }; + const run = runBin; test('check reports format + marker for spec, legacy, unknown, missing', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); @@ -297,9 +474,17 @@ describe('bin/gstack-design-md.ts', () => { const m = run(['mark', 'legacy-keep'], dir); expect(m.code).toBe(0); const text = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); - expect(text.split('\n')[0]).toBe(''); + expect(text).toBe('\n' + LEGACY); // byte-identical apart from line 1 expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: legacy\nDESIGN_MD_MARKER: legacy-keep\n'); expect(run(['mark', 'maybe'], dir).code).toBe(2); + // a choice that contradicts the file is refused and the file is unchanged + const bad = run(['mark', 'spec'], dir); + expect(bad.code).toBe(2); + expect(bad.err).toContain('DESIGN_MD_CONVERT_REFUSED: mark spec contradicts'); + expect(fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8')).toBe(text); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC); + expect(run(['mark', 'legacy-keep'], dir).code).toBe(2); + expect(fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8')).toBe(SPEC); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); }); @@ -336,8 +521,7 @@ describe('design binary: updateDesignMd is frontmatter-safe', () => { fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY); updateDesignMd(dir, extracted, '/tmp/mock.png'); const out = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); - expect(out.startsWith('# Design System — gstack')).toBe(true); - expect(out).toContain('## Decisions Log'); + expect(out.startsWith(LEGACY.replace(/\s*$/, ''))).toBe(true); // every original byte kept, in order expect([...out.matchAll(/^## (.+)$/gm)].map(m => m[1]).at(-1)).toBe('Extracted Design Language'); expect(detectFormat(parseDesignMd(out)).format).toBe('legacy'); } finally { fs.rmSync(dir, { recursive: true, force: true }); }