@reqvire-org/ads 0.3.3 → 0.3.6

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 (68) hide show
  1. package/README.md +17 -5
  2. package/dist/behaviors.mjs +157 -0
  3. package/dist/bootstrap-ds/ADS.md +16 -3
  4. package/dist/bootstrap-ds/README.md +10 -0
  5. package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  6. package/dist/boundaries.mjs +3 -0
  7. package/dist/check.mjs +20 -2
  8. package/dist/cli.mjs +17 -0
  9. package/dist/config.mjs +23 -3
  10. package/dist/css-values.mjs +26 -0
  11. package/dist/index.mjs +2 -0
  12. package/dist/init.mjs +1 -1
  13. package/dist/mcp/build-status.mjs +17 -7
  14. package/dist/mcp/catalog.mjs +11 -8
  15. package/dist/mcp/context.mjs +3 -3
  16. package/dist/mcp/edit-preparation.mjs +111 -0
  17. package/dist/mcp/mutations.mjs +64 -38
  18. package/dist/mcp/preview-owner-guard.mjs +29 -0
  19. package/dist/mcp/preview-watch.mjs +38 -8
  20. package/dist/mcp/preview-widget.bundle.js +2 -2
  21. package/dist/mcp/prompts.mjs +2 -2
  22. package/dist/mcp/server.mjs +88 -41
  23. package/dist/mcp/showcase-host.mjs +9 -5
  24. package/dist/mcp/text-diff.mjs +19 -0
  25. package/dist/preview-resources.mjs +45 -8
  26. package/dist/preview-scope.mjs +30 -0
  27. package/dist/public-api.mjs +39 -0
  28. package/dist/scenarios-compiler.mjs +41 -19
  29. package/dist/scenarios.mjs +72 -42
  30. package/dist/scoped-tokens.mjs +57 -0
  31. package/dist/skills/README.md +3 -3
  32. package/dist/skills/ads-break-ui/SKILL.md +26 -2
  33. package/dist/skills/ads-break-ui/references/guarded-editing.md +44 -0
  34. package/dist/skills/ads-break-ui/references/verification.md +6 -0
  35. package/dist/skills/ads-designer/SKILL.md +27 -3
  36. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +16 -3
  37. package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
  38. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  39. package/dist/skills/ads-designer/references/cli.md +1 -1
  40. package/dist/skills/ads-designer/references/design-craft.md +1 -1
  41. package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
  42. package/dist/skills/ads-import/SKILL.md +26 -2
  43. package/dist/skills/ads-import/references/cli.md +1 -1
  44. package/dist/skills/ads-import/references/guarded-editing.md +44 -0
  45. package/dist/skills/ads-import/references/tokens.md +4 -2
  46. package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +32 -8
  47. package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
  48. package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
  49. package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
  50. package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +11 -0
  51. package/dist/skills/ads-motion/SKILL.md +26 -2
  52. package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
  53. package/dist/token-fixtures.mjs +30 -0
  54. package/dist/tokens.mjs +11 -4
  55. package/dist/units.mjs +35 -9
  56. package/dist/verification/browser.mjs +27 -0
  57. package/dist/verification/playwright-reporter.cjs +20 -0
  58. package/dist/verification.mjs +214 -0
  59. package/docs/ADS.md +72 -4
  60. package/docs/ARCHITECTURE.md +60 -1
  61. package/docs/BEHAVIORS.md +241 -0
  62. package/docs/CHANGELOG.md +56 -0
  63. package/docs/GUIDE.md +24 -4
  64. package/docs/MCP.md +106 -12
  65. package/package.json +4 -2
  66. package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
  67. /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
  68. /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";
@@ -12,11 +13,14 @@ import { createSkillStore, page, SKILLS_EXTENSION } from "./skills.mjs";
12
13
  import { inspectCatalog, projectDocuments, PROJECT_URI, readProjectDocument } from "./catalog.mjs";
13
14
  import { APP_MIME, appMetadata, appUri, projectApp, TOKENS_APP_URI, tokensApp, tokenWidgetData, SCENARIO_APP_URI, scenarioApp, previewApp, PREVIEW_APP_URI } from "./apps.mjs";
14
15
  import { PREVIEW_MANIFEST_URI, readPreviewManifest, readPreviewAsset } from "../preview-resources.mjs";
16
+ import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
15
17
  import { getWorkflowPrompt, promptDefinitions } from "./prompts.mjs";
16
18
  import { createReconfigurablePreviewWatch } from "./preview-watch.mjs";
17
19
  import { BUILD_URI, buildFailure, inspectBuildStatus, summarizeBuild } from "./build-status.mjs";
18
20
  import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow } from "./context.mjs";
19
21
  import { createMutationController, mutationLimits } from "./mutations.mjs";
22
+ import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
23
+ import { reconcileTokenSamples } from "../token-fixtures.mjs";
20
24
 
21
25
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
22
26
  class InvalidParamsError extends ProtocolError {
@@ -27,12 +31,14 @@ const empty = z.object({}).strict();
27
31
  const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
28
32
  const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
29
33
  const schemas = { params: z.object({ cursor: z.string().optional() }).passthrough(), result: z.object({ resultType: z.literal("complete") }).passthrough() };
34
+ const ownerPatch = z.object({ unit: z.string().min(1).max(256), 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.");
30
35
 
31
36
  export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollInterval = 1000, previewWatch, showcaseUrl, notifications, inspection, mutations = false } = {}) {
32
37
  const root = path.resolve(directory);
33
38
  const cache = inspection ?? {};
34
39
  const skills = cache.skills ??= createSkillStore(skillDirectory);
35
40
  const edits = cache.edits ??= createMutationController(root);
41
+ const verification = cache.verification ??= createVerificationController(root);
36
42
  let options = loadConfig(root);
37
43
  const signature = `${root}:${JSON.stringify(options.config)}`;
38
44
  if (cache.signature !== signature) {
@@ -52,13 +58,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
52
58
  const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
53
59
  const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
54
60
  const scenarioWidget = () => options.config.mcp.previewManifest ? PREVIEW_APP_URI : SCENARIO_APP_URI;
61
+ const previewOptions = () => withPreviewSession(options, previewWatch?.resourceSession?.());
55
62
  const resourceAssets = () => {
56
- try { return Object.entries(readPreviewManifest(options)?.assets ?? {}).map(([uri, record]) => ({ uri, name: `Preview asset ${uri.slice("ads://assets/".length)}`, mimeType: record.mimeType, size: record.size })); }
63
+ try { return Object.entries(readPreviewManifest(previewOptions())?.assets ?? {}).map(([uri, record]) => ({ uri, name: `Preview asset ${uri.slice("ads://assets/".length)}`, mimeType: record.mimeType, size: record.size })); }
57
64
  catch { return []; } // Discovery and diagnostics remain usable after a failed graph.
58
65
  };
59
66
  function graphSelection(id) {
60
67
  if (!options.config.mcp.previewManifest) return {};
61
- const manifest = readPreviewManifest(options);
68
+ const manifest = readPreviewManifest(previewOptions());
62
69
  if (!manifest?.scenarios.some(entry => entry.id === id)) throw new Error("The preview resource graph is not ready for this scenario. Rebuild previews.");
63
70
  return { manifestUri: PREVIEW_MANIFEST_URI, manifestRevision: manifest.revision };
64
71
  }
@@ -77,11 +84,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
77
84
  const skillEntries = new Set(skills.skills.map((skill) => skill.uri));
78
85
  return [
79
86
  { 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" },
87
+ { uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
80
88
  { uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
81
89
  { uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
82
90
  { uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
83
91
  { uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
84
92
  { uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
93
+ { uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
85
94
  { uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
86
95
  { uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
87
96
  { uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
@@ -98,18 +107,20 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
98
107
  const context = project();
99
108
  return { projectRoot: root, workflow: workflow([...previews(), ...scenarioApps()]), project: context,
100
109
  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,
110
+ components: getCatalog().filter((item) => item.kind === "components" && item.apiKind === "ui").length,
111
+ patterns: getCatalog().filter((item) => item.kind === "patterns" && item.apiKind === "ui").length,
103
112
  tokenCount: inspectTokens(root).tokens.length, showcaseUrl: getShowcaseUrl(),
104
113
  previewWatch: previewWatch?.status() ?? { state: "disabled" },
105
114
  mutations: mutations ? edits.status() : { enabled: false },
106
115
  build: summarizeBuild(builds()),
107
116
  previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
108
117
  scenarios: { count: getRegistry().scenarios.length, diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios`, tool: "ads_show_scenario" },
118
+ behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", ...verification.status() },
109
119
  experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
110
120
  }
111
121
  async function read(uri, signal) {
112
122
  if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
123
+ if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
113
124
  if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
114
125
  let value;
115
126
  if (uri === `${PROJECT_URI}/status`) value = status();
@@ -118,8 +129,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
118
129
  else if (uri === `${PROJECT_URI}/catalog`) value = { items: getCatalog() };
119
130
  else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
120
131
  else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
132
+ else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
121
133
  else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
122
- value = readPreviewManifest(options);
134
+ value = readPreviewManifest(previewOptions());
123
135
  if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
124
136
  }
125
137
  if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
@@ -127,7 +139,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
127
139
  if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
128
140
  if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
129
141
  if (uri.startsWith("ads://assets/") && options.config.mcp.previewManifest) {
130
- try { return { contents: [readPreviewAsset(options, uri)] }; }
142
+ try { return { contents: [readPreviewAsset(previewOptions(), uri)] }; }
131
143
  catch (error) { if (error.code === "ADS_PREVIEW_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
132
144
  }
133
145
  const app = allApps().find((entry) => appUri(entry.id) === uri);
@@ -150,20 +162,30 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
150
162
  return build;
151
163
  }
152
164
 
165
+ async function applyEdits(args,signal) {
166
+ const result = await edits.apply(args,signal);
167
+ if (result.applied) {
168
+ try {
169
+ if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration" || result.units?.includes("configuration")) await previewWatch?.reconfigure?.(loadConfig(root).config.mcp,true);
170
+ await refresh(true);
171
+ } 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.`}];}
172
+ }
173
+ return {...json(result),...(!result.ok ? {isError:true} : {})};
174
+ }
153
175
  function tools() {
154
176
  return [
155
177
  { 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 }) => {
178
+ { 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
179
  const matches = getCatalog().filter((item) => (!kind || item.kind === kind) && item.name.toLowerCase().includes(search.toLowerCase()));
158
180
  const units = edits.units();
159
181
  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
182
  } },
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()));
183
+ { 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 }) => {
184
+ 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
185
  const units = edits.units();
164
186
  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
187
  } },
166
- { name: "ads_show_scenario", description: "Render one real token, component, pattern or composition scenario with its colocated fixtures and state adapter. First get an exact id from ads_scenarios or ads_catalog.previews. The managed compiler builds it on demand; errors return diagnostics instead of falsely current output. Requires MCP Apps plus host resource forwarding; fallback links to the same scenario in the project showcase.", schema: z.object({ id: z.string().min(1).max(256) }).strict(), _meta: { ui: { resourceUri: scenarioWidget() } }, call: async ({ id }, signal) => {
188
+ { name: "ads_show_scenario", title: "Scenario preview", description: "Render one real token, component, pattern or composition scenario with its colocated fixtures and state adapter. First get an exact id from ads_scenarios or ads_catalog.previews. The managed compiler builds it on demand; errors return diagnostics instead of falsely current output. Requires MCP Apps plus host resource forwarding; fallback links to the same scenario in the project showcase.", schema: z.object({ id: z.string().min(1).max(256) }).strict(), _meta: { ui: { resourceUri: scenarioWidget() } }, call: async ({ id }, signal) => {
167
189
  const entry = getRegistry().scenarios.find(item => item.id === id);
168
190
  if (!entry) throw new Error("Unknown scenario id. Read ads_scenarios; IDs must match discovered source entries.");
169
191
  if (getRegistry().diagnostics.length) throw new Error("Scenario registry has errors. Read ads://project/scenarios and fix its diagnostics.");
@@ -185,7 +207,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
185
207
  const result = checkProject(root);
186
208
  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
209
  } },
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 })) }) },
210
+ { 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
211
  { 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
212
  const skill = skills.skills.find((entry) => entry.frontmatter.name === name);
191
213
  if (!skill) throw new Error("Unknown ADS skill name.");
@@ -196,7 +218,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
196
218
  if (!("text" in content)) return json({ ...manifest, mimeType: content.mimeType, binary: true, note: "Read binary bytes via resources/read; this tool does not embed them." });
197
219
  return json({ ...manifest, text: content.text.slice(offset, offset + limit), offset, ...(offset + limit < content.text.length ? { nextOffset: offset + limit } : {}) });
198
220
  } },
199
- { name: "ads_show_tokens", description: "Render an exact discovered token scenario with its project-owned templates and fixtures. Pass its id as category, discovered through ads_tokens.categories or ads_scenarios(kind: tokens). Omit category for the first project token scenario, or a generic definition inspector for older compilers.", schema: z.object({ category: z.string().max(256).optional() }).strict(), _meta: { ui: { resourceUri: options.config.mcp.previewManifest ? PREVIEW_APP_URI : TOKENS_APP_URI } }, call: async ({ category }, signal) => {
221
+ { name: "ads_show_tokens", title: "Token previews", description: "Render an exact discovered token scenario with its project-owned templates and fixtures. Pass its id as category, discovered through ads_tokens.categories or ads_scenarios(kind: tokens). Omit category for the first project token scenario, or a generic definition inspector for older compilers.", schema: z.object({ category: z.string().max(256).optional() }).strict(), _meta: { ui: { resourceUri: options.config.mcp.previewManifest ? PREVIEW_APP_URI : TOKENS_APP_URI } }, call: async ({ category }, signal) => {
200
222
  if (!category && options.config.mcp.previewManifest) category = tokenScenarios()[0]?.id;
201
223
  if (!category && options.config.mcp.previewManifest) throw new Error("No token scenario is registered. Colocate a token scenario and fixture before opening the preview; ads_tokens still inspects definitions.");
202
224
  if (category) {
@@ -214,27 +236,39 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
214
236
  }
215
237
  return json({ widget: { resourceUri: TOKENS_APP_URI }, category: null, tokenCount: inspectTokens(root).tokens.length, dataUri: `${PROJECT_URI}/tokens`, showcaseUrl: getShowcaseUrl() });
216
238
  } },
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. 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 }) => {
239
+ { 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 }) => {
240
+ const inventory = behaviorStatus(options);
241
+ 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()));
242
+ return json({ total: matches.length, contracts: matches.slice(0, limit).map(item => {
243
+ if (unit) return item;
244
+ const apis = Object.values(item.subjects).filter(subject => subject.api).map(subject => subject.api);
245
+ const demonstrations = [...new Set(Object.values(item.demonstrations).flat())];
246
+ return { unit: item.unit, file: item.file, apis: apis.slice(0, 20), apiCount: apis.length,
247
+ guarantees: item.guarantees.slice(0, 5).map(guarantee => ({ id: guarantee.id, result: guarantee.result })), guaranteeCount: item.guarantees.length,
248
+ demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
249
+ }), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
250
+ } },
251
+ { 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) => {
252
+ const result = await verification.run(args, signal);
253
+ await refresh();
254
+ return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
255
+ } },
256
+ { 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
257
  const matches = edits.units().filter(item => unit ? item.id === unit : item.id.toLowerCase().includes(search.toLowerCase()));
219
258
  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
259
  } },
221
260
  { 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. 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.",
261
+ ...(mutations ? [{ name: "ads_prepare_patch", description: "Prepare a Git patch without a separate project mirror. Create copies only one owner plus its declared companions into a local draft; edit that directory with local file tools. Diff checks baseline/source freshness and owner paths, then writes a patchFile under .ds/patches. Discard removes the draft. Live source is never changed; ads_apply_patch still validates all gates. Remote clients can continue sending inline Git diffs instead.", schema:z.object({unit:z.string().min(1).max(256),action:z.enum(["create","diff","discard"]).default("create"),draft:z.string().max(100).optional()}).strict(), call:args=>json(prepareUnitPatch(options,args)) },
262
+ {name:"ads_prepare_token_samples",description:"Reconcile a token scenario's consumed literal fixture samples. Removes undeclared tokens, preserves existing project bindings, and accepts explicit real-token/property/probe bindings. Reports unresolved token names; never guesses CSS meaning from token names. Returns an owner patchFile without changing source. Apply it through ads_apply_patch and review the real preview.",schema:z.object({id:z.string().min(1).max(256),bindings:z.array(z.object({token:z.string().max(256),property:z.string().max(100),probe:z.string().max(1000)}).passthrough()).max(1000).optional()}).strict(),call:args=>{
263
+ const {patch,...result}=reconcileTokenSamples(options,args);
264
+ return json({...result,removed:result.removed.slice(0,50),unresolved:result.unresolved.slice(0,50),unresolvedCount:result.unresolved.length,...storePreparedPatch(options,patch)});
265
+ }},
266
+ { 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. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance.",
223
267
  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
268
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
225
- call: async (args, signal) => {
226
- const result = await edits.apply(args, signal);
227
- if (result.applied) {
228
- try {
229
- if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
230
- await refresh(true);
231
- }
232
- 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.` }]; }
233
- }
234
- return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
235
- },
236
- }] : []),
237
- ...options.config.mcp.apps.map((app) => ({ name: `ads_show_${app.id.replaceAll("-", "_")}`, description: `${app.description ?? app.title}. Renders the project's built ${app.kind} examples through MCP Apps; text fallback provides the showcase URL.`, schema: empty, _meta: { ui: { resourceUri: appUri(app.id) } }, call: async (_args, signal) => {
269
+ call: applyEdits,
270
+ }, {name:"ads_apply_transaction",description:"Apply genuinely coupled ADS changes as 2–8 distinct owner Git patches. Each patch must obey its unit and narrow companion rules. Patches run in order; later patches use the earlier candidate's shared-entry content. Core guards, registered Oxlint/project checks and affected behavior checks validate the complete final candidate before any live source is written. Maximum 32 distinct files/16 MiB combined. Dry runs, failures and cancellation publish nothing; publication rolls back on write failure. Prefer ads_apply_patch for independent edits.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean()}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
271
+ ...options.config.mcp.apps.map((app) => ({ name: `ads_show_${app.id.replaceAll("-", "_")}`, title: app.title, description: `${app.description ?? app.title}. Renders the project's built ${app.kind} examples through MCP Apps; text fallback provides the showcase URL.`, schema: empty, _meta: { ui: { resourceUri: appUri(app.id) } }, call: async (_args, signal) => {
238
272
  // Validate readiness without embedding executable HTML in model results.
239
273
  const build = await previewBuild(app, signal);
240
274
  projectApp(options, app);
@@ -295,8 +329,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
295
329
  try { const stat = statSync(file); return `${file}:${stat.mtimeMs}:${stat.size}`; } catch { return `${file}:missing`; }
296
330
  }).join("\n");
297
331
  }
298
- const fingerprint = () => fileFingerprint([...sourceFiles(options.dsRoot), ...getDocuments().map((doc) => doc.file), path.join(root, "ds.config.json"), path.join(root, "package.json")]);
299
- const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, app.file)));
332
+ 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")]);
333
+ const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, previewArtifactPath(previewOptions(), app.file))));
300
334
  const assetFingerprint = () => resourceAssets().map(asset => asset.uri).sort().join("\n");
301
335
  let previous = fingerprint();
302
336
  let previousArtifacts = artifactFingerprint();
@@ -330,7 +364,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
330
364
  previousAssets = assetFingerprint();
331
365
  previousBuild = JSON.stringify(builds());
332
366
  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));
367
+ : [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
368
  for (const uri of targets) { if (notifications) notifications.resourceUpdated(uri); else await server.sendResourceUpdated({ uri }); }
335
369
  return true;
336
370
  }
@@ -338,7 +372,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
338
372
  timer?.unref();
339
373
  server.oninitialized = () => previewWatch?.start();
340
374
  server.onclose = () => { if (timer) clearInterval(timer); };
341
- return { server, refresh, async close() { if (timer) clearInterval(timer); await server.close(); } };
375
+ return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) await verification.close(); await server.close(); } };
342
376
  }
343
377
 
344
378
  export async function startAdsMcp(directory = process.cwd(), { stdio = true, showcase = true, port = 0, mutations = false } = {}) {
@@ -353,25 +387,38 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
353
387
  { port, observe: notifications => createAdsMcp(directory, { era: "modern", previewWatch, showcaseUrl, inspection, mutations, notifications }) });
354
388
  process.stderr.write(`ADS showcase: ${host.url}\n`);
355
389
  }
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;
390
+ const transport = stdio ? new StdioServerTransport() : null;
391
+ 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
392
  let closing;
358
393
  function close() {
359
394
  return closing ??= (async () => {
360
395
  inspection.edits?.cancel();
361
- await host?.close();
362
- await previewWatch.stop();
363
- await handle?.close();
364
- process.stdin.removeListener("end", disconnect);
396
+ // Stop every owned resource even if another close fails or has in-flight RPCs.
397
+ const results = await Promise.allSettled([
398
+ Promise.resolve().then(() => host?.close()),
399
+ previewWatch.stop(),
400
+ Promise.resolve().then(() => inspection.verification?.close()),
401
+ Promise.resolve().then(() => handle?.close()),
402
+ ]);
403
+ for (const event of ["end", "close", "error"]) process.stdin.removeListener(event, disconnect);
365
404
  for (const signal of ["SIGINT", "SIGTERM"]) process.removeListener(signal, disconnect);
366
405
  process.removeListener("exit", forceCleanup);
406
+ const errors = results.filter(result => result.status === "rejected").map(result => result.reason);
407
+ if (errors.length) throw new AggregateError(errors, "ADS shutdown failed");
367
408
  })();
368
409
  }
369
- function disconnect() { void close(); }
410
+ function disconnect() { void close().catch(error => { process.stderr.write(`ads mcp: ${error.message}\n`); process.exitCode = 1; }); }
370
411
  function forceCleanup() { previewWatch.kill(); }
371
412
  // 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);
413
+ // The SDK also closes its wire on stdout failure. Closing a probe instance
414
+ // must not shut the runtime down, so observe the wire, not server.onclose.
415
+ if (transport) {
416
+ const wireClosed = transport.onclose;
417
+ transport.onclose = () => { try { wireClosed?.(); } finally { disconnect(); } };
418
+ for (const event of ["end", "close", "error"]) process.stdin.once(event, disconnect);
419
+ if (process.stdin.readableEnded || process.stdin.destroyed) disconnect();
420
+ }
374
421
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, disconnect);
375
422
  process.once("exit", forceCleanup);
376
- return { close, url: host?.url };
423
+ return { close, url: host?.url, previewRevision: () => readPreviewManifest(withPreviewSession(loadConfig(root), previewWatch.resourceSession()))?.revision ?? null };
377
424
  }
@@ -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
  }
@@ -0,0 +1,19 @@
1
+ /** One exact Git-style hunk, retaining only shared context around the change. */
2
+ export function textDiff(file, before, after) {
3
+ if (before === after) return "";
4
+ const lines = value => value?.match(/[^\n]*\n|[^\n]+$/g) ?? [];
5
+ const emit = (prefix, line) => prefix + line.replace(/\n$/, "") + "\n" + (line.endsWith("\n") ? "" : "\\n");
6
+ const left = lines(before), right = lines(after);
7
+ let common = 0, suffix = 0;
8
+ while (common < left.length && common < right.length && left[common] === right[common]) common++;
9
+ while (suffix < left.length - common && suffix < right.length - common && left.at(-suffix - 1) === right.at(-suffix - 1)) suffix++;
10
+ const start = Math.max(0, common - 3), tail = Math.min(3, suffix);
11
+ const oldCount = left.length - suffix + tail - start, newCount = right.length - suffix + tail - start;
12
+ const headers = `--- ${before === null ? "/dev/null" : "a/" + file}\n+++ ${after === null ? "/dev/null" : "b/" + file}\n`;
13
+ if (!oldCount && !newCount) return headers;
14
+ return headers + `@@ -${oldCount ? start + 1 : start},${oldCount} +${newCount ? start + 1 : start},${newCount} @@\n`
15
+ + left.slice(start, common).map(line => emit(" ", line)).join("")
16
+ + left.slice(common, left.length - suffix).map(line => emit("-", line)).join("")
17
+ + right.slice(common, right.length - suffix).map(line => emit("+", line)).join("")
18
+ + left.slice(left.length - suffix, left.length - suffix + tail).map(line => emit(" ", line)).join("");
19
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import ts from "typescript";
5
5
  import valueParser from "postcss-value-parser";
6
6
  import { isInside } from "./config.mjs";
7
+ import { previewArtifactPath, previewManifestPath } from "./preview-scope.mjs";
7
8
 
8
9
  export const PREVIEW_APP_URI = "ui://ads/preview";
9
10
  export const PREVIEW_MANIFEST_URI = "ads://preview/manifest";
@@ -118,11 +119,41 @@ export function createPreviewResources(output, options, entries) {
118
119
  const graph = { schemaVersion: 1, framework: options.config.framework, assets: records, scenarios };
119
120
  const revision = hash(JSON.stringify(graph));
120
121
  validateManifest({ ...graph, revision });
121
- const target = path.resolve(options.root, options.config.mcp.previewManifest ?? ".ds/preview/manifest.json");
122
+ return publication(options, entries, { ...graph, revision }, contents);
123
+ }
124
+
125
+ export function previewArtifactHtml(id, revision) {
126
+ const descriptor = JSON.stringify({ schemaVersion: 1, manifestUri: PREVIEW_MANIFEST_URI, revision, id });
127
+ return `<!doctype html><html><head></head><body><script type="application/json" id="ads-preview-resource">${descriptor.replaceAll("<", "\\u003c")}</script></body></html>`;
128
+ }
129
+
130
+ function cacheOptions(options, revision) {
131
+ return { ...options, previewSession: undefined, config: { ...options.config, mcp: { ...options.config.mcp, previewManifest: `.ds/preview-graphs/${revision}/manifest.json` } } };
132
+ }
133
+
134
+ /** A cache hit still verifies every compiled byte before republishing it. */
135
+ export function cachedPreviewPublication(options, entries, revision) {
136
+ if (!/^[a-f0-9]{64}$/.test(revision ?? "")) return null;
137
+ try {
138
+ const cached = cacheOptions(options, revision), manifest = readPreviewManifest(cached, { verify: true });
139
+ if (!manifest || manifest.revision !== revision || manifest.framework !== options.config.framework
140
+ || JSON.stringify(manifest.scenarios.map(entry => entry.id)) !== JSON.stringify(entries.map(entry => entry.id))) return null;
141
+ const contents = new Map(Object.keys(manifest.assets).map(uri => {
142
+ const value = readPreviewAsset(cached, uri, manifest);
143
+ return [uri, "text" in value ? Buffer.from(value.text) : Buffer.from(value.blob, "base64")];
144
+ }));
145
+ const { session: _session, ...graph } = manifest;
146
+ return publication(options, entries, graph, contents);
147
+ } catch { return null; } // Missing, pruned or corrupt cache means a real build.
148
+ }
149
+
150
+ function publication(options, entries, manifest, contents, saveCache = true) {
151
+ const { revision } = manifest;
152
+ const target = previewManifestPath(options);
122
153
  const assetsRoot = path.join(path.dirname(target), "assets");
123
154
  const assetFiles = [...contents.keys()].map(uri => path.join(assetsRoot, uri.slice("ads://assets/".length)));
124
- return { assetFiles, publish(session) {
125
- const encoded = JSON.stringify({ ...graph, revision, session });
155
+ return { assetFiles, revision, publish(session) {
156
+ const encoded = JSON.stringify({ ...manifest, session });
126
157
  if (Buffer.byteLength(encoded) > 2 * 1024 * 1024) throw new Error("Preview manifest exceeds the 2 MiB resource limit.");
127
158
  const history = path.join(path.dirname(target), "history");
128
159
  if (existsSync(target)) {
@@ -133,8 +164,7 @@ export function createPreviewResources(output, options, entries) {
133
164
  }
134
165
  for (const [uri, bytes] of contents) atomic(path.join(assetsRoot, uri.slice("ads://assets/".length)), bytes);
135
166
  for (const entry of entries) {
136
- const descriptor = JSON.stringify({ schemaVersion: 1, manifestUri: PREVIEW_MANIFEST_URI, revision, id: entry.id });
137
- atomic(path.join(options.root, entry.artifact), `<!doctype html><html><head></head><body><script type="application/json" id="ads-preview-resource">${descriptor.replaceAll("<", "\\u003c")}</script></body></html>`);
167
+ atomic(path.join(options.root, previewArtifactPath(options, entry.artifact)), previewArtifactHtml(entry.id, revision));
138
168
  }
139
169
  atomic(target, encoded);
140
170
  const retained = existsSync(history) ? readdirSync(history).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).sort((a, b) => statSync(path.join(history, b)).mtimeMs - statSync(path.join(history, a)).mtimeMs) : [];
@@ -145,13 +175,20 @@ export function createPreviewResources(output, options, entries) {
145
175
  }
146
176
  for (const file of retained.slice(6)) rmSync(path.join(history, file));
147
177
  for (const file of readdirSync(assetsRoot)) if (/^[a-f0-9]{64}\.[a-z0-9]+$/.test(file) && !keep.has(`ads://assets/${file}`)) rmSync(path.join(assetsRoot, file));
178
+ if (saveCache) try {
179
+ publication(cacheOptions(options, revision), [], manifest, contents, false).publish("cache");
180
+ const directory = path.join(options.root, ".ds/preview-graphs");
181
+ const snapshots = readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name))
182
+ .sort((a, b) => statSync(path.join(directory, b.name)).mtimeMs - statSync(path.join(directory, a.name)).mtimeMs);
183
+ for (const entry of snapshots.slice(6)) rmSync(path.join(directory, entry.name), { recursive: true, force: true });
184
+ } catch { /* Cache races/storage failures never invalidate published output. */ }
148
185
  } };
149
186
  }
150
187
 
151
188
  export function readPreviewManifest(options, { verify = false } = {}) {
152
189
  const relative = options.config.mcp.previewManifest;
153
190
  if (!relative) return null;
154
- const file = path.resolve(options.root, relative);
191
+ const file = previewManifestPath(options);
155
192
  if (!existsSync(file)) return null;
156
193
  const manifest = readManifestFile(file);
157
194
  if (verify) for (const uri of Object.keys(manifest.assets)) readPreviewAsset(options, uri, manifest);
@@ -180,7 +217,7 @@ export function readPreviewAsset(options, uri, manifest = readPreviewManifest(op
180
217
  if (!assetUriPattern.test(uri)) throw unknownResource("Unknown preview asset resource.");
181
218
  let record = manifest?.assets?.[uri];
182
219
  if (!record) {
183
- const history = path.join(path.dirname(path.resolve(options.root, options.config.mcp.previewManifest)), "history");
220
+ const history = path.join(path.dirname(previewManifestPath(options)), "history");
184
221
  for (const file of existsSync(history) ? readdirSync(history).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).slice(0, 6) : []) {
185
222
  const candidate = path.join(history, file);
186
223
  try { record = readManifestFile(candidate).assets[uri]; }
@@ -189,7 +226,7 @@ export function readPreviewAsset(options, uri, manifest = readPreviewManifest(op
189
226
  }
190
227
  }
191
228
  if (!record) throw unknownResource("Unknown or expired preview asset resource. Refresh the manifest.");
192
- const directory = path.join(path.dirname(path.resolve(options.root, options.config.mcp.previewManifest)), "assets");
229
+ const directory = path.join(path.dirname(previewManifestPath(options)), "assets");
193
230
  const file = path.join(directory, uri.slice("ads://assets/".length));
194
231
  if (!isInside(directory, file) || realpathSync(file) !== file || statSync(file).size > 16 * 1024 * 1024) throw new Error("Invalid preview asset file.");
195
232
  const bytes = readFileSync(file);
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { isInside } from "./config.mjs";
4
+
5
+ /** Logical resource names stay stable; managed builders own physical output. */
6
+ export function withPreviewSession(options, session) {
7
+ if (!options.config.mcp.previewManifest || !session) return options;
8
+ if (!/^[\w-]{1,100}$/.test(session)) throw new Error("Invalid preview build session.");
9
+ return { ...options, previewSession: session };
10
+ }
11
+
12
+ export function previewManifestPath(options) {
13
+ const configured = path.resolve(options.root, options.config.mcp.previewManifest);
14
+ return options.previewSession ? path.join(path.dirname(configured), "sessions", options.previewSession, "manifest.json") : configured;
15
+ }
16
+
17
+ export function previewArtifactPath(options, file) {
18
+ if (!options.previewSession || !file.replaceAll("\\", "/").startsWith(".ds/scenarios/")) return file;
19
+ return path.join(".ds/preview-sessions", options.previewSession, path.relative(".ds", file)).replaceAll("\\", "/");
20
+ }
21
+
22
+ export function previewSessionDirectories(options) {
23
+ if (!options.previewSession) return [];
24
+ const directories = [path.dirname(previewManifestPath(options)), path.join(options.root, ".ds/preview-sessions", options.previewSession)];
25
+ if (directories.some(directory => !isInside(path.join(options.root, ".ds"), directory))) throw new Error("Preview session output must stay inside .ds/.");
26
+ for (const directory of directories) for (let current = directory; isInside(path.join(options.root, ".ds"), current); current = path.dirname(current)) {
27
+ if (existsSync(current) && realpathSync(current) !== current) throw new Error("Preview session output cannot resolve through a symlink.");
28
+ }
29
+ return directories;
30
+ }
@@ -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
+ }