fix(design-md): mark and updateDesignMd never rewrite the user's file; refuse a contradictory mark

renderDesignMd re-sorted canonical section names into spec order on every
render, so `gstack-design-md mark legacy-keep` (the "leave it alone" answer)
and the design binary's mockup extraction reordered a legacy DESIGN.md
(Typography and Layout jumped to the top) and normalized its whitespace, while
the bin promised "body bytes untouched". `mark` now splices only the marker
line (insertMarker) and `updateDesignMd` splices only its own section
(spliceSection); every other byte of an existing file is preserved, and spec
order applies only to files that open with front matter. `mark` refuses a
choice that contradicts the file's format (spec on a non-spec file,
legacy-keep on a spec file) with DESIGN_MD_CONVERT_REFUSED, exit 2, file
unchanged. convertLegacy keeps intro prose under the title instead of
rebuilding the preamble from the title alone. detectFormat returns a
machine-readable `code` beside the prose reason (the bin no longer branches on
reason text); the marker regexes derive from FORMAT_MARKER_PREFIX and
FORMAT_CHOICES; the hop limit and legacy identity headings are named
constants; slug is exported and reused; both writers use lib/fs-atomic.ts.
Tests pin byte identity for mark and updateDesignMd on the legacy fixture,
the refusal paths, and the preserved preamble.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 17:31:30 +00:00
co-authored by Claude Fable 5.1
parent 60758c5ddf
commit b4d88a0126
4 changed files with 327 additions and 79 deletions
+16 -14
View File
@@ -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;
}
+19 -25
View File
@@ -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<string, string> = {};
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<string, Record<string, string>> = {};
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<string, string> = { 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)}*`,
"",
+91 -23
View File
@@ -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('|') + ')';
/** `<!-- gstack: design-md-format=... -->` on line 1 (legacy files) */
const LEGACY_MARKER_RE = new RegExp('^<!--\\s*' + MARKER_RE_BODY + '\\s*-->\\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<string, unknown> | null = null;
let frontmatterError: string | undefined;
const legacyMarker = rest.match(/^<!--\s*gstack: design-md-format=(spec|legacy-keep)\s*-->\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<string>(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(`<!-- ${FORMAT_MARKER_PREFIX}${doc.marker} -->`);
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 `## <heading>` (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 `<!-- ${FORMAT_MARKER_PREFIX}${choice} -->\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<string, unknown> | 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<string, unknown> | 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,
};
}
+201 -17
View File
@@ -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('<!-- gstack: design-md-format=legacy-keep -->\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<string, string> = { 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<string, unknown>);
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('<!-- gstack: design-md-format=legacy-keep -->');
expect(text).toBe('<!-- gstack: design-md-format=legacy-keep -->\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 }); }