@reqvire-org/ads 0.3.5 → 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 (43) hide show
  1. package/README.md +5 -1
  2. package/dist/bootstrap-ds/ADS.md +9 -3
  3. package/dist/bootstrap-ds/README.md +10 -0
  4. package/dist/boundaries.mjs +3 -0
  5. package/dist/check.mjs +13 -0
  6. package/dist/config.mjs +6 -2
  7. package/dist/mcp/build-status.mjs +7 -4
  8. package/dist/mcp/context.mjs +2 -2
  9. package/dist/mcp/edit-preparation.mjs +111 -0
  10. package/dist/mcp/mutations.mjs +54 -38
  11. package/dist/mcp/preview-watch.mjs +10 -4
  12. package/dist/mcp/server.mjs +32 -22
  13. package/dist/mcp/text-diff.mjs +19 -0
  14. package/dist/preview-resources.mjs +45 -8
  15. package/dist/preview-scope.mjs +30 -0
  16. package/dist/scenarios-compiler.mjs +24 -17
  17. package/dist/scenarios.mjs +46 -4
  18. package/dist/scoped-tokens.mjs +57 -0
  19. package/dist/skills/ads-break-ui/SKILL.md +4 -2
  20. package/dist/skills/ads-break-ui/references/guarded-editing.md +44 -0
  21. package/dist/skills/ads-break-ui/references/verification.md +6 -0
  22. package/dist/skills/ads-designer/SKILL.md +4 -2
  23. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +9 -3
  24. package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
  25. package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
  26. package/dist/skills/ads-import/SKILL.md +4 -2
  27. package/dist/skills/ads-import/references/guarded-editing.md +44 -0
  28. package/dist/skills/ads-import/references/tokens.md +4 -2
  29. package/dist/skills/ads-migrate/SKILL.md +4 -2
  30. package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
  31. package/dist/skills/ads-motion/SKILL.md +4 -2
  32. package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
  33. package/dist/token-fixtures.mjs +30 -0
  34. package/dist/tokens.mjs +8 -2
  35. package/dist/units.mjs +15 -0
  36. package/dist/verification.mjs +1 -1
  37. package/docs/ADS.md +45 -2
  38. package/docs/ARCHITECTURE.md +21 -0
  39. package/docs/BEHAVIORS.md +11 -0
  40. package/docs/CHANGELOG.md +20 -0
  41. package/docs/GUIDE.md +8 -3
  42. package/docs/MCP.md +60 -5
  43. package/package.json +1 -1
@@ -13,11 +13,14 @@ import { createSkillStore, page, SKILLS_EXTENSION } from "./skills.mjs";
13
13
  import { inspectCatalog, projectDocuments, PROJECT_URI, readProjectDocument } from "./catalog.mjs";
14
14
  import { APP_MIME, appMetadata, appUri, projectApp, TOKENS_APP_URI, tokensApp, tokenWidgetData, SCENARIO_APP_URI, scenarioApp, previewApp, PREVIEW_APP_URI } from "./apps.mjs";
15
15
  import { PREVIEW_MANIFEST_URI, readPreviewManifest, readPreviewAsset } from "../preview-resources.mjs";
16
+ import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
16
17
  import { getWorkflowPrompt, promptDefinitions } from "./prompts.mjs";
17
18
  import { createReconfigurablePreviewWatch } from "./preview-watch.mjs";
18
19
  import { BUILD_URI, buildFailure, inspectBuildStatus, summarizeBuild } from "./build-status.mjs";
19
20
  import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow } from "./context.mjs";
20
21
  import { createMutationController, mutationLimits } from "./mutations.mjs";
22
+ import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
23
+ import { reconcileTokenSamples } from "../token-fixtures.mjs";
21
24
 
22
25
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
23
26
  class InvalidParamsError extends ProtocolError {
@@ -28,6 +31,7 @@ const empty = z.object({}).strict();
28
31
  const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
29
32
  const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
30
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.");
31
35
 
32
36
  export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollInterval = 1000, previewWatch, showcaseUrl, notifications, inspection, mutations = false } = {}) {
33
37
  const root = path.resolve(directory);
@@ -54,13 +58,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
54
58
  const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
55
59
  const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
56
60
  const scenarioWidget = () => options.config.mcp.previewManifest ? PREVIEW_APP_URI : SCENARIO_APP_URI;
61
+ const previewOptions = () => withPreviewSession(options, previewWatch?.resourceSession?.());
57
62
  const resourceAssets = () => {
58
- 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 })); }
59
64
  catch { return []; } // Discovery and diagnostics remain usable after a failed graph.
60
65
  };
61
66
  function graphSelection(id) {
62
67
  if (!options.config.mcp.previewManifest) return {};
63
- const manifest = readPreviewManifest(options);
68
+ const manifest = readPreviewManifest(previewOptions());
64
69
  if (!manifest?.scenarios.some(entry => entry.id === id)) throw new Error("The preview resource graph is not ready for this scenario. Rebuild previews.");
65
70
  return { manifestUri: PREVIEW_MANIFEST_URI, manifestRevision: manifest.revision };
66
71
  }
@@ -126,7 +131,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
126
131
  else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
127
132
  else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
128
133
  else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
129
- value = readPreviewManifest(options);
134
+ value = readPreviewManifest(previewOptions());
130
135
  if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
131
136
  }
132
137
  if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
@@ -134,7 +139,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
134
139
  if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
135
140
  if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
136
141
  if (uri.startsWith("ads://assets/") && options.config.mcp.previewManifest) {
137
- try { return { contents: [readPreviewAsset(options, uri)] }; }
142
+ try { return { contents: [readPreviewAsset(previewOptions(), uri)] }; }
138
143
  catch (error) { if (error.code === "ADS_PREVIEW_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
139
144
  }
140
145
  const app = allApps().find((entry) => appUri(entry.id) === uri);
@@ -157,6 +162,16 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
157
162
  return build;
158
163
  }
159
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
+ }
160
175
  function tools() {
161
176
  return [
162
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()) },
@@ -170,7 +185,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
170
185
  const units = edits.units();
171
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` });
172
187
  } },
173
- { 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) => {
174
189
  const entry = getRegistry().scenarios.find(item => item.id === id);
175
190
  if (!entry) throw new Error("Unknown scenario id. Read ads_scenarios; IDs must match discovered source entries.");
176
191
  if (getRegistry().diagnostics.length) throw new Error("Scenario registry has errors. Read ads://project/scenarios and fix its diagnostics.");
@@ -203,7 +218,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
203
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." });
204
219
  return json({ ...manifest, text: content.text.slice(offset, offset + limit), offset, ...(offset + limit < content.text.length ? { nextOffset: offset + limit } : {}) });
205
220
  } },
206
- { 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) => {
207
222
  if (!category && options.config.mcp.previewManifest) category = tokenScenarios()[0]?.id;
208
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.");
209
224
  if (category) {
@@ -243,22 +258,17 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
243
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 } : {}) } : {}) })) });
244
259
  } },
245
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)) },
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.",
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.",
247
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."),
248
268
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
249
- call: async (args, signal) => {
250
- const result = await edits.apply(args, signal);
251
- if (result.applied) {
252
- try {
253
- if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
254
- await refresh(true);
255
- }
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.` }]; }
257
- }
258
- return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
259
- },
260
- }] : []),
261
- ...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) => {
262
272
  // Validate readiness without embedding executable HTML in model results.
263
273
  const build = await previewBuild(app, signal);
264
274
  projectApp(options, app);
@@ -320,7 +330,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
320
330
  }).join("\n");
321
331
  }
322
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")]);
323
- const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, app.file)));
333
+ const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, previewArtifactPath(previewOptions(), app.file))));
324
334
  const assetFingerprint = () => resourceAssets().map(asset => asset.uri).sort().join("\n");
325
335
  let previous = fingerprint();
326
336
  let previousArtifacts = artifactFingerprint();
@@ -410,5 +420,5 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
410
420
  }
411
421
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, disconnect);
412
422
  process.once("exit", forceCleanup);
413
- return { close, url: host?.url };
423
+ return { close, url: host?.url, previewRevision: () => readPreviewManifest(withPreviewSession(loadConfig(root), previewWatch.resourceSession()))?.revision ?? null };
414
424
  }
@@ -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
+ }
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { loadConfig, isInside } from "./config.mjs";
5
5
  import { inspectScenarios } from "./scenarios.mjs";
6
6
  import { artifactDigest, createBuildReporter } from "./mcp/build-status.mjs";
7
- import { readPreviewManifest } from "./preview-resources.mjs";
7
+ import { cachedPreviewPublication, previewArtifactHtml, readPreviewManifest } from "./preview-resources.mjs";
8
+ import { previewArtifactPath, withPreviewSession } from "./preview-scope.mjs";
8
9
 
9
10
  const digest = value => createHash("sha256").update(value).digest("hex");
10
11
  const fileDigest = file => { try { return digest(readFileSync(file)); } catch { return null; } };
@@ -13,7 +14,7 @@ const fileDigest = file => { try { return digest(readFileSync(file)); } catch {
13
14
  * Cache state is development output, never proof that a failed build is current. */
14
15
  export function createPreviewCache(options, compilerFiles = []) {
15
16
  const { root, config } = options;
16
- const adapterFiles = [import.meta.filename, path.join(import.meta.dirname, "preview-resources.mjs"), path.join(import.meta.dirname, "scenarios-vite.mjs"), ...compilerFiles,
17
+ const adapterFiles = [import.meta.filename, path.join(import.meta.dirname, "preview-resources.mjs"), path.join(import.meta.dirname, "preview-scope.mjs"), path.join(import.meta.dirname, "scenarios-vite.mjs"), ...compilerFiles,
17
18
  ...["package.json", "package-lock.json", "bun.lock", "bun.lockb", "node_modules/.package-lock.json"].map(file => path.join(root, file))];
18
19
  const adapterDigest = () => digest(JSON.stringify(adapterFiles.map(file => [file, fileDigest(file)])));
19
20
  const loadedAdapter = adapterDigest();
@@ -62,14 +63,17 @@ export function createPreviewCache(options, compilerFiles = []) {
62
63
  ["scheduler", fileDigest(import.meta.filename)],
63
64
  ["node-runtime", `${process.version}/${process.platform}/${process.arch}`],
64
65
  ]));
65
- function reusable(entry) {
66
+ function reusable(entry, expectedHtml) {
66
67
  const cached = value.entries[entry.id];
67
68
  if (!cached || cached.metadata !== JSON.stringify(entry) || cached.compiler !== compilerDigest()) return false;
68
69
  if (!cached.dependencies || !Object.keys(cached.dependencies).length) return false;
69
70
  if (Object.entries(cached.dependencies).some(([file, hash]) => fileDigest(path.resolve(root, file)) !== hash)) return false;
70
- try { return cached.artifact === artifactDigest(path.join(root, entry.artifact)); } catch { return false; }
71
+ if (expectedHtml !== undefined && cached.artifact !== `sha256:${digest(expectedHtml)}`) return false;
72
+ const file = path.join(root, previewArtifactPath(options, entry.artifact));
73
+ if (expectedHtml !== undefined && !existsSync(file)) return true;
74
+ try { return cached.artifact === artifactDigest(file); } catch { return false; }
71
75
  }
72
- return { inputs, reusable, validated: stamp => value.validation === stamp,
76
+ return { inputs, reusable, graphRevision: () => value.graphRevision, recordGraph(revision) { value.graphRevision = revision; save(); }, validated: stamp => value.validation === stamp,
73
77
  assertCompiler() {
74
78
  if (adapterDigest() !== loadedAdapter) throw Object.assign(new Error("Preview compiler code or dependencies changed. Invoke the preview again to restart the builder and load current code."), { code: "ADS_PREVIEW_RESTART" });
75
79
  },
@@ -78,7 +82,7 @@ export function createPreviewCache(options, compilerFiles = []) {
78
82
  value.entries[entry.id] = { metadata: JSON.stringify(entry), compiler: compilerDigest(),
79
83
  dependencies: Object.fromEntries([...new Set([path.join(root, entry.file), ...dependencies])]
80
84
  .filter(file => isInside(root, file) && !file.includes(`${path.sep}node_modules${path.sep}`)).map(file => [path.relative(root, file), fileDigest(file)])),
81
- artifact: artifactDigest(path.join(root, entry.artifact)) };
85
+ artifact: artifactDigest(path.join(root, previewArtifactPath(options, entry.artifact))) };
82
86
  save();
83
87
  },
84
88
  };
@@ -87,11 +91,11 @@ export function createPreviewCache(options, compilerFiles = []) {
87
91
  /** Shared scheduling and cache; the project supplies its own React/Preact build. */
88
92
  export async function runScenarioCompiler(directory, { watched = false, validate, build, buildGraph, compilerFiles = [] }) {
89
93
  const root = path.resolve(directory);
90
- let options = loadConfig(root);
94
+ let options = withPreviewSession(loadConfig(root), process.env.ADS_MCP_BUILD_SESSION);
91
95
  if (buildGraph && (!options.config.mcp.previewManifest || !options.config.framework)) throw new Error("Resource preview builds require an explicit framework and mcp.previewManifest in ds.config.json.");
92
96
  const cache = createPreviewCache(options, compilerFiles);
93
97
  function registry() {
94
- options = loadConfig(root);
98
+ options = withPreviewSession(loadConfig(root), process.env.ADS_MCP_BUILD_SESSION);
95
99
  const result = inspectScenarios(options);
96
100
  if (result.diagnostics.length) throw Object.assign(new Error(result.diagnostics.map(item => `${item.file}: ${item.message}`).join("\n")), { scenarios: result.scenarios });
97
101
  mkdirSync(path.join(root, ".ds/scenarios"), { recursive: true });
@@ -104,7 +108,8 @@ export async function runScenarioCompiler(directory, { watched = false, validate
104
108
  if (!watched) throw error;
105
109
  registryFailure = error; entries = error.scenarios ?? [];
106
110
  }
107
- const reporter = createBuildReporter(root, { apps: entries.map(entry => ({ id: entry.id, file: entry.artifact })) });
111
+ const buildApps = () => entries.map(entry => ({ id: entry.id, file: previewArtifactPath(options, entry.artifact) }));
112
+ const reporter = createBuildReporter(root, { apps: buildApps() });
108
113
  const requested = new Set();
109
114
  const pending = new Set();
110
115
  let active;
@@ -202,8 +207,12 @@ export async function runScenarioCompiler(directory, { watched = false, validate
202
207
  }
203
208
  } };
204
209
  cache.assertCompiler();
205
- let reusable = entries.every(entry => cache.reusable(entry));
206
- if (reusable) { try { reusable = Boolean(readPreviewManifest(options, { verify: true })); } catch { reusable = false; } }
210
+ const cached = cachedPreviewPublication(options, entries, cache.graphRevision());
211
+ let reusable = cached && entries.every(entry => cache.reusable(entry, previewArtifactHtml(entry.id, cached.revision)));
212
+ if (reusable) {
213
+ try { readPreviewManifest(options, { verify: true }); }
214
+ catch { reusable = false; }
215
+ }
207
216
  const outcomes = await checkedBuild(stamp, () => reusable ? null : buildGraph(entries, options, track));
208
217
  const failure = outcomes.find(outcome => outcome.status === "rejected");
209
218
  if (failure) throw failure.reason;
@@ -212,12 +221,10 @@ export async function runScenarioCompiler(directory, { watched = false, validate
212
221
  const publication = outcomes[1].value;
213
222
  if (publication) {
214
223
  publication.publish(reporter.session);
215
- for (const entry of entries) cache.record(entry, [...dependencies, ...publication.assetFiles]);
224
+ for (const entry of entries) cache.record(entry, dependencies);
225
+ cache.recordGraph(publication.revision);
216
226
  } else {
217
- const file = path.join(root, options.config.mcp.previewManifest);
218
- const manifest = readPreviewManifest(options);
219
- writeFileSync(`${file}.tmp`, JSON.stringify({ ...manifest, session: reporter.session }));
220
- renameSync(`${file}.tmp`, file);
227
+ cached.publish(reporter.session);
221
228
  }
222
229
  for (const entry of entries) { pending.delete(entry.id); reporter.ready(entry.id, revisions.get(entry.id)); }
223
230
  publishedStamp = stamp;
@@ -281,7 +288,7 @@ export async function runScenarioCompiler(directory, { watched = false, validate
281
288
  try {
282
289
  entries = registry();
283
290
  registryFailure = undefined;
284
- reporter.setApps(entries.map(entry => ({ id: entry.id, file: entry.artifact })));
291
+ reporter.setApps(buildApps());
285
292
  reporter.clearFailure();
286
293
  request(false);
287
294
  for (const id of requested) if (!entries.some(entry => entry.id === id)) { requested.delete(id); pending.delete(id); }
@@ -8,6 +8,7 @@ import { projectCompilerOptions } from "./boundaries.mjs";
8
8
  import { inspectTokens } from "./tokens.mjs";
9
9
  import { isSharedToken } from "./token-policy.mjs";
10
10
  import { validTokenSamples } from "./token-samples.mjs";
11
+ import { scopedTokens } from "./scoped-tokens.mjs";
11
12
 
12
13
  export const scenarioArtifact = (root, id) => path.join(root, ".ds/scenarios", id, "index.html");
13
14
 
@@ -39,14 +40,16 @@ export function inspectScenarios(options, suppliedProgram) {
39
40
  const known = new Map(publicUi(program, options).map((item) => [item.name, item]));
40
41
  const checker = program.getTypeChecker();
41
42
  const behaviorApis = new Map(publicApi(program, options).filter(item => !item.renderable && item.behavioral).map(item => [item.name, item]));
42
- const tokenNames = new Set(inspectTokens(root).tokens.map(token => token.name));
43
+ const tokenInventory = inspectTokens(root);
44
+ const tokenNames = new Set(tokenInventory.tokens.map(token => token.name));
45
+ const scoped = scopedTokens(options);
43
46
  const scenarios = [];
44
47
  const diagnostics = [];
45
48
  const ids = new Set();
46
49
  function report(file, message) { diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "scenario-contract", line: 1, column: 1, message }); }
47
50
  for (const file of files) {
48
51
  const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
49
- const kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
52
+ let kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
50
53
  if (!["tokens", "components", "patterns", "composition"].includes(kind)) { report(file, "Scenarios live beside tokens/components/patterns or under composition/, not inside showcase pages."); continue; }
51
54
  const id = relative.replace(/(?:\/Scenario|\.scenario)\.[^.]+$/, "");
52
55
  if (id.length > 256) { report(file, "Scenario paths must fit within 256 characters."); continue; }
@@ -62,6 +65,11 @@ export function inspectScenarios(options, suppliedProgram) {
62
65
  for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === "scenario") metadata = literal(declaration.initializer, checker);
63
66
  }
64
67
  }
68
+ if (metadata?.kind !== undefined) {
69
+ const owner = scoped.inventory.owner(relative);
70
+ if (metadata.kind !== "tokens" || !scoped.definitions.some(entry => entry.unit === owner)) { report(file, "Only an owner with registered local tokens may set kind: tokens for a colocated token preview."); continue; }
71
+ kind = "tokens";
72
+ }
65
73
  if (!entry) report(file, "Export a default independently mountable scenario entrypoint.");
66
74
  const module = checker.getSymbolAtLocation(source);
67
75
  const defaultExport = module && checker.getExportsOfModule(module).find(item => item.name === "default");
@@ -77,7 +85,7 @@ export function inspectScenarios(options, suppliedProgram) {
77
85
  }
78
86
  const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
79
87
  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.");
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)
88
+ if (!metadata || Object.keys(metadata).some((key) => !["title", "kind", "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
89
  || Object.hasOwn(metadata, "exercises") && (!Array.isArray(metadata.exercises) || metadata.exercises.some(name => typeof name !== "string" || !behaviorApis.has(name)))
82
90
  || Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--")))
83
91
  || kind === "tokens" && (Object.hasOwn(metadata, "tokens") || !validTokenSamples(metadata.samples) || !metadata.covers.some(name => known.get(name)?.kind === "patterns"))
@@ -86,7 +94,11 @@ export function inspectScenarios(options, suppliedProgram) {
86
94
  }
87
95
  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.");
88
96
  const tokens = [...new Set([...(metadata.tokens ?? []), ...(metadata.samples ?? []).map(sample => sample.token)])];
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.` });
97
+ 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 token from its defining owner.` });
98
+ if (kind === "tokens") {
99
+ const owner = scoped.inventory.owner(relative);
100
+ for (const name of tokens) if (tokenInventory.tokens.find(token => token.name === name)?.owner !== owner) report(file, `Token ${name} needs a preview beside its defining owner. Shared tokens belong under the configured token directory; local tokens stay with their component/pattern.`);
101
+ }
90
102
  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("\\", "/") });
91
103
  }
92
104
  return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
@@ -209,3 +221,33 @@ export function writeScenarioRegistry(options, write) {
209
221
  write({ schemaVersion: 1, scenarios: registry.scenarios });
210
222
  return registry.scenarios;
211
223
  }
224
+
225
+ /** Locate the actual consumed array, rather than regenerating a whole fixture. */
226
+ export function tokenSampleSource(options, scenario) {
227
+ const file = path.join(options.root, scenario.file);
228
+ const program = ts.createProgram([file], { ...projectCompilerOptions(options), allowJs:true, resolveJsonModule:true });
229
+ const checker = program.getTypeChecker(), source = program.getSourceFile(file);
230
+ function resolve(node, seen = new Set()) {
231
+ while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression;
232
+ if (!node || seen.has(node)) return null;
233
+ if (ts.isArrayLiteralExpression(node) || ts.isObjectLiteralExpression(node)) return node;
234
+ let symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
235
+ if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
236
+ const declaration = symbol?.valueDeclaration;
237
+ return declaration?.initializer ? resolve(declaration.initializer, new Set([...seen,node])) : null;
238
+ }
239
+ for (const statement of source.statements) if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) {
240
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "scenario") continue;
241
+ const metadata = resolve(declaration.initializer);
242
+ const property = metadata && ts.isObjectLiteralExpression(metadata) && metadata.properties.find(node => ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && node.name.text === "samples");
243
+ const array = property && resolve(property.initializer);
244
+ if (array && ts.isArrayLiteralExpression(array)) {
245
+ const source = array.getSourceFile();
246
+ const scoped = scopedTokens(options), owner = scoped.inventory.owner(scoped.inventory.relative(scenario.file));
247
+ const relative = path.relative(options.root, source.fileName).replaceAll("\\", "/");
248
+ if (!isDevelopmentFile(source.fileName, options) || scoped.inventory.owner(scoped.inventory.relative(relative)) !== owner) throw new Error("The consumed samples array must live in this scenario's own development fixture.");
249
+ return {file:relative, content:source.text, start:array.getStart(source), end:array.end};
250
+ }
251
+ }
252
+ throw new Error("Cannot locate a literal consumed samples array. Keep fixture.samples statically inspectable; edit other fixture shapes with a normal owner patch.");
253
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import postcss from "postcss";
4
+ import ts from "typescript";
5
+ import { inspectUnits } from "./units.mjs";
6
+ import { isDevelopmentFile } from "./config.mjs";
7
+ import { foundationSelector } from "./foundation.mjs";
8
+ import { isSharedToken, tokenContract } from "./token-policy.mjs";
9
+
10
+ /** An explicit owner contract, never a heuristic based on a token's name. */
11
+ export function scopedTokens(options) {
12
+ if (options.scopedTokens) return options.scopedTokens;
13
+ const inventory = inspectUnits(options), definitions = [], diagnostics = [];
14
+ const report = (file, message, line = 1) => diagnostics.push({rule:"scoped-token", file, message, line, column:1});
15
+ for (const unit of inventory.units.filter(unit => ["components", "patterns"].includes(unit.kind))) {
16
+ for (const manifest of unit.files.filter(file => /(?:^|\/)tokens\.json$|\.tokens\.json$/.test(file))) {
17
+ const absolute = path.join(options.root, manifest);
18
+ try {
19
+ if (statSync(absolute).size > 65536 || realpathSync(absolute) !== absolute) throw new Error("Token manifests must be ordinary files no larger than 64 KiB.");
20
+ const data = JSON.parse(readFileSync(absolute, "utf8"));
21
+ const stylesheet = manifest.replace(/\.json$/, ".css");
22
+ if (!data || data.schemaVersion !== 1 || Object.keys(data).some(key => !["schemaVersion", "namespace"].includes(key))
23
+ || typeof data.namespace !== "string" || !/^--[a-z][a-z0-9-]*-$/.test(data.namespace)
24
+ || /^(?:--ds-|--ux-|--showcase-)/.test(data.namespace) || isSharedToken(data.namespace + "value")
25
+ || tokenContract.naming.prefixes.some(prefix => prefix.startsWith(data.namespace))
26
+ || [...tokenContract.naming.names,...Object.keys(tokenContract.tokens)].some(name => name.startsWith(data.namespace))) throw new Error("Use {schemaVersion:1, namespace:\"--<owner>-\"}; shared and runtime-variable prefixes are reserved.");
27
+ if (definitions.some(entry => entry.unit === unit.id || entry.namespace.startsWith(data.namespace) || data.namespace.startsWith(entry.namespace))) throw new Error("Each unit has one token namespace; namespaces must not overlap other owners.");
28
+ if (!unit.files.includes(stylesheet) || !existsSync(path.join(options.root, stylesheet))) throw new Error(`Colocate the definition-only stylesheet ${stylesheet}.`);
29
+ const file = path.join(options.root, stylesheet);
30
+ if (realpathSync(file) !== file) throw new Error("Scoped token stylesheets cannot resolve through symlinks.");
31
+ const css = postcss.parse(readFileSync(file, "utf8"), {from:file});
32
+ let count = 0;
33
+ css.walk(node => {
34
+ if (node.type === "comment" || node.type === "root") return;
35
+ if (node.type === "atrule" && ["media", "supports", "layer"].includes(node.name) && node.nodes) return;
36
+ if (node.type === "rule" && node.parent.type !== "rule" && !foundationSelector(node.selector, {definitions:true})) return;
37
+ if (node.type === "decl" && node.parent.type === "rule" && node.prop.startsWith(data.namespace) && node.prop.length > data.namespace.length && !node.important) { count++; return; }
38
+ report(stylesheet, "Owner token sheets contain only namespaced custom-property definitions under root/theme conditions. UI selectors, imports, font faces, animations and ordinary declarations belong in their runtime owners.", node.source?.start.line);
39
+ });
40
+ if (!count) report(stylesheet, "Define at least one real namespaced token.");
41
+ const imported = unit.files.filter(file => /\.[cm]?[jt]sx?$/.test(file) && !isDevelopmentFile(path.join(options.root, file), options)).some(source => {
42
+ const ast = ts.createSourceFile(source, readFileSync(path.join(options.root, source), "utf8"), ts.ScriptTarget.Latest, true);
43
+ return ast.statements.some(node => ts.isImportDeclaration(node) && !node.importClause && ts.isStringLiteralLike(node.moduleSpecifier) && path.resolve(options.root, path.dirname(source), node.moduleSpecifier.text) === file);
44
+ });
45
+ if (!imported) report(manifest, `Import ./${path.basename(stylesheet)} from this owner's runtime source so its real preview and product consume the same definitions.`);
46
+ definitions.push({unit:unit.id, namespace:data.namespace, manifest, file:stylesheet});
47
+ } catch (error) { report(manifest, error.reason ?? error.message, error.line); }
48
+ }
49
+ }
50
+ const result = {definitions, diagnostics, inventory};
51
+ options.scopedTokens = result;
52
+ return result;
53
+ }
54
+
55
+ export function scopedTokenOwner(options, name) {
56
+ return scopedTokens(options).definitions.find(entry => name.startsWith(entry.namespace));
57
+ }
@@ -36,8 +36,8 @@ and `boundaries.styleWrites` entries together with its source. Unused permission
36
36
  fail the core checks; do not pre-register a boundary without its implementation.
37
37
  Use `dryRun: true` to
38
38
  inspect validation; applying re-runs the gates. Fix rejected patches in their
39
- owner. Report unsupported coupled changes instead of bypassing a rejection with
40
- direct source writes. Shared styles/helpers use `foundation`, root guides use
39
+ owner. Use `ads_apply_transaction` for genuinely coupled owner patches;
40
+ never bypass a rejection with direct source writes. Shared styles/helpers use `foundation`, root guides use
41
41
  `documentation`, and branding/fonts use their discovered `assets/*` unit.
42
42
  Intentional configuration, rule exceptions, registered guard code and compiler
43
43
  changes use `configuration`; validate the proposed settings and keep mandatory
@@ -45,6 +45,8 @@ ADS gates. For binary/large assets, generate `git diff --binary --full-index` in
45
45
  `.ds/patches/<name>.patch` and send `patchFile` instead of inline `patch`, so bytes
46
46
  stay out of model context. Binary changes require Git. When editing is disabled,
47
47
  use the client's normal source-editing tools.
48
+ For owner drafts, token-sample reconciliation, coupled transactions and local
49
+ constant namespaces, read [guarded-editing.md](references/guarded-editing.md).
48
50
 
49
51
 
50
52
  Controllers are UI behavior owners: private helpers stay with their component or