Files
PaperClipAI/scripts/storybook-catalog.mjs
T
DottaandPaperclip 5e8e992df2 fix(storybook): reconcile capability associations and map omissions (#16154)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The feature map describes user capabilities and verification
recipes.
> - Storybook holds isolated examples of those capabilities.
> - Missing catalog links can hide existing examples.
> - Some stories also expose shipped capabilities absent from the map.
> - This pull request adds verified source associations and three paired
map entries.
> - Readers can find existing examples without changing their homes or
URLs.

## Linked Issues or Issue Description

Refs #16052, #16049 and #16046.

**What existing behavior does this improve?**

Storybook capability lookup and the feature/agent access maps.

**Current behavior**

Seventy capabilities have no example link. Some matching stories already
exist. Primary-agent preferences and company decision/fast-response
services appear only under broad neighboring capabilities.

**Proposed behavior**

Add source-supported links across 34 suites. Keep shared foundations in
place while cross-listing them. Add primary-agent, decision-model and
fast-response capabilities to both upstream maps. Retain explicit
simulation and partial-example limits.

**Reason and benefit**

The catalog distinguishes missing associations from missing examples.
The upstream map describes three existing product capabilities more
precisely.

**Breaking changes**

None to product behavior, story exports, folders, sidebar titles or
URLs. A shared foundation can declare a reasoned `home: "foundations"`
override while referencing related capabilities.

## What Changed

- Add 55 references across 34 suites. Replace primary-agent suites'
reporting-line association with their own capability.
- Add three source-reviewed capabilities and their API/actor boundaries
to paired maps.
- Preserve the shared Foundations home when cross-listing developer
design examples. Add a validation regression test.
- Record which exports demonstrate each association and what remains
simulated or unverified.
- Regenerate the catalog and capability data. Preserve all original 223
authored suites and 2,210 story IDs. Retain the two skill-removal
stories added by current master, for 2,212 authored stories in total.
- Reduce empty example lookups from 70 of 171 capabilities to 59 of 174.
Links do not establish complete capability coverage.

## Verification

- Passed: 27 catalog/generator/map tests, catalog validation, map
structural checks, token gates and skill validation.
- Passed: source inventory comparison preserves all authored files,
titles, meta IDs and story IDs.
- Passed: repository typecheck/build and Storybook build. Final built
index has 2,248 entries and preserves all previous entries, including
the two skills stories added by current master.
- The broad local repository test run was interrupted before completion
to resolve a new master conflict. A complete local suite pass is not
claimed; fresh CI passed on the reconciled branch.
- Browser: Design links to preserved Foundations stories;
company-service sections link settings/history/journey suites; decision
SetupToHistory passed 12 interaction steps; primary-agent ReplacePrimary
passed four steps. No console errors in selected checks.
- Greptile: 5/5 on final commit
`805a8af62abe589907e7cc6ca1b415ce1e0cc3c0`, with no findings or
unresolved threads.
- Final-revision CI passed all 57 checks/statuses, including all eight
browser shards, server and Runner tests, typecheck and build. The
unchanged Cloud-auth mocked-response race seen on a superseded revision
did not recur on the final commit. No test expectations were changed.
- New agent-access evidence is source-only. Live provider and
agent-credential verification is not established.

## Risks

- Suite-level associations can represent only selected states. Notes
retain those limits and existing review classifications.
- In-memory connection drafts do not prove reload persistence.
Resume-failure UI does not prove successful continuation. No-op budget
controls do not prove relief or resumption.
- Three map capabilities document existing behavior. No permission, API,
provider or production component changes.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code execution and source
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>
2026-10-13 08:11:15 -05:00

172 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
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), "..");
const storyRoot = "ui/storybook/stories";
const catalogPath = "maps/storybook-map/catalog.json";
const indexPath = "maps/storybook-map/INDEX.md";
const statuses = ["current", "proposed", "historical", "mixed", "unreviewed"];
const implementations = ["production", "prototype", "mixed", "guide"];
const boundaries = ["fixtures", "fixture-server", "static"];
function files(root, dir) {
return readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
const name = `${dir}/${entry.name}`;
return entry.isDirectory() ? files(root, name) : [name];
}).sort();
}
export function fingerprint(root, sources) {
const hash = createHash("sha256");
for (const file of [...new Set(sources)].sort()) {
hash.update(file).update("\0").update(readFileSync(path.join(root, file))).update("\0");
}
return hash.digest("hex");
}
export function capabilities(root) {
const result = new Map();
for (const file of files(root, "maps/feature-map").filter(file => file.endsWith(".md") && !file.endsWith("README.md"))) {
const text = readFileSync(path.join(root, file), "utf8");
const area = path.basename(file, ".md");
const section = text.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0] ?? "";
for (const match of section.matchAll(/^- `([^`]+)`:/gm)) result.set(`${area}/${match[1]}`, file);
}
return result;
}
export function featureNavigation(root) {
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) {
const require = createRequire(path.join(root, "ui/package.json"));
const { loadCsf } = require("storybook/internal/csf-tools");
return files(root, storyRoot).filter(file => /\.stories\.(ts|tsx|mdx)$/.test(file)).map(file => {
const csf = loadCsf(readFileSync(path.join(root, file), "utf8"), { fileName: file, makeTitle: title => title }).parse();
return { file, title: csf.meta.title, metaId: csf.meta.id, ids: csf.stories.map(story => story.id), plays: csf.stories.filter(story => story.__stats?.play).length };
});
}
function storyHome(entry) {
return entry.home ?? entry.capabilities[0]?.split("/")[0] ?? (entry.implementation === "guide" ? "overview" : "foundations");
}
export function validateCatalog(root, catalog, stories) {
const errors = [];
const refs = capabilities(root);
const navigation = featureNavigation(root);
const byFile = new Map(stories.map(story => [story.file, story]));
const seen = new Set();
const local = file => typeof file === "string" && !path.isAbsolute(file) && !file.split("/").includes("..") && existsSync(path.join(root, file));
if (catalog.version !== 1 || !Array.isArray(catalog.entries)) return ["Unsupported catalog version or missing entries"];
function review(label, sources, hash) {
if (!Array.isArray(sources) || !sources.length || sources.some(file => !local(file))) {
errors.push(`${label}: missing/invalid review sources`);
} else if (fingerprint(root, sources) !== hash) errors.push(`${label}: source changed; review before updating its fingerprint`);
}
review("Shared Storybook setup", catalog.sharedReview?.sources, catalog.sharedReview?.fingerprint);
for (const entry of catalog.entries) {
const label = entry.file;
if (seen.has(label)) errors.push(`${label}: duplicate catalog entry`);
seen.add(label);
if (!byFile.has(label)) errors.push(`${label}: missing story file`);
if (!statuses.includes(entry.status)) errors.push(`${label}: invalid product status`);
if (!implementations.includes(entry.implementation)) errors.push(`${label}: invalid implementation`);
if (!boundaries.includes(entry.boundary)) errors.push(`${label}: invalid data boundary`);
if (!entry.purpose?.trim() || !entry.notes?.trim()) errors.push(`${label}: purpose and review notes are required`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.reviewedAt ?? "")) errors.push(`${label}: missing review date`);
if (!Array.isArray(entry.capabilities)) { errors.push(`${label}: missing capabilities`); continue; }
if (entry.home !== undefined && entry.home !== "foundations") errors.push(`${label}: only shared foundations may override the capability home`);
if (entry.home === "foundations" && !entry.exception?.trim()) errors.push(`${label}: foundation home needs an explicit reason`);
for (const ref of entry.capabilities) if (!refs.has(ref)) errors.push(`${label}: unknown feature capability ${ref}`);
if (!entry.capabilities.length && !entry.exception?.trim()) errors.push(`${label}: unmapped story needs an explicit reason`);
if (entry.capabilities.length !== new Set(entry.capabilities).size) errors.push(`${label}: duplicate capability references`);
const area = storyHome(entry);
const prefix = navigation.get(area) ?? ({ overview: "Overview", foundations: "Foundations" })[area];
const story = byFile.get(label);
if (!label.startsWith(`${storyRoot}/${area}/`)) errors.push(`${label}: story must live in its primary feature folder ${area}`);
if (!prefix || !entry.sidebarTitle?.startsWith(`${prefix}/`) || story?.title !== entry.sidebarTitle) errors.push(`${label}: sidebar title must match its feature-map home`);
if (!entry.stableId || story?.metaId !== entry.stableId) errors.push(`${label}: explicit meta ID must preserve the catalog stableId`);
if (entry.replacement && !byFile.has(entry.replacement)) errors.push(`${label}: replacement story is missing`);
if (!entry.sources?.includes(label)) errors.push(`${label}: review must include its story source`);
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), ...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, index) {
const byFile = new Map(stories.map(story => [story.file, story]));
const groups = new Map();
for (const entry of catalog.entries) {
const home = storyHome(entry);
const area = ["foundations", "overview"].includes(home) ? "foundations-and-guides" : home;
if (!groups.has(area)) groups.set(area, []);
groups.get(area).push(entry);
}
const lines = ["# Storybook by feature", "", "<!-- Generated by node scripts/storybook-catalog.mjs --write. Edit catalog.json. -->", "",
"[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.", ""];
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))) {
const story = byFile.get(entry.file);
lines.push(`| [${cell(story.title)}](../../${entry.file}) | ${entry.capabilities.map(ref => `\`${ref}\``).join(", ") || cell(entry.exception)} | ${entry.status} / ${entry.implementation} / ${entry.boundary} | ${story.ids.length} / ${story.plays} | ${cell(entry.notes)} |`);
}
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 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 = 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.`);
}