mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
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:
52 files changed
+2943
-782
No files matched your search
@@ -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.`);
|
||||
}
|
||||
@@ -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"}');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user