@reqvire-org/ads 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +12 -4
  2. package/dist/behaviors.mjs +157 -0
  3. package/dist/bootstrap-ds/ADS.md +7 -0
  4. package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  5. package/dist/boundaries.mjs +21 -23
  6. package/dist/check.mjs +25 -13
  7. package/dist/cli.mjs +18 -0
  8. package/dist/config.mjs +19 -3
  9. package/dist/css-values.mjs +26 -0
  10. package/dist/index.mjs +2 -0
  11. package/dist/init.mjs +1 -1
  12. package/dist/mcp/build-status.mjs +10 -3
  13. package/dist/mcp/catalog.mjs +11 -8
  14. package/dist/mcp/context.mjs +2 -2
  15. package/dist/mcp/mutations.mjs +11 -1
  16. package/dist/mcp/preview-owner-guard.mjs +29 -0
  17. package/dist/mcp/preview-watch.mjs +28 -4
  18. package/dist/mcp/preview-widget.bundle.js +2 -2
  19. package/dist/mcp/prompts.mjs +3 -3
  20. package/dist/mcp/server.mjs +61 -24
  21. package/dist/mcp/showcase-host.mjs +9 -5
  22. package/dist/oxlint.mjs +1 -1
  23. package/dist/public-api.mjs +39 -0
  24. package/dist/scenarios-compiler.mjs +17 -2
  25. package/dist/scenarios.mjs +27 -39
  26. package/dist/skills/README.md +3 -3
  27. package/dist/skills/ads-break-ui/SKILL.md +48 -4
  28. package/dist/skills/ads-designer/SKILL.md +49 -5
  29. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +7 -0
  30. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  31. package/dist/skills/ads-designer/references/cli.md +1 -1
  32. package/dist/skills/ads-designer/references/design-craft.md +1 -1
  33. package/dist/skills/ads-import/SKILL.md +48 -4
  34. package/dist/skills/ads-import/references/cli.md +1 -1
  35. package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +54 -10
  36. package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
  37. package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
  38. package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +27 -0
  39. package/dist/skills/ads-motion/SKILL.md +48 -4
  40. package/dist/style-boundaries.mjs +59 -0
  41. package/dist/tokens.mjs +3 -2
  42. package/dist/units.mjs +56 -9
  43. package/dist/verification/browser.mjs +27 -0
  44. package/dist/verification/playwright-reporter.cjs +20 -0
  45. package/dist/verification.mjs +214 -0
  46. package/docs/ADS.md +32 -3
  47. package/docs/ARCHITECTURE.md +45 -4
  48. package/docs/BEHAVIORS.md +230 -0
  49. package/docs/CHANGELOG.md +48 -0
  50. package/docs/GUIDE.md +25 -1
  51. package/docs/MCP.md +76 -7
  52. package/package.json +4 -2
  53. package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
  54. /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
  55. /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Server, ProtocolError, INVALID_PARAMS, ResourceNotFoundError } from "@modelcontextprotocol/server";
4
- import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
+ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod";
6
6
  import { checkProject } from "../check.mjs";
7
7
  import { loadConfig, sourceFiles } from "../config.mjs";
8
+ import { behaviorStatus, createVerificationController, BEHAVIOR_URI } from "../verification.mjs";
8
9
  import { inspectTokens } from "../tokens.mjs";
9
10
  import { inspectScenarios } from "../scenarios.mjs";
10
11
  import { startShowcaseHost } from "./showcase-host.mjs";
@@ -33,6 +34,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
33
34
  const cache = inspection ?? {};
34
35
  const skills = cache.skills ??= createSkillStore(skillDirectory);
35
36
  const edits = cache.edits ??= createMutationController(root);
37
+ const verification = cache.verification ??= createVerificationController(root);
36
38
  let options = loadConfig(root);
37
39
  const signature = `${root}:${JSON.stringify(options.config)}`;
38
40
  if (cache.signature !== signature) {
@@ -43,7 +45,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
43
45
  const project = () => { const context = projectContext(options); context.showcase.url = getShowcaseUrl(); return context; };
44
46
  const subscribed = new Set();
45
47
  const server = new Server({ name: "reqvire-ads", version: pkg.version }, {
46
- instructions: `${serverInstructions} ${mutations ? "Guarded editing is enabled: discover one owner with ads_units, read relevant ranges with ads_read_file and send only a Git unified diff to ads_apply_patch with that unit ID. Include its own scenarios/fixtures and required re-exports together, never unrelated units. Token manifests list companionFiles for canonical foundation import changes required by token files created/deleted in the same patch. The server validates architecture, registered Oxlint and project checks before writing. Fix rejected patches in their owner. Intentional settings, project guards and builders use the configuration unit; mandatory ADS gates still run." : "Source mutation tools are disabled; source edits use the client's editor/filesystem tools."}`,
48
+ instructions: `${serverInstructions} ${mutations ? "Guarded editing is enabled: discover one owner with ads_units, read relevant ranges with ads_read_file and send only a Git unified diff to ads_apply_patch with that unit ID. Include its own scenarios/fixtures and required re-exports together, never unrelated units. Manifests list companionFiles: required canonical token imports, or only the selected runtime owner's style-boundary entries in ds.config.json alongside its source. Unused style permissions fail validation. The server validates architecture, registered Oxlint and project checks before writing. Fix rejected patches in their owner. Intentional settings, project guards and builders use the configuration unit; mandatory ADS gates still run." : "Source mutation tools are disabled; source edits use the client's editor/filesystem tools."}`,
47
49
  capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true }, prompts: {},
48
50
  extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [APP_MIME] }, ...(era === "modern" ? { [SKILLS_EXTENSION]: { directoryRead: true } } : {}) } },
49
51
  });
@@ -77,11 +79,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
77
79
  const skillEntries = new Set(skills.skills.map((skill) => skill.uri));
78
80
  return [
79
81
  { uri: GUIDE_URI, name: "Start here: design-system workflow", description: "Read first for UI/ADS tasks, especially when only resource access is exposed. Explains the exact workspace, tool sequence, skill selection, previews, project commands and fallback.", mimeType: "text/markdown" },
82
+ { uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
80
83
  { uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
81
84
  { uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
82
85
  { uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
83
86
  { uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
84
87
  { uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
88
+ { uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
85
89
  { uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
86
90
  { uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
87
91
  { uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
@@ -98,18 +102,20 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
98
102
  const context = project();
99
103
  return { projectRoot: root, workflow: workflow([...previews(), ...scenarioApps()]), project: context,
100
104
  package: pkg.name, version: pkg.version, dsDir: options.config.dsDir, publicAlias: options.config.publicAlias,
101
- components: getCatalog().filter((item) => item.kind === "components").length,
102
- patterns: getCatalog().filter((item) => item.kind === "patterns").length,
105
+ components: getCatalog().filter((item) => item.kind === "components" && item.apiKind === "ui").length,
106
+ patterns: getCatalog().filter((item) => item.kind === "patterns" && item.apiKind === "ui").length,
103
107
  tokenCount: inspectTokens(root).tokens.length, showcaseUrl: getShowcaseUrl(),
104
108
  previewWatch: previewWatch?.status() ?? { state: "disabled" },
105
109
  mutations: mutations ? edits.status() : { enabled: false },
106
110
  build: summarizeBuild(builds()),
107
111
  previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
108
112
  scenarios: { count: getRegistry().scenarios.length, diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios`, tool: "ads_show_scenario" },
113
+ behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", ...verification.status() },
109
114
  experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
110
115
  }
111
116
  async function read(uri, signal) {
112
117
  if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
118
+ if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
113
119
  if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
114
120
  let value;
115
121
  if (uri === `${PROJECT_URI}/status`) value = status();
@@ -118,6 +124,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
118
124
  else if (uri === `${PROJECT_URI}/catalog`) value = { items: getCatalog() };
119
125
  else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
120
126
  else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
127
+ else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
121
128
  else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
122
129
  value = readPreviewManifest(options);
123
130
  if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
@@ -153,13 +160,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
153
160
  function tools() {
154
161
  return [
155
162
  { name: "ads_status", description: "Start here for UI/ADS work: components, patterns, tokens/themes/branding, layout, behavior, motion and showcase review. Returns the exact project root/public API, workflow, project commands, editing mode and build diagnostics. When mutations.enabled, use guarded ads_apply_patch for ADS edits; otherwise use local editing tools.", schema: empty, call: () => json(status()) },
156
- { name: "ads_catalog", description: "Before creating/changing UI, find existing public components/patterns with a small query/limit. Returns import alias and exact source/guide URIs so you can reuse or edit the owner. Read ads_status first for workspace/workflow. Metadata only; does not render UI.", schema: query.extend({ kind: z.enum(["components", "patterns"]).optional() }), call: ({ query: search = "", limit, kind }) => {
163
+ { name: "ads_catalog", description: "Before creating/changing UI or behavior, find existing public components/patterns/controllers and hooks with a small query/limit. Returns import alias and exact source/guide URIs so you can reuse or edit the owner. Read ads_status first for workspace/workflow. Metadata only; does not render UI.", schema: query.extend({ kind: z.enum(["components", "patterns", "controllers"]).optional() }), call: ({ query: search = "", limit, kind }) => {
157
164
  const matches = getCatalog().filter((item) => (!kind || item.kind === kind) && item.name.toLowerCase().includes(search.toLowerCase()));
158
165
  const units = edits.units();
159
166
  return json({ total: matches.length, items: matches.slice(0, limit).map(item => ({ ...item, unit: units.find(unit => unit.files.includes(item.file))?.id ?? null })), resourceUri: `${PROJECT_URI}/catalog` });
160
167
  } },
161
- { name: "ads_scenarios", description: "Find an independently mountable token, component, pattern or composition preview. IDs derive from source filenames; covers lists public UI and tokens lists the real token inputs. Use ads_show_scenario with an exact returned id. Showcase and MCP use this same registry.", schema: query.extend({ kind: z.enum(["tokens", "components", "patterns", "composition"]).optional(), offset: z.number().int().min(0).default(0) }), call: ({ query: search = "", limit, kind, offset }) => {
162
- const matches = getRegistry().scenarios.filter(item => (!kind || item.kind === kind) && `${item.id} ${item.title} ${item.covers.join(" ")} ${item.tokens.join(" ")}`.toLowerCase().includes(search.toLowerCase()));
168
+ { name: "ads_scenarios", description: "Find an independently mountable token, component, pattern or composition preview. IDs derive from source filenames; covers lists rendered public UI, exercises lists behavioral APIs and tokens lists the real token inputs. Use ads_show_scenario with an exact returned id. Showcase and MCP use this same registry.", schema: query.extend({ kind: z.enum(["tokens", "components", "patterns", "composition"]).optional(), offset: z.number().int().min(0).default(0) }), call: ({ query: search = "", limit, kind, offset }) => {
169
+ const matches = getRegistry().scenarios.filter(item => (!kind || item.kind === kind) && `${item.id} ${item.title} ${item.covers.join(" ")} ${(item.exercises ?? []).join(" ")} ${item.tokens.join(" ")}`.toLowerCase().includes(search.toLowerCase()));
163
170
  const units = edits.units();
164
171
  return json({ total: matches.length, scenarios: matches.slice(offset, offset + limit).map(item => ({ ...item, unit: units.find(unit => unit.files.includes(item.file))?.id ?? null })), ...(offset + limit < matches.length ? { nextOffset: offset + limit } : {}), diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios` });
165
172
  } },
@@ -181,11 +188,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
181
188
  const matches = inventory.tokens.filter((token) => token.name.toLowerCase().includes(search.toLowerCase()));
182
189
  return json({ total: matches.length, tokens: matches.slice(0, limit), categories: tokenScenarios(), diagnosticCount: inventory.diagnostics.length, resourceUri: `${PROJECT_URI}/tokens` });
183
190
  } },
184
- { name: "ads_check", description: "Run read-only core ADS architecture checks. Project Oxlint, typecheck, build and browser tests remain separate.", schema: z.object({ limit: z.number().int().min(1).max(50).default(20) }).strict(), call: ({ limit }) => {
191
+ { name: "ads_check", description: "Run read-only core ADS architecture checks; returns blocking diagnostics separately from nonblocking review advisories. Project Oxlint, typecheck, build and browser tests remain separate.", schema: z.object({ limit: z.number().int().min(1).max(50).default(20) }).strict(), call: ({ limit }) => {
185
192
  const result = checkProject(root);
186
- return json({ ok: result.ok, total: result.diagnostics.length, diagnostics: result.diagnostics.slice(0, limit), resourceUri: `${PROJECT_URI}/checks` });
193
+ return json({ ok: result.ok, total: result.diagnostics.length, diagnostics: result.diagnostics.slice(0, limit), advisoryCount: result.advisories?.length ?? 0, advisories: (result.advisories ?? []).slice(0, limit), resourceUri: `${PROJECT_URI}/checks` });
187
194
  } },
188
- { name: "ads_skills", description: "Select one ADS workflow after ads_status: ads-designer for building/refining/debugging UI; ads-assimilate for a library reference; ads-import for another project's ADS; ads-motion for animation; ads-break-ui for stress testing. Then read that workflow with ads_read_skill. Listing does not activate a native skill.", schema: empty, call: () => json({ skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, ...frontmatter })) }) },
195
+ { name: "ads_skills", description: "Select one ADS workflow after ads_status: ads-designer for building/refining/debugging UI; ads-migrate for a library reference; ads-import for another project's ADS; ads-motion for animation; ads-break-ui for stress testing. Then read that workflow with ads_read_skill. Listing does not activate a native skill.", schema: empty, call: () => json({ skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, ...frontmatter })) }) },
189
196
  { name: "ads_read_skill", description: "Read a selected skill or relative supporting text file in bounded chunks. This is explicit instruction access, not automatic native skill activation/install.", schema: z.object({ name: z.string().max(64), file: z.string().max(300).default("SKILL.md"), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(8000).default(6000) }).strict(), call: ({ name, file, offset, limit }) => {
190
197
  const skill = skills.skills.find((entry) => entry.frontmatter.name === name);
191
198
  if (!skill) throw new Error("Unknown ADS skill name.");
@@ -214,19 +221,36 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
214
221
  }
215
222
  return json({ widget: { resourceUri: TOKENS_APP_URI }, category: null, tokenCount: inspectTokens(root).tokens.length, dataUri: `${PROJECT_URI}/tokens`, showcaseUrl: getShowcaseUrl() });
216
223
  } },
217
- { name: "ads_units", description: "Discover editable ADS units and their files: components/patterns with colocated previews; composition; tokens; foundation shared files; documentation; assets/brand, assets/fonts and asset groups; configuration with ds.config.json, package/build/lint configuration and registered project guard/compiler sources. List summaries with query, or pass an exact unit ID for its manifest. Token manifests include companionFiles with the foundation entry path and allowed import changes. Imported peers stay separate. New owners follow the existing colocation convention.", schema: query.extend({ unit: z.string().min(1).max(256).optional() }), call: ({ unit, query: search = "", limit }) => {
224
+ { name: "ads_behaviors", description: "Discover owner-local behavioral guarantees for public hooks/APIs or private controllers, their scenario demonstrations, registered checks/profiles and fresh or stale execution evidence. Optional exercises links behavioral APIs to scenarios; covers remains rendered UI. Metadata and reachable calls do not prove a guarantee passed. Use ads_verify for selected registered checks.", schema: query.extend({ unit: z.string().max(256).optional(), api: z.string().max(200).optional() }), call: ({ query: search = "", limit, unit, api }) => {
225
+ const inventory = behaviorStatus(options);
226
+ const matches = inventory.contracts.filter(item => (!unit || item.unit === unit) && (!api || Object.values(item.subjects).some(subject => subject.api === api)) && JSON.stringify(item).toLowerCase().includes(search.toLowerCase()));
227
+ return json({ total: matches.length, contracts: matches.slice(0, limit).map(item => {
228
+ if (unit) return item;
229
+ const apis = Object.values(item.subjects).filter(subject => subject.api).map(subject => subject.api);
230
+ const demonstrations = [...new Set(Object.values(item.demonstrations).flat())];
231
+ return { unit: item.unit, file: item.file, apis: apis.slice(0, 20), apiCount: apis.length,
232
+ guarantees: item.guarantees.slice(0, 5).map(guarantee => ({ id: guarantee.id, result: guarantee.result })), guaranteeCount: item.guarantees.length,
233
+ demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
234
+ }), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
235
+ } },
236
+ { name: "ads_verify", description: "Run one owner's registered development behavior checks using its configured runner/profile. Executes trusted project commands with bounded output, cancellation and process-tree cleanup; installs nothing. Browser profiles use an isolated managed showcase/MCP App. React StrictMode requires a project development harness, not a production widget. Missing/skipped cases, stale inputs and process exit alone cannot produce passing evidence. Discover exact unit/check IDs with ads_behaviors first.", schema: z.object({ unit: z.string().min(1).max(256), checks: z.array(z.string().min(1).max(100)).min(1).max(100).optional() }).strict(), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, call: async (args, signal) => {
237
+ const result = await verification.run(args, signal);
238
+ await refresh();
239
+ return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
240
+ } },
241
+ { name: "ads_units", description: "Discover editable ADS units and their files: components/patterns with colocated previews; shared controllers; composition; tokens; foundation shared files; documentation; assets/brand, assets/fonts and asset groups; configuration with ds.config.json, package/build/lint configuration and registered project guard/compiler sources. List summaries with query, or pass an exact unit ID for its manifest. Manifests include companionFiles with permitted shared-entry or owner-only style-boundary bookkeeping; preserve peer permissions and unrelated configuration. Imported peers stay separate. New owners follow the existing colocation convention.", schema: query.extend({ unit: z.string().min(1).max(256).optional() }), call: ({ unit, query: search = "", limit }) => {
218
242
  const matches = edits.units().filter(item => unit ? item.id === unit : item.id.toLowerCase().includes(search.toLowerCase()));
219
243
  return json({ total: matches.length, units: matches.slice(0, limit).map(item => ({ id: item.id, kind: item.kind, fileCount: item.files.length, ...(unit ? { files: item.files.slice(0, 200), filesTruncated: item.files.length > 200, ...(item.companionFiles ? { companionFiles: item.companionFiles } : {}) } : {}) })) });
220
244
  } },
221
245
  { name: "ads_read_file", description: "Read a bounded range of ADS or registered configuration/extension source. Returns unit, full-file digest, startLine and nextOffset; default/max is 12000 UTF-16 characters. Binary assets return byte count/digest and no encoded content. Paths come from ads_units or discovery. Missing new paths return digest null. Read only relevant context before preparing a Git patch.", schema: z.object({ path: z.string().min(1).max(500), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(mutationLimits.readChars).default(mutationLimits.readChars) }).strict(), call: ({ path: file, ...range }) => json(edits.read(file, range)) },
222
- ...(mutations ? [{ name: "ads_apply_patch", description: "Apply one Git diff to one ADS unit from ads_units. Pass exactly one of patch (inline) or patchFile (a regular .patch/.diff under .ds/patches/, for large/binary diffs without model-context bytes). Text uses --- a/path, +++ b/path and exact @@ hunks; /dev/null creates/deletes. Git binary patches require git diff --binary --full-index and installed Git. A unit owns its implementation, previews and fixtures; imported peers are separate. Foundation, documentation, assets and configuration are editable too. Configuration changes may register/edit project guards and builders; the proposed schema, mandatory core/Oxlint and candidate project checks must pass. Only selected-unit public re-exports may change; preserve unrelated statements including export {};. A tokens patch may include its companionFiles foundation entry only to add/remove canonical imports required by token files created/deleted in this patch, preserving other imports/comments and canonical order. Set dryRun true to validate without writing. Fix rejected patches and inspect current previews after acceptance.",
246
+ ...(mutations ? [{ name: "ads_apply_patch", description: "Apply one Git diff to one ADS unit from ads_units. Pass exactly one of patch (inline) or patchFile (a regular .patch/.diff under .ds/patches/, for large/binary diffs without model-context bytes). Text uses --- a/path, +++ b/path and exact @@ hunks; /dev/null creates/deletes. Git binary patches require git diff --binary --full-index and installed Git. A unit owns its implementation, previews and fixtures; imported peers are separate. Foundation, documentation, assets and configuration are editable too. Configuration changes may register/edit project guards and builders; the proposed schema, mandatory core/Oxlint and candidate project checks must pass. Only selected-unit public re-exports may change; preserve unrelated statements. A real selected-unit re-export may remove a redundant export {}; placeholder. A tokens patch may include its companionFiles foundation entry only to add/remove canonical imports required by token files created/deleted in this patch, preserving other imports/comments and canonical order. A runtime owner patch may also include its ds.config.json companion solely for that owner's inlineStyleFiles/styleWrites entries alongside its source. Unused permissions fail validation. Set dryRun true to validate without writing. Fix rejected patches and inspect current previews after acceptance.",
223
247
  schema: z.object({ unit: z.string().min(1).max(256), dryRun: z.boolean(), patch: z.string().min(1).max(mutationLimits.patchBytes).optional(), patchFile: z.string().min(1).max(500).optional() }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile."),
224
248
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
225
249
  call: async (args, signal) => {
226
250
  const result = await edits.apply(args, signal);
227
251
  if (result.applied) {
228
252
  try {
229
- if (result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
253
+ if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
230
254
  await refresh(true);
231
255
  }
232
256
  catch (error) { result.warnings = [...(result.warnings ?? []), { rule: "mutation-notification", message: `Changes were applied, but preview refresh failed: ${error.message.slice(0, 2000)}. Recheck ads_status and reopen the scenario.` }]; }
@@ -295,7 +319,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
295
319
  try { const stat = statSync(file); return `${file}:${stat.mtimeMs}:${stat.size}`; } catch { return `${file}:missing`; }
296
320
  }).join("\n");
297
321
  }
298
- const fingerprint = () => fileFingerprint([...sourceFiles(options.dsRoot), ...getDocuments().map((doc) => doc.file), path.join(root, "ds.config.json"), path.join(root, "package.json")]);
322
+ const fingerprint = () => fileFingerprint([...sourceFiles(options.dsRoot), ...getDocuments().map((doc) => doc.file), path.join(root, "ds.config.json"), path.join(root, "package.json"), path.join(root, ".ds/behavior-results.json")]);
299
323
  const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, app.file)));
300
324
  const assetFingerprint = () => resourceAssets().map(asset => asset.uri).sort().join("\n");
301
325
  let previous = fingerprint();
@@ -330,7 +354,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
330
354
  previousAssets = assetFingerprint();
331
355
  previousBuild = JSON.stringify(builds());
332
356
  const targets = filesChanged ? era === "modern" ? resources().map((entry) => entry.uri).filter((uri) => !skills.files.has(uri)) : [...subscribed]
333
- : [BUILD_URI, `${PROJECT_URI}/status`, ...(options.config.mcp.previewManifest ? [PREVIEW_MANIFEST_URI] : []), ...(artifactsChanged ? allApps().map(app => appUri(app.id)) : [])].filter((uri) => era === "modern" || subscribed.has(uri));
357
+ : [BUILD_URI, BEHAVIOR_URI, `${PROJECT_URI}/status`, ...(options.config.mcp.previewManifest ? [PREVIEW_MANIFEST_URI] : []), ...(artifactsChanged ? allApps().map(app => appUri(app.id)) : [])].filter((uri) => era === "modern" || subscribed.has(uri));
334
358
  for (const uri of targets) { if (notifications) notifications.resourceUpdated(uri); else await server.sendResourceUpdated({ uri }); }
335
359
  return true;
336
360
  }
@@ -338,7 +362,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
338
362
  timer?.unref();
339
363
  server.oninitialized = () => previewWatch?.start();
340
364
  server.onclose = () => { if (timer) clearInterval(timer); };
341
- return { server, refresh, async close() { if (timer) clearInterval(timer); await server.close(); } };
365
+ return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) await verification.close(); await server.close(); } };
342
366
  }
343
367
 
344
368
  export async function startAdsMcp(directory = process.cwd(), { stdio = true, showcase = true, port = 0, mutations = false } = {}) {
@@ -353,24 +377,37 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
353
377
  { port, observe: notifications => createAdsMcp(directory, { era: "modern", previewWatch, showcaseUrl, inspection, mutations, notifications }) });
354
378
  process.stderr.write(`ADS showcase: ${host.url}\n`);
355
379
  }
356
- const handle = stdio ? serveStdio(({ era }) => createAdsMcp(directory, { era, previewWatch, showcaseUrl, inspection, mutations }).server, { legacy: "serve", onerror: (error) => process.stderr.write(`ads mcp: ${error.message}\n`) }) : null;
380
+ const transport = stdio ? new StdioServerTransport() : null;
381
+ const handle = stdio ? serveStdio(({ era }) => createAdsMcp(directory, { era, previewWatch, showcaseUrl, inspection, mutations }).server, { transport, legacy: "serve", onerror: (error) => process.stderr.write(`ads mcp: ${error.message}\n`) }) : null;
357
382
  let closing;
358
383
  function close() {
359
384
  return closing ??= (async () => {
360
385
  inspection.edits?.cancel();
361
- await host?.close();
362
- await previewWatch.stop();
363
- await handle?.close();
364
- process.stdin.removeListener("end", disconnect);
386
+ // Stop every owned resource even if another close fails or has in-flight RPCs.
387
+ const results = await Promise.allSettled([
388
+ Promise.resolve().then(() => host?.close()),
389
+ previewWatch.stop(),
390
+ Promise.resolve().then(() => inspection.verification?.close()),
391
+ Promise.resolve().then(() => handle?.close()),
392
+ ]);
393
+ for (const event of ["end", "close", "error"]) process.stdin.removeListener(event, disconnect);
365
394
  for (const signal of ["SIGINT", "SIGTERM"]) process.removeListener(signal, disconnect);
366
395
  process.removeListener("exit", forceCleanup);
396
+ const errors = results.filter(result => result.status === "rejected").map(result => result.reason);
397
+ if (errors.length) throw new AggregateError(errors, "ADS shutdown failed");
367
398
  })();
368
399
  }
369
- function disconnect() { void close(); }
400
+ function disconnect() { void close().catch(error => { process.stderr.write(`ads mcp: ${error.message}\n`); process.exitCode = 1; }); }
370
401
  function forceCleanup() { previewWatch.kill(); }
371
402
  // Probe instances have no managed child; a real connection activates it.
372
- // stdin EOF also cleans up the compiler when the host drops the transport.
373
- if (stdio) process.stdin.once("end", disconnect);
403
+ // The SDK also closes its wire on stdout failure. Closing a probe instance
404
+ // must not shut the runtime down, so observe the wire, not server.onclose.
405
+ if (transport) {
406
+ const wireClosed = transport.onclose;
407
+ transport.onclose = () => { try { wireClosed?.(); } finally { disconnect(); } };
408
+ for (const event of ["end", "close", "error"]) process.stdin.once(event, disconnect);
409
+ if (process.stdin.readableEnded || process.stdin.destroyed) disconnect();
410
+ }
374
411
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, disconnect);
375
412
  process.once("exit", forceCleanup);
376
413
  return { close, url: host?.url };
@@ -56,13 +56,17 @@ export async function startShowcaseHost(factory, { port = 0, observe } = {}) {
56
56
  let closing;
57
57
  return { url: `http://127.0.0.1:${web.address().port}/`, close() {
58
58
  return closing ??= (async () => {
59
- for (const request of requests) request.abort();
60
- await observer?.close();
61
- await handler.close();
62
- await sandbox?.close();
59
+ // Close sockets first: live subscriptions and preview requests must not
60
+ // hold shutdown open while protocol instances wait for cancellation.
63
61
  const stopped = new Promise(resolve => web.close(resolve));
62
+ for (const request of requests) request.abort();
64
63
  web.closeAllConnections();
65
- await stopped;
64
+ const results = await Promise.allSettled([stopped,
65
+ Promise.resolve().then(() => observer?.close()),
66
+ handler.close(), sandbox?.close(),
67
+ ]);
68
+ const errors = results.filter(result => result.status === "rejected").map(result => result.reason);
69
+ if (errors.length) throw new AggregateError(errors, "Showcase shutdown failed");
66
70
  })();
67
71
  } };
68
72
  }
package/dist/oxlint.mjs CHANGED
@@ -66,7 +66,7 @@ export default {
66
66
  },
67
67
  },
68
68
  "no-inline-style": {
69
- meta: { type: "problem", schema, messages: { inline: "Move visual policy into the local ADS, or document a narrow exception in inlineStyleFiles." } },
69
+ meta: { type: "problem", schema, messages: { inline: "Own visual styles in component/pattern Linaria. Put demo-only sizing and framing in a public review/layout pattern. Reserve inlineStyleFiles for documented measured geometry; add component props for deliberate product API needs." } },
70
70
  create(context) {
71
71
  const { root, config } = options(context);
72
72
  const allowed = config.inlineStyleFiles.some((file) => path.resolve(root, file) === path.resolve(context.getFilename()));
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { isDevelopmentFile, isInside } from "./config.mjs";
4
+ import { uiOperation } from "./ui-framework.mjs";
5
+
6
+ export function publicApi(program, options) {
7
+ const checker = program.getTypeChecker();
8
+ const entry = program.getSourceFile(path.join(options.dsRoot, options.config.publicEntry));
9
+ const module = entry && checker.getSymbolAtLocation(entry);
10
+ const items = [];
11
+ for (const exported of module ? checker.getExportsOfModule(module) : []) {
12
+ let symbol = exported;
13
+ if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
14
+ const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
15
+ if (!declaration || !(symbol.flags & ts.SymbolFlags.Value)) continue;
16
+ const file = declaration.getSourceFile().fileName;
17
+ if (isDevelopmentFile(file, options)) continue;
18
+ const kind = isInside(path.join(options.dsRoot, "components"), file) ? "components" : isInside(path.join(options.dsRoot, "patterns"), file) ? "patterns" : isInside(path.join(options.dsRoot, "controllers"), file) ? "controllers" : null;
19
+ if (!kind) continue;
20
+ let renderable = false;
21
+ function visit(node) {
22
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) renderable = true;
23
+ if (ts.isCallExpression(node) && ["createElement", "forwardRef", "memo"].includes(uiOperation(node.expression, checker))) renderable = true;
24
+ if (ts.isTaggedTemplateExpression(node)) {
25
+ let tag = node.tag;
26
+ if (ts.isCallExpression(tag)) tag = tag.expression;
27
+ let base = tag;
28
+ while (ts.isPropertyAccessExpression(base)) base = base.expression;
29
+ const symbol = checker.getSymbolAtLocation(base);
30
+ if (symbol?.declarations?.some(item => ts.isImportSpecifier(item) && (item.propertyName ?? item.name).text === "styled" && item.parent.parent.parent.moduleSpecifier.text.startsWith("@linaria/") || ts.isNamespaceImport(item) && item.parent.parent.moduleSpecifier.text.startsWith("@linaria/") && /^\.styled(?:\.|$)/.test(tag.getText().slice(base.getText().length)))) renderable = true;
31
+ }
32
+ ts.forEachChild(node, visit);
33
+ }
34
+ visit(declaration);
35
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
36
+ items.push({ name: exported.name, kind, file, declaration, renderable: kind !== "controllers" && renderable, behavioral: !!(type.getCallSignatures().length || type.getConstructSignatures().length) });
37
+ }
38
+ return items;
39
+ }
@@ -126,6 +126,16 @@ export async function runScenarioCompiler(directory, { watched = false, validate
126
126
  cache.validate(stamp);
127
127
  });
128
128
  }
129
+ async function checkedBuild(stamp, build) {
130
+ const started = generation;
131
+ let checking = true, compiling = true;
132
+ function progress() { if (started === generation) reporter.progress(checking && compiling ? "validation-and-compilation" : checking ? "validation" : compiling ? "compilation" : "publication"); }
133
+ progress();
134
+ return Promise.allSettled([
135
+ gate(stamp).finally(() => { checking = false; progress(); }),
136
+ Promise.resolve().then(build).finally(() => { compiling = false; progress(); }),
137
+ ]);
138
+ }
129
139
  async function compile(entry) {
130
140
  if (buildGraph) return compileGraph();
131
141
  const revision = reporter.begin(entry.id);
@@ -154,16 +164,18 @@ export async function runScenarioCompiler(directory, { watched = false, validate
154
164
  if (path.isAbsolute(file) && existsSync(file)) dependencies.add(file);
155
165
  }
156
166
  } };
157
- const outcomes = await Promise.allSettled([gate(stamp), reuse ? Promise.resolve() : build(entry, options, track)]);
167
+ const outcomes = await checkedBuild(stamp, () => reuse ? undefined : build(entry, options, track));
158
168
  const failure = outcomes.find(outcome => outcome.status === "rejected");
159
169
  if (failure) throw failure.reason;
160
170
  cache.assertCompiler();
161
171
  if (stopped || started !== generation || stamp !== cache.inputs()) return; // Watch queued a newer revision.
162
172
  if (!reuse) cache.record(entry, dependencies);
163
173
  reporter.ready(entry.id, revision);
174
+ reporter.progress("idle");
164
175
  } catch (error) {
165
176
  if (started !== generation) return;
166
177
  reporter.error(entry.id, revision, error);
178
+ reporter.progress("idle");
167
179
  console.error(error);
168
180
  if (!watched) process.exitCode = 1;
169
181
  }
@@ -192,7 +204,7 @@ export async function runScenarioCompiler(directory, { watched = false, validate
192
204
  cache.assertCompiler();
193
205
  let reusable = entries.every(entry => cache.reusable(entry));
194
206
  if (reusable) { try { reusable = Boolean(readPreviewManifest(options, { verify: true })); } catch { reusable = false; } }
195
- const outcomes = await Promise.allSettled([gate(stamp), reusable ? Promise.resolve(null) : buildGraph(entries, options, track)]);
207
+ const outcomes = await checkedBuild(stamp, () => reusable ? null : buildGraph(entries, options, track));
196
208
  const failure = outcomes.find(outcome => outcome.status === "rejected");
197
209
  if (failure) throw failure.reason;
198
210
  cache.assertCompiler();
@@ -209,9 +221,11 @@ export async function runScenarioCompiler(directory, { watched = false, validate
209
221
  }
210
222
  for (const entry of entries) { pending.delete(entry.id); reporter.ready(entry.id, revisions.get(entry.id)); }
211
223
  publishedStamp = stamp;
224
+ reporter.progress("idle");
212
225
  } catch (error) {
213
226
  if (started !== generation) return;
214
227
  for (const entry of entries) { pending.delete(entry.id); reporter.error(entry.id, revisions.get(entry.id), error); }
228
+ reporter.progress("idle");
215
229
  console.error(error);
216
230
  if (!watched) process.exitCode = 1;
217
231
  }
@@ -261,6 +275,7 @@ export async function runScenarioCompiler(directory, { watched = false, validate
261
275
  clearTimeout(timer);
262
276
  timer = setTimeout(() => {
263
277
  ++generation;
278
+ reporter.progress("queued");
264
279
  validation = undefined;
265
280
  const changes = [...changed]; changed.clear();
266
281
  try {
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import ts from "typescript";
4
+ import { publicApi } from "./public-api.mjs";
4
5
  import { uiOperation } from "./ui-framework.mjs";
5
6
  import { isDevelopmentFile, isInside, isScenarioFile, sourceFiles } from "./config.mjs";
6
7
  import { projectCompilerOptions } from "./boundaries.mjs";
@@ -27,46 +28,17 @@ function literal(node, checker, seen = new Set()) {
27
28
  }
28
29
 
29
30
  export function publicUi(program, options) {
30
- const checker = program.getTypeChecker();
31
- const entry = program.getSourceFile(path.join(options.dsRoot, options.config.publicEntry));
32
- const module = entry && checker.getSymbolAtLocation(entry);
33
- const items = [];
34
- for (const exported of module ? checker.getExportsOfModule(module) : []) {
35
- let symbol = exported;
36
- if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
37
- const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
38
- if (!declaration || !(symbol.flags & ts.SymbolFlags.Value)) continue;
39
- const file = declaration.getSourceFile().fileName;
40
- if (isDevelopmentFile(file, options)) continue;
41
- const kind = isInside(path.join(options.dsRoot, "components"), file) ? "components" : isInside(path.join(options.dsRoot, "patterns"), file) ? "patterns" : null;
42
- if (!kind) continue;
43
- let renderable = false;
44
- function visit(node) {
45
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) renderable = true;
46
- if (ts.isCallExpression(node) && ["createElement", "forwardRef", "memo"].includes(uiOperation(node.expression, checker))) renderable = true;
47
- if (ts.isTaggedTemplateExpression(node)) {
48
- let tag = node.tag;
49
- if (ts.isCallExpression(tag)) tag = tag.expression;
50
- let base = tag;
51
- while (ts.isPropertyAccessExpression(base)) base = base.expression;
52
- const symbol = checker.getSymbolAtLocation(base);
53
- if (symbol?.declarations?.some(item => ts.isImportSpecifier(item) && (item.propertyName ?? item.name).text === "styled" && item.parent.parent.parent.moduleSpecifier.text.startsWith("@linaria/") || ts.isNamespaceImport(item) && item.parent.parent.moduleSpecifier.text.startsWith("@linaria/") && /^\.styled(?:\.|$)/.test(tag.getText().slice(base.getText().length)))) renderable = true;
54
- }
55
- ts.forEachChild(node, visit);
56
- }
57
- visit(declaration);
58
- items.push({ name: exported.name, kind, file, declaration, renderable });
59
- }
60
- return items;
31
+ return publicApi(program, options).filter(item => item.kind !== "controllers");
61
32
  }
62
33
 
63
34
  /** Discover metadata statically. The MCP process never imports scenario modules. */
64
35
  export function inspectScenarios(options, suppliedProgram) {
65
36
  const { root, dsRoot } = options;
66
37
  const files = sourceFiles(dsRoot).filter(isScenarioFile);
67
- const program = suppliedProgram ?? ts.createProgram([path.join(dsRoot, options.config.publicEntry), ...files], projectCompilerOptions(options));
38
+ const program = suppliedProgram ?? ts.createProgram([path.join(dsRoot, options.config.publicEntry), ...files], { ...projectCompilerOptions(options), allowJs: true });
68
39
  const known = new Map(publicUi(program, options).map((item) => [item.name, item]));
69
40
  const checker = program.getTypeChecker();
41
+ const behaviorApis = new Map(publicApi(program, options).filter(item => !item.renderable && item.behavioral).map(item => [item.name, item]));
70
42
  const tokenNames = new Set(inspectTokens(root).tokens.map(token => token.name));
71
43
  const scenarios = [];
72
44
  const diagnostics = [];
@@ -105,21 +77,22 @@ export function inspectScenarios(options, suppliedProgram) {
105
77
  }
106
78
  const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
107
79
  if (![".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].some(extension => existsSync(fixtureBase + extension))) report(file, "Keep this scenario's inputs beside it: fixture.ts (directory entry) or <Owner>.fixture.ts (flat entry). An empty fixture is valid for a component without inputs.");
108
- if (!metadata || Object.keys(metadata).some((key) => !["title", "covers", "tokens", "samples"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
80
+ if (!metadata || Object.keys(metadata).some((key) => !["title", "covers", "exercises", "tokens", "samples"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
81
+ || Object.hasOwn(metadata, "exercises") && (!Array.isArray(metadata.exercises) || metadata.exercises.some(name => typeof name !== "string" || !behaviorApis.has(name)))
109
82
  || Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--")))
110
83
  || kind === "tokens" && (Object.hasOwn(metadata, "tokens") || !validTokenSamples(metadata.samples) || !metadata.covers.some(name => known.get(name)?.kind === "patterns"))
111
84
  || Object.hasOwn(metadata, "samples") && !validTokenSamples(metadata.samples)) {
112
- report(file, "Export static metadata with nonempty covers. Token scenarios must render a project-owned public review pattern and declare samples: fixture.samples, a literal list of { token, property, probe } CSS samples. A plain value list or covers: [] is incomplete."); continue;
85
+ report(file, "Export static metadata with nonempty covers naming rendered public UI. Optional exercises names public behavioral APIs, not rendered UI. Token scenarios must render a project-owned public review pattern and declare samples: fixture.samples, a literal list of { token, property, probe } CSS samples. A plain value list or covers: [] is incomplete."); continue;
113
86
  }
114
87
  if (!["composition", "tokens"].includes(kind) && metadata.covers.some((name) => known.get(name).kind !== kind)) report(file, "Component scenarios cover primitives; pattern scenarios cover patterns. Use composition/ for larger interfaces.");
115
88
  const tokens = [...new Set([...(metadata.tokens ?? []), ...(metadata.samples ?? []).map(sample => sample.token)])];
116
89
  for (const name of tokens) if (!tokenNames.has(name)) diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "token-scenario-reference", line: 1, column: 1, message: `Scenario references undeclared token ${name}. Use a real foundation token.` });
117
- scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
90
+ scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], ...(metadata.exercises ? { exercises: [...new Set(metadata.exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
118
91
  }
119
92
  return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
120
93
  }
121
94
 
122
- export function inspectScenarioCoverage(options, program, report) {
95
+ export function inspectScenarioCoverage(options, program, report, advise = () => {}) {
123
96
  const registry = inspectScenarios(options, program);
124
97
  for (const item of registry.diagnostics) report(path.join(options.root, item.file), item.rule, item.message, item.line, item.column);
125
98
  const checker = program.getTypeChecker();
@@ -153,7 +126,7 @@ export function inspectScenarioCoverage(options, program, report) {
153
126
  return symbol.declarations?.length ? symbol : null;
154
127
  }
155
128
  const identities = new Map();
156
- for (const item of ui) {
129
+ for (const item of publicApi(program, options)) {
157
130
  if (!identities.has(item.declaration)) identities.set(item.declaration, new Set());
158
131
  identities.get(item.declaration).add(item.name);
159
132
  }
@@ -165,12 +138,16 @@ export function inspectScenarioCoverage(options, program, report) {
165
138
  for (const scenario of registry.scenarios) {
166
139
  const source = program.getSourceFile(path.join(options.root, scenario.file));
167
140
  const rendered = new Set();
141
+ const exercised = new Set(), referencedApis = new Set();
168
142
  const referencedTokens = new Set();
169
143
  let samplesPassed = false;
170
144
  const visited = new Set();
171
145
  function visit(node, reached = false) {
172
146
  if (!node || visited.has(node)) return;
173
- if (!reached && (ts.isFunctionDeclaration(node) || ts.isVariableDeclaration(node))) return;
147
+ if (!reached && ts.isFunctionDeclaration(node)) return;
148
+ // Calls/construction in a mounted adapter execute even when only a result
149
+ // property is read later. Unused literal fixture fields still do not count.
150
+ if (!reached && ts.isVariableDeclaration(node) && !(node.initializer && (ts.isCallExpression(node.initializer) || ts.isNewExpression(node.initializer)))) return;
174
151
  if (!reached && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && !ts.isCallExpression(node.parent) && !ts.isJsxExpression(node.parent)) return;
175
152
  visited.add(node);
176
153
  if (ts.isStringLiteralLike(node) && (isSharedToken(node.text) || scenario.tokens.includes(node.text))) referencedTokens.add(node.text);
@@ -180,18 +157,25 @@ export function inspectScenarioCoverage(options, program, report) {
180
157
  if (expression) {
181
158
  const symbol = canonical(symbolOf(expression));
182
159
  const names = identities.get(symbol?.valueDeclaration ?? symbol?.declarations?.[0]);
183
- for (const name of names ?? []) rendered.add(name);
160
+ for (const name of names ?? []) { rendered.add(name); exercised.add(name); }
184
161
  if (scenario.kind === "tokens" && [...names ?? []].some(name => scenario.covers.includes(name))) {
185
162
  const attributes = ts.isCallExpression(node) ? literal(node.arguments[1], checker) : Object.assign({}, ...node.attributes.properties.map(attribute => ts.isJsxSpreadAttribute(attribute) ? literal(attribute.expression, checker) : ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.initializer && ts.isJsxExpression(attribute.initializer) ? { [attribute.name.text]: literal(attribute.initializer.expression, checker) } : {}));
186
163
  if (JSON.stringify(attributes?.samples) === JSON.stringify(scenario.samples)) samplesPassed = true;
187
164
  }
188
165
  }
166
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
167
+ let expression = node.expression;
168
+ if (ts.isPropertyAccessExpression(expression) && ["call", "apply"].includes(expression.name.text)) expression = expression.expression;
169
+ const symbol = canonical(symbolOf(expression));
170
+ for (const name of identities.get(symbol?.valueDeclaration ?? symbol?.declarations?.[0]) ?? []) exercised.add(name);
171
+ }
189
172
  // Follow local adapters reachable from the default entry. Unused example
190
173
  // functions do not satisfy coverage; production implementations are never
191
174
  // traversed to manufacture coverage for their internal dependencies.
192
175
  if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) || ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) || ts.isElementAccessExpression(node.parent))) {
193
176
  const symbol = canonical(symbolOf(node));
194
177
  const declaration = symbol?.valueDeclaration;
178
+ for (const name of identities.get(declaration ?? symbol?.declarations?.[0]) ?? []) referencedApis.add(name);
195
179
  if (declaration && isDevelopmentFile(declaration.getSourceFile().fileName, options)) visit(declaration, true);
196
180
  }
197
181
  ts.forEachChild(node, child => visit(child, reached && (ts.isExportAssignment(node) || ts.isVariableDeclaration(node))));
@@ -201,6 +185,10 @@ export function inspectScenarioCoverage(options, program, report) {
201
185
  if (!rendered.has(name)) report(source.fileName, "scenario-render", `${name} is declared in covers but has no public render reference in this scenario. Render the real API; an import alone does not count.`);
202
186
  else covered.add(name);
203
187
  }
188
+ for (const name of scenario.exercises ?? []) {
189
+ if (!exercised.has(name) && !referencedApis.has(name)) report(source.fileName, "scenario-exercise", `${name} is declared in exercises but has no reachable behavioral use. Imports and unused adapters do not count.`);
190
+ else if (!exercised.has(name)) advise(source.fileName, "scenario-exercise-review", `${name} is referenced dynamically. Verify its invocation in the registered browser/behavior checks; a reference alone does not prove execution.`);
191
+ }
204
192
  for (const name of referencedTokens) if (!declaredTokens.has(name)) report(source.fileName, "token-scenario-reference", `Reachable scenario input references undeclared token ${name}.`);
205
193
  if (scenario.kind === "tokens" && !samplesPassed) report(source.fileName, "token-scenario-render", "Pass the declared fixture.samples into a rendered public review pattern's samples prop. Printing token names or reading unused fixtures is not sample coverage.");
206
194
  for (const name of scenario.tokens) {
@@ -3,13 +3,13 @@
3
3
  Install the skills you need with `npx skills`:
4
4
 
5
5
  ```sh
6
- npx skills add reqvire-org/ads --skill ads-designer --skill ads-import --skill ads-assimilate --skill ads-break-ui --skill ads-motion
6
+ npx skills add reqvire-org/ads --skill ads-designer --skill ads-import --skill ads-migrate --skill ads-break-ui --skill ads-motion
7
7
  ```
8
8
 
9
9
  For local development, install from this checkout:
10
10
 
11
11
  ```sh
12
- npx skills add ./skills --skill ads-designer --skill ads-import --skill ads-assimilate --skill ads-break-ui --skill ads-motion
12
+ npx skills add ./skills --skill ads-designer --skill ads-import --skill ads-migrate --skill ads-break-ui --skill ads-motion
13
13
  ```
14
14
 
15
15
  Each skill includes its own instructions and references. `ads-designer` also
@@ -36,7 +36,7 @@ the browser, and provides a running URL or exact command for review. See the
36
36
  |-------|-----|---------|
37
37
  | [ads-designer](ads-designer/SKILL.md) | Bootstrap or build UI using the local ADS, with craft/mobile guidance | `$ads-designer build a settings page using this project's ADS` |
38
38
  | [ads-import](ads-import/SKILL.md) | Import selected external ADS source, brand artwork, or compatible source token values | `$ads-import /path/to/source-repo component Toast` |
39
- | [ads-assimilate](ads-assimilate/SKILL.md) | Study an external component library and implement a native counterpart | `$ads-assimilate https://github.com/emilkowalski/sonner` |
39
+ | [ads-migrate](ads-migrate/SKILL.md) | Study an external component library and implement a native counterpart | `$ads-migrate https://github.com/emilkowalski/sonner` |
40
40
  | [ads-break-ui](ads-break-ui/SKILL.md) | Stress-test real components/patterns; optionally fix demonstrated failures | `$ads-break-ui AppShell +fix` |
41
41
  | [ads-motion](ads-motion/SKILL.md) | Implement, review, audit, or profile motion | `$ads-motion review Toast` |
42
42