feat(storybook): mirror the complete feature-map index (#16052)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The feature map describes its user-facing capabilities and
verification recipes.
> - Storybook provides isolated UI examples for those capabilities.
> - The previous organization showed only features with primary story
suites.
> - This pull request derives the complete Storybook index from the
upstream map.
> - Readers can find the same features in both places and see related
examples or explicit gaps.

## Linked Issues or Issue Description

Refs #16046 and #16049.

**What existing behavior does this improve?**

Storybook navigation and the downstream feature catalog.

**Current behavior**

The sidebar has 28 primary feature homes. The feature map lists 35. The
main guide groups suites alphabetically by area slug. Related examples
can live under a different feature.

**Proposed behavior**

Use the feature map's eight sections and 35 features in the same order.
Give each feature an Overview page with all its capabilities, related
example links, and missing-example messages. Keep shared foundations
outside the product index. Preserve all existing stories and URLs.

**Reason and benefit**

Readers can move between the feature map and Storybook without learning
a second index. A feature without cataloged examples remains visible.

**Breaking changes**

None to existing story IDs or product behavior. Storybook start and
build now reject stale generated feature pages and ordering. Run the
catalog generator after upstream changes, inspect the output, and review
affected source fingerprints.

## What Changed

- Generate 35 feature Overview stories and capability data from the
upstream map. Generate the inline sidebar order required by Storybook.
- Replace the main guide with the full ordered feature index and search.
Link all related suites from feature pages, even across primary homes.
- Preserve all 223 authored files, 2,210 story IDs, and the existing
docs ID. Keep status, implementation, and fixture classifications
visible.
- Put the generated Markdown catalog in feature-map order. Keep
generated guides outside example counts.
- Update maintenance and review skills, documentation, and generator
tests. Keep the feature map independent of Storybook.

## Verification

- Passed: 16 catalog and generator tests; map checks; token gates; both
skill validators.
- Passed: `pnpm -r typecheck`, `pnpm build`, and the Storybook build.
- Built-index comparison preserved all 2,211 previous entries and added
only 35 feature overviews. Feature order matches the map.
- Browser: inspected the complete index and sidebar, searched for Goals,
opened its overview, and followed a related suite under Projects. The
original suite renders.
- Passed: all 8,170 UI tests in 728 files. Browser checks also covered
empty search, capability-ID search, missing-example pages, the original
editable-text bookmark, the CLI sidebar label, and searching/opening
shared foundations.
- The broad local `pnpm test:run` was stopped after a failure in the
unchanged GitHub chat test `authorizes description edits as the editor,
never inheriting the original author's access`. That exact test passed
alone without code or expectation changes. A complete local repository
suite pass is not established.
- Original revision CI passed after one runner retry. The merged
revision `5ca01c70716f23974680a8019ddc613035d11bc4` passed fresh CI.
Local UI typecheck, catalog checks, 16 generator tests, map checks and
token gates pass.
- Greptile: 5/5 on final commit
`5ca01c70716f23974680a8019ddc613035d11bc4`, with all findings resolved.
- Master merge: inspected source drift in 61 catalog entries and
regenerated INDEX, preserving upstream notes. Marked the sidebar suite
that mounts IssueDetail unreviewed for the new activity query; recorded
limits for composer and channel-default changes. No stories removed.
- Final Storybook build passed. Browser filename searches for
`new-task-composer` and `forms-editors` return the expected feature and
shared example.
- No visual-baseline or full production acceptance claim. The deeper
authored scenario groups remain intact.

## Risks

- The parser depends on the feature-map Markdown contract. It rejects
unknown index rows, omitted feature recipes, and unexpected generated
files.
- A suite can reference several capabilities. A link proves catalog
association, not complete behavior coverage or production parity.
- Storybook treats `/` as a folder separator. Sidebar labels use a comma
for that character; the guide and page headings retain the upstream
name.
- No database, API, authorization, or production component changes.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code execution, and browser
inspection. The exact runtime model ID and context window are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 07:01:29 -05:00
1 parent d67e3507b2
commit 9bd820e687
52 files changed
+2943 -782

No files matched your search

+31 -19
View File
@@ -4,6 +4,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { featureIndex, reconcileFeatureArtifacts, sidebarLabel } from "./storybook-feature-index.mjs";
const scriptPath = fileURLToPath(import.meta.url);
const defaultRoot = path.resolve(path.dirname(scriptPath), "..");
@@ -41,14 +42,7 @@ export function capabilities(root) {
}
export function featureNavigation(root) {
const navigation = new Map();
let section;
for (const line of readFileSync(path.join(root, "maps/feature-map/README.md"), "utf8").split("\n")) {
if (line.startsWith("### ")) section = line.slice(4);
const match = line.match(/^\| \[([^\]]+)\]\(\.\/([^.)]+)\.md\)/);
if (match && section) navigation.set(match[2], `${section}/${match[1]}`);
}
return navigation;
return new Map(featureIndex(root).sections.flatMap(section => section.features.map(feature => [feature.id, `${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}`])));
}
export function scanStories(root = defaultRoot) {
@@ -99,14 +93,14 @@ export function validateCatalog(root, catalog, stories) {
review(label, entry.sources, entry.fingerprint);
}
for (const story of stories) if (!seen.has(story.file)) errors.push(`${story.file}: story missing from catalog`);
const ids = stories.flatMap(story => story.ids);
const ids = [...stories.flatMap(story => story.ids), ...featureIndex(root).sections.flatMap(section => section.features.map(feature => `feature-map-${feature.id}--overview`))];
if (ids.length !== new Set(ids).size) errors.push("Duplicate story IDs");
return errors;
}
function cell(value) { return String(value).replaceAll("|", "\\|").replaceAll("\n", " "); }
export function renderIndex(catalog, stories, refs) {
export function renderIndex(catalog, stories, index) {
const byFile = new Map(stories.map(story => [story.file, story]));
const groups = new Map();
for (const entry of catalog.entries) {
@@ -118,8 +112,19 @@ export function renderIndex(catalog, stories, refs) {
"[Contract and maintenance](README.md) · [Review findings](REVIEW.md)", "",
`${stories.length} story files; ${stories.reduce((n, story) => n + story.ids.length, 0)} indexed stories. Counts describe inventory, not passed tests or complete feature coverage.`, "",
"Status describes product alignment; implementation describes the rendered subject; boundary describes simulated data. Sources and limitations are in catalog.json. Related capabilities cross-list one canonical story file.", ""];
for (const [area, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
lines.push(`## ${area}`, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []),
const homes = index.sections.flatMap(section => section.features.map(feature => ({ ...feature, section: section.title })));
homes.push({ id: "foundations-and-guides", title: "Shared foundations and guides", section: "Foundations", description: "Shared components and contributor guidance outside the product feature index." });
let currentSection;
for (const feature of homes) {
const area = feature.id;
const entries = groups.get(area) ?? [];
if (feature.section !== currentSection) {
lines.push(`## ${feature.section}`, "");
currentSection = feature.section;
}
lines.push(`### ${feature.title}`, "", feature.description, "", ...(area !== "foundations-and-guides" ? [`[Feature recipe](../feature-map/${area}.md)`, ""] : []));
if (!entries.length) lines.push("No primary story suite. Check capability lookup below for related examples; this does not imply a missing product feature.", "");
else lines.push(
"| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |",
"| --- | --- | --- | --- | --- |");
for (const entry of entries.sort((a, b) => a.file.localeCompare(b.file))) {
@@ -129,24 +134,31 @@ export function renderIndex(catalog, stories, refs) {
lines.push("");
}
lines.push("## Capability lookup", "", "References are navigation links, not proof of parity. An absent link means no cataloged story, not a missing product feature. CLI/operator capabilities may need no Storybook example.", "");
for (const ref of refs.keys()) {
for (const ref of index.sections.flatMap(section => section.features.flatMap(feature => feature.capabilities.map(capability => capability.id)))) {
const entries = catalog.entries.filter(entry => entry.capabilities.includes(ref));
lines.push(`- \`${ref}\`: ${entries.length ? entries.map(entry => `[${cell(byFile.get(entry.file).title)}](../../${entry.file}) (${entry.status})`).join(", ") : "No cataloged story."}`);
}
return lines.join("\n") + "\n";
}
export function reconcileCatalog(root, catalog, stories, write = false) {
const index = featureIndex(root);
const errors = reconcileFeatureArtifacts(root, index, write);
errors.push(...validateCatalog(root, catalog, stories));
if (!errors.length) {
const generated = renderIndex(catalog, stories, index);
if (write) writeFileSync(path.join(root, indexPath), generated);
else if (!existsSync(path.join(root, indexPath)) || readFileSync(path.join(root, indexPath), "utf8") !== generated) errors.push("Generated INDEX.md is stale; run with --write after reviewing catalog changes");
}
return errors;
}
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
const args = process.argv.slice(2);
if (args.some(arg => arg !== "--write")) throw new Error("Usage: node scripts/storybook-catalog.mjs [--write]");
const catalog = JSON.parse(readFileSync(path.join(defaultRoot, catalogPath), "utf8"));
const stories = scanStories();
const errors = validateCatalog(defaultRoot, catalog, stories);
if (!errors.length) {
const generated = renderIndex(catalog, stories, capabilities(defaultRoot));
if (args.includes("--write")) writeFileSync(path.join(defaultRoot, indexPath), generated);
else if (!existsSync(path.join(defaultRoot, indexPath)) || readFileSync(path.join(defaultRoot, indexPath), "utf8") !== generated) errors.push("Generated INDEX.md is stale; run with --write after reviewing catalog changes");
}
const errors = reconcileCatalog(defaultRoot, catalog, stories, args.includes("--write"));
if (errors.length) { errors.forEach(error => console.error(error)); process.exitCode = 1; }
else console.log(`Storybook catalog aligned: ${stories.length} files, ${stories.reduce((n, story) => n + story.ids.length, 0)} stories. Structure and review fingerprints only; no runtime proof.`);
}
+43 -2
View File
@@ -4,7 +4,8 @@ import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { capabilities, fingerprint, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs";
import { fingerprint, reconcileCatalog, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs";
import { featureIndex, reconcileFeatureArtifacts } from "./storybook-feature-index.mjs";
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), "storybook-catalog-"));
@@ -99,7 +100,7 @@ test("missing and escaping source references cannot certify a review", t => {
test("cross-capability lookup retains one canonical file and honest status", t => {
const f = fixture(t);
f.catalog.entries[0].status = "historical";
const index = renderIndex(f.catalog, f.stories, capabilities(f.root));
const index = renderIndex(f.catalog, f.stories, featureIndex(f.root));
assert.match(index, /tasks\/create.*\(historical\)/);
assert.match(index, /tasks\/discussion.*\(historical\)/);
assert.match(index, /1 story files; 1 indexed stories/);
@@ -112,6 +113,46 @@ test("duplicate story IDs cannot silently collapse distinct examples", t => {
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /Duplicate story IDs/);
});
test("feature index keeps empty homes and cross-feature references in map order", t => {
const f = fixture(t);
f.write("maps/feature-map/README.md", "### Work\n| [Goals](./goals.md) | Align work. |\n| [Tasks](./tasks.md) | Create work. |\n");
f.write("maps/feature-map/goals.md", "## Sub-features\n- `hierarchy`: Link goals.\n## Gotchas\n");
f.catalog.entries[0].capabilities.push("goals/hierarchy");
const index = renderIndex(f.catalog, f.stories, featureIndex(f.root));
assert.ok(index.indexOf("### Goals") < index.indexOf("### Tasks"));
assert.match(index, /### Goals[\s\S]*?No primary story suite/);
assert.match(index, /`goals\/hierarchy`: \[Tasks and conversations\/Tasks\/Create\]/);
});
test("authored stories cannot collide with generated overview IDs", t => {
const f = fixture(t);
f.stories[0].ids = ["feature-map-tasks--overview"];
assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /Duplicate story IDs/);
});
test("regeneration reports review drift from its own writes without refreshing fingerprints", t => {
const f = fixture(t);
f.write("ui/storybook/.storybook/preview.tsx", "export default { parameters: { options: { storySort: { order: [],\n}, } } };\n");
reconcileFeatureArtifacts(f.root, featureIndex(f.root), true);
f.write("maps/storybook-map/INDEX.md", "");
const generatedSource = "ui/storybook/generated/feature-index.json";
f.catalog.sharedReview.sources.push(generatedSource);
f.catalog.entries[0].sources.push(generatedSource);
f.catalog.sharedReview.fingerprint = fingerprint(f.root, f.catalog.sharedReview.sources);
f.catalog.entries[0].fingerprint = fingerprint(f.root, f.catalog.entries[0].sources);
assert.deepEqual(reconcileCatalog(f.root, f.catalog, f.stories, true), []);
const reviewed = f.catalog.sharedReview.fingerprint;
f.write("maps/feature-map/tasks.md", "## Sub-features\n- `create`: Updated description.\n- `discussion`: discuss\n## Gotchas\n");
const errors = reconcileCatalog(f.root, f.catalog, f.stories, true);
assert.equal(errors.filter(error => error.includes("source changed; review")).length, 2);
assert.equal(f.catalog.sharedReview.fingerprint, reviewed);
assert.notEqual(fingerprint(f.root, f.catalog.sharedReview.sources), reviewed);
f.catalog.sharedReview.fingerprint = fingerprint(f.root, f.catalog.sharedReview.sources);
f.catalog.entries[0].fingerprint = fingerprint(f.root, f.catalog.entries[0].sources);
assert.deepEqual(reconcileCatalog(f.root, f.catalog, f.stories, true), []);
assert.deepEqual(reconcileCatalog(f.root, f.catalog, f.stories), []);
});
test("Storybook parser discovers nested files, filters helpers, preserves explicit IDs and counts play functions", t => {
const f = fixture(t);
f.write("ui/package.json", '{"type":"module"}');
+126
View File
@@ -0,0 +1,126 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const generatedRoot = "ui/storybook/generated";
export const sidebarLabel = title => title.replaceAll("/", ", ");
export function featureIndex(root) {
const sections = [];
let section;
const seen = new Set();
const source = readFileSync(path.join(root, "maps/feature-map/README.md"), "utf8");
for (const line of source.split("\n")) {
if (line.startsWith("### ")) {
section = { title: line.slice(4), features: [] };
sections.push(section);
}
const match = line.match(/^\| \[([^\]]+)\]\(\.\/([a-z0-9-]+)\.md\) \| (.*) \|$/);
if (!match) {
if (line.startsWith("| [")) throw new Error(`Unrecognized feature-map row: ${line}`);
continue;
}
if (!section || seen.has(match[2])) throw new Error(`Invalid feature-map entry: ${line}`);
const [, title, id, description] = match;
seen.add(id);
const recipe = readFileSync(path.join(root, `maps/feature-map/${id}.md`), "utf8");
const body = recipe.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0];
if (!body) throw new Error(`Missing sub-features: ${id}`);
const capabilities = [];
for (const row of body.trim().split("\n")) {
const capability = row.match(/^- `([^`]+)`:\s*(.*)$/);
if (capability) {
const [, key, text] = capability;
capabilities.push({ id: `${id}/${key}`, label: key.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase()), description: text });
} else if (row.trim() && capabilities.length) {
capabilities.at(-1).description += ` ${row.trim()}`;
}
}
if (!capabilities.length || new Set(capabilities.map(item => item.id)).size !== capabilities.length) throw new Error(`Invalid sub-features: ${id}`);
section.features.push({ id, title, description, capabilities });
}
const populated = sections.filter(item => item.features.length);
if (!populated.length) throw new Error("Feature-map index has no feature families");
for (const file of readdirSync(path.join(root, "maps/feature-map"))) {
if (file.endsWith(".md") && file !== "README.md" && !seen.has(file.slice(0, -3))) throw new Error(`Feature recipe missing from index: ${file}`);
}
return { sections: populated };
}
export function featureOrder(index) {
return ["Overview", ...index.sections.flatMap(section => [sidebarLabel(section.title),
section.features.flatMap(feature => [sidebarLabel(feature.title), ["Overview", "*"]]),
]), "Foundations"];
}
export function featureArtifacts(index) {
const outputs = new Map([
[`${generatedRoot}/feature-index.json`, JSON.stringify(index, null, 2) + "\n"],
]);
for (const section of index.sections) {
for (const feature of section.features) {
outputs.set(`${generatedRoot}/features/${feature.id}.stories.tsx`, [
'import type { Meta, StoryObj } from "@storybook/react-vite";',
'import { FeatureOverview } from "../../support/feature-map/FeatureOverview";',
"",
"const meta = {",
` id: ${JSON.stringify(`feature-map-${feature.id}`)},`,
` title: ${JSON.stringify(`${sidebarLabel(section.title)}/${sidebarLabel(feature.title)}/Overview`)},`,
" component: FeatureOverview,",
` args: { area: ${JSON.stringify(feature.id)} },`,
' tags: ["!autodocs"],',
' parameters: { layout: "fullscreen" },',
"} satisfies Meta<typeof FeatureOverview>;",
"export default meta;",
"export const Overview: StoryObj<typeof meta> = {};",
"",
].join("\n"));
}
}
return outputs;
}
export function reconcileFeatureArtifacts(root, index, write = false) {
const outputs = featureArtifacts(index);
const errors = [];
function inspect(directory) {
if (!existsSync(path.join(root, directory))) return;
for (const entry of readdirSync(path.join(root, directory), { withFileTypes: true })) {
const file = `${directory}/${entry.name}`;
if (entry.isDirectory()) inspect(file);
else if (!outputs.has(file)) errors.push(`Unexpected generated feature file: ${file}`);
}
}
inspect(generatedRoot);
if (errors.length) return errors;
for (const [file, content] of outputs) {
const destination = path.join(root, file);
if (write) {
mkdirSync(path.dirname(destination), { recursive: true });
writeFileSync(destination, content);
} else if (!existsSync(destination) || readFileSync(destination, "utf8") !== content) {
errors.push(`${file}: generated feature index is stale; run storybook-catalog.mjs --write`);
}
}
const previewFile = "ui/storybook/.storybook/preview.tsx";
const previewPath = path.join(root, previewFile);
const preview = readFileSync(previewPath, "utf8");
const pattern = /(\bstorySort:\s*\{\s*order:\s*)([\s\S]*?)(,\s*\n\s*\},)/g;
if ([...preview.matchAll(pattern)].length !== 1) throw new Error("Expected one inline storySort.order in preview.tsx");
const order = JSON.stringify(featureOrder(index), null, 2)
.replace(/("[^"]+",)\n\s+\[\n\s+"Overview",\n\s+"\*"\n\s+\]/g, '$1 ["Overview", "*"]')
.replaceAll("\n", "\n ");
const updated = preview.replace(pattern, (_, before, value, after) => before + order + after);
if (write) writeFileSync(previewPath, updated);
else if (updated !== preview) errors.push(`${previewFile}: generated sidebar order is stale; run storybook-catalog.mjs --write`);
return errors;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const root = fileURLToPath(new URL("..", import.meta.url));
const errors = reconcileFeatureArtifacts(root, featureIndex(root));
if (errors.length) {
errors.forEach(error => console.error(error));
process.exitCode = 1;
}
}
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { featureArtifacts, featureIndex, featureOrder, reconcileFeatureArtifacts } from "./storybook-feature-index.mjs";
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), "storybook-feature-index-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const write = (file, text) => {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), text);
};
write("maps/feature-map/README.md", "# Map\n### Work\n| [Tasks](./tasks.md) | Create and follow work. |\n| [Goals](./goals.md) | Align work. |\n### Connections\n| [Apps](./apps.md) | Connect tools. |\n");
write("maps/feature-map/tasks.md", "# Tasks\n## Sub-features\n- `create`: Create work;\n retain the initial description.\n- `review`: Review it.\n## How to get to it\nNot a capability.\n");
write("maps/feature-map/goals.md", "# Goals\n## Sub-features\n- `hierarchy`: Link goals.\n## Gotchas\n");
write("maps/feature-map/apps.md", "# Apps\n## Sub-features\n- `connect`: Connect an app.\n## Gotchas\n");
write("ui/storybook/.storybook/preview.tsx", "export default { parameters: { options: { storySort: { order: [],\n}, unrelated: true } } };\n");
return { root, write };
}
test("feature index preserves recipe names, map order, and multiline capability descriptions", t => {
const { root } = fixture(t);
const index = featureIndex(root);
assert.deepEqual(index.sections.map(section => section.title), ["Work", "Connections"]);
assert.deepEqual(index.sections.flatMap(section => section.features.map(feature => feature.id)), ["tasks", "goals", "apps"]);
assert.deepEqual(index.sections[0].features[0].capabilities, [
{ id: "tasks/create", label: "Create", description: "Create work; retain the initial description." },
{ id: "tasks/review", label: "Review", description: "Review it." },
]);
assert.deepEqual(featureOrder(index), ["Overview", "Work", ["Tasks", ["Overview", "*"], "Goals", ["Overview", "*"]], "Connections", ["Apps", ["Overview", "*"]], "Foundations"]);
});
test("every feature gets a stable overview, including features with no authored stories", t => {
const { root } = fixture(t);
const index = featureIndex(root);
const outputs = featureArtifacts(index);
const file = "ui/storybook/generated/features/goals.stories.tsx";
assert.match(outputs.get(file), /id: "feature-map-goals"/);
assert.match(outputs.get(file), /title: "Work\/Goals\/Overview"/);
index.sections[0].features[1].title = "Work alignment";
assert.match(featureArtifacts(index).get(file), /id: "feature-map-goals"/);
assert.match(featureArtifacts(index).get(file), /title: "Work\/Work alignment\/Overview"/);
index.sections[0].features[1].title = "CLI/API and local worktrees";
assert.match(featureArtifacts(index).get(file), /title: "Work\/CLI, API and local worktrees\/Overview"/);
assert.ok(featureOrder(index)[2].includes("CLI, API and local worktrees"));
});
test("generated guides detect upstream drift, local edits, missing files, and unknown files", t => {
const { root, write } = fixture(t);
const index = featureIndex(root);
assert.equal(reconcileFeatureArtifacts(root, index).length, 5);
assert.deepEqual(reconcileFeatureArtifacts(root, index, true), []);
assert.deepEqual(reconcileFeatureArtifacts(root, index), []);
write("ui/storybook/generated/features/goals.stories.tsx", "export default {};");
assert.match(reconcileFeatureArtifacts(root, index).join("\n"), /goals.stories.tsx: generated feature index is stale/);
reconcileFeatureArtifacts(root, index, true);
index.sections.reverse();
assert.match(reconcileFeatureArtifacts(root, index).join("\n"), /preview.tsx: generated sidebar order is stale/);
assert.match(readFileSync(path.join(root, "ui/storybook/.storybook/preview.tsx"), "utf8"), /unrelated: true/);
write("ui/storybook/generated/features/orphan.stories.tsx", "keep this visible");
assert.match(reconcileFeatureArtifacts(root, index, true).join("\n"), /Unexpected generated feature file/);
assert.equal(readFileSync(path.join(root, "ui/storybook/generated/features/orphan.stories.tsx"), "utf8"), "keep this visible");
});
test("malformed or omitted map entries fail instead of silently hiding a feature", t => {
const { root, write } = fixture(t);
write("maps/feature-map/README.md", "### Work\n| [Tasks](./tasks.md) | Tasks |\n");
assert.throws(() => featureIndex(root), /missing from index/);
write("maps/feature-map/README.md", "### Work\n| [Tasks](tasks.md) | Tasks |\n");
assert.throws(() => featureIndex(root), /Unrecognized feature-map row/);
});