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
+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)}*`,
"",