@reqvire-org/ads 0.3.16 → 0.3.18

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 (59) hide show
  1. package/README.md +3 -0
  2. package/dist/atomic-composition.mjs +1 -1
  3. package/dist/check.mjs +43 -11
  4. package/dist/evidence.mjs +18 -0
  5. package/dist/investigations.mjs +101 -0
  6. package/dist/linaria/preserve-values.cjs +21 -2
  7. package/dist/mcp/behavior-pages.mjs +36 -0
  8. package/dist/mcp/context.mjs +3 -1
  9. package/dist/mcp/execution-results.mjs +146 -0
  10. package/dist/mcp/mutations.mjs +34 -17
  11. package/dist/mcp/preview-widget.bundle.js +12 -12
  12. package/dist/mcp/resource-loader.js +3 -3
  13. package/dist/mcp/result-pages.mjs +87 -0
  14. package/dist/mcp/server.mjs +67 -30
  15. package/dist/property-registrations.mjs +39 -0
  16. package/dist/scenario-selectors.mjs +140 -0
  17. package/dist/scenario-trace.mjs +1 -1
  18. package/dist/scoped-tokens.mjs +6 -0
  19. package/dist/selector-match.mjs +94 -0
  20. package/dist/showcase/client.bundle.js +2 -2
  21. package/dist/skills/ads-break-ui/SKILL.md +8 -3
  22. package/dist/skills/ads-break-ui/references/atomic-styles.md +48 -0
  23. package/dist/skills/ads-break-ui/references/captures.md +6 -1
  24. package/dist/skills/ads-break-ui/references/evidence.md +120 -0
  25. package/dist/skills/ads-break-ui/references/guarded-editing.md +8 -5
  26. package/dist/skills/ads-break-ui/references/scenarios.md +32 -0
  27. package/dist/skills/ads-designer/SKILL.md +8 -3
  28. package/dist/skills/ads-designer/references/atomic-styles.md +48 -0
  29. package/dist/skills/ads-designer/references/captures.md +6 -1
  30. package/dist/skills/ads-designer/references/evidence.md +120 -0
  31. package/dist/skills/ads-designer/references/guarded-editing.md +8 -5
  32. package/dist/skills/ads-designer/references/scenarios.md +32 -0
  33. package/dist/skills/ads-import/SKILL.md +8 -3
  34. package/dist/skills/ads-import/references/atomic-styles.md +48 -0
  35. package/dist/skills/ads-import/references/captures.md +6 -1
  36. package/dist/skills/ads-import/references/evidence.md +120 -0
  37. package/dist/skills/ads-import/references/guarded-editing.md +8 -5
  38. package/dist/skills/ads-import/references/scenarios.md +32 -0
  39. package/dist/skills/ads-migrate/SKILL.md +8 -3
  40. package/dist/skills/ads-migrate/references/atomic-styles.md +48 -0
  41. package/dist/skills/ads-migrate/references/captures.md +6 -1
  42. package/dist/skills/ads-migrate/references/evidence.md +120 -0
  43. package/dist/skills/ads-migrate/references/guarded-editing.md +8 -5
  44. package/dist/skills/ads-migrate/references/scenarios.md +32 -0
  45. package/dist/skills/ads-motion/SKILL.md +8 -3
  46. package/dist/skills/ads-motion/references/atomic-styles.md +48 -0
  47. package/dist/skills/ads-motion/references/captures.md +6 -1
  48. package/dist/skills/ads-motion/references/evidence.md +120 -0
  49. package/dist/skills/ads-motion/references/guarded-editing.md +8 -5
  50. package/dist/skills/ads-motion/references/scenarios.md +32 -0
  51. package/dist/verification/browser.mjs +22 -7
  52. package/dist/verification/playwright-reporter.cjs +23 -5
  53. package/dist/verification.mjs +31 -9
  54. package/docs/ADS.md +10 -0
  55. package/docs/ARCHITECTURE.md +47 -3
  56. package/docs/BEHAVIORS.md +30 -0
  57. package/docs/CHANGELOG.md +30 -0
  58. package/docs/MCP.md +66 -7
  59. package/package.json +2 -1
@@ -31,14 +31,14 @@ export class PreviewResourceLoader {
31
31
  if (!record) throw new Error(`Unknown preview dependency: ${uri}`);
32
32
  let response;
33
33
  try { response = await this.read(uri); }
34
- catch (error) { throw new Error(`Preview resource read failed for ${uri}: ${error.message}`, { cause: error }); }
34
+ catch (error) { throw Object.assign(new Error(`Preview resource read failed for ${uri}: ${error.message}`, { cause: error }), { code: "ADS_RESOURCE_READ", category: "transport", resourceUri: uri }); }
35
35
  if (this.closed) throw new Error("The preview App was closed.");
36
36
  const content = response.contents.find(item => item.uri === uri);
37
- if (!content) throw new Error(`Missing preview dependency: ${uri}`);
37
+ if (!content) throw Object.assign(new Error(`Missing preview dependency: ${uri}`), { code: "ADS_RESOURCE_CONTENT", category: "build", resourceUri: uri });
38
38
  const bytes = typeof content.text === "string" ? new TextEncoder().encode(content.text) : Uint8Array.from(atob(content.blob), letter => letter.charCodeAt(0));
39
39
  const digest = await crypto.subtle.digest("SHA-256", bytes);
40
40
  const actual = "sha256:" + [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, "0")).join("");
41
- if (bytes.length !== record.size || actual !== record.digest) throw new Error("Preview resource integrity check failed. Rebuild and reopen the preview.");
41
+ if (bytes.length !== record.size || actual !== record.digest) throw Object.assign(new Error("Preview resource integrity check failed. Rebuild and reopen the preview."), { code: "ADS_RESOURCE_INTEGRITY", category: "build", resourceUri: uri });
42
42
  return bytes;
43
43
  })();
44
44
  this.bytes.set(uri, task);
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const resultBudgets = { summary: 4096, page: 8192, row: 2048, defaultLimit: 10, maxLimit: 20 };
4
+ export const jsonBytes = value => Buffer.byteLength(JSON.stringify(value));
5
+
6
+ /** Projection is only for delivery; validation always uses the complete result. */
7
+ export function compactValue(input, { string = 500, array = 8, keys = 24, depth = 5 } = {}) {
8
+ let truncated = false;
9
+ function visit(value, level) {
10
+ if (typeof value === "string") {
11
+ if (value.length <= string) return value;
12
+ truncated = true; return `${value.slice(0, string)}…`;
13
+ }
14
+ if (value === null || typeof value === "number" || typeof value === "boolean" || value === undefined) return value;
15
+ if (level >= depth) { truncated = true; return "[omitted]"; }
16
+ if (Array.isArray(value)) { if (value.length > array) truncated = true; return value.slice(0, array).map(item => visit(item, level + 1)); }
17
+ const entries = Object.entries(value);
18
+ if (entries.length > keys) truncated = true;
19
+ return Object.fromEntries(entries.slice(0, keys).map(([key, item]) => [key, visit(item, level + 1)]));
20
+ }
21
+ const value = visit(input, 0);
22
+ return { value, truncated };
23
+ }
24
+
25
+ export function compactRow(input, bytes = resultBudgets.row) {
26
+ if (input === null || typeof input !== "object" || Array.isArray(input)) input = { value: input };
27
+ for (const limits of [{}, { string: 160, array: 3, keys: 18 }, { string: 60, array: 1, keys: 12, depth: 3 }]) {
28
+ const { value, truncated } = compactValue(input, limits);
29
+ const row = { ...value, ...(truncated ? { detailTruncated: true } : {}) };
30
+ if (jsonBytes(row) <= bytes) return row;
31
+ }
32
+ const row = Object.fromEntries(["recordIndex", "id", "unit", "check", "status", "passed", "rule", "file", "path", "name"].filter(key => input[key] !== undefined).slice(0, 4).map(key => [key, typeof input[key] === "string" ? input[key].slice(0, 20) : input[key]]));
33
+ const result = { ...row, detailTruncated: true };
34
+ return jsonBytes(result) <= bytes ? result : { detailTruncated: true };
35
+ }
36
+
37
+ export function compactAcceptance(value) {
38
+ if (!value) return undefined;
39
+ const establishedCount=value.establishedCount??value.established?.length??0,unverifiedCount=value.unverifiedCount??value.unverified?.length??0;
40
+ return { scope: value.scope, status: value.status, establishedCount, unverifiedCount,
41
+ established: (value.established ?? []).slice(0, 3).map(item => String(item).slice(0, 100)),
42
+ unverified: (value.unverified ?? []).slice(0, 12).map(item => String(item).slice(0, 100)),
43
+ ...(value.detailTruncated || establishedCount > 3 || unverifiedCount > 12 ? { detailTruncated: true } : {}) };
44
+ }
45
+
46
+ /** The cursor binds the immutable result/query, not just an array offset. */
47
+ export function resultPage(items, { cursor, limit = resultBudgets.defaultLimit, identity, header = {}, key = "items" } = {}) {
48
+ if (!Number.isInteger(limit) || limit < 1 || limit > resultBudgets.maxLimit) throw new Error("Use a page limit from 1 to 20.");
49
+ const stamp = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24);
50
+ let offset = 0;
51
+ if (cursor !== undefined) {
52
+ if (typeof cursor !== "string" || cursor.length > 300) throw new Error("Invalid result cursor.");
53
+ const match = /^([a-f0-9]{24}):(\d+)$/.exec(cursor);
54
+ offset = match && match[1] === stamp ? Number(match[2]) : -1;
55
+ if (!Number.isSafeInteger(offset) || offset <= 0 || offset >= items.length) throw new Error("Invalid or stale result cursor; restart this listing.");
56
+ }
57
+ const selected = [];
58
+ const envelope = () => ({ ...header, total: items.length, returned: selected.length, byteLimit: resultBudgets.page, [key]: selected,
59
+ ...(offset + selected.length < items.length ? { nextCursor: `${stamp}:${offset + selected.length}` } : {}) });
60
+ for (const item of items.slice(offset, offset + limit)) {
61
+ selected.push(compactRow(item));
62
+ if (jsonBytes(envelope()) > resultBudgets.page) { selected.pop(); break; }
63
+ }
64
+ if (offset < items.length && !selected.length) throw new Error("The page envelope exceeds the response budget.");
65
+ const result = envelope();
66
+ if (jsonBytes(result) > resultBudgets.page) throw new Error("The page exceeds its response budget.");
67
+ return result;
68
+ }
69
+
70
+ export function compactProgress(value) {
71
+ if (!value) return undefined;
72
+ const { stage, profile, elapsedMs, timeoutMs, total, completed, pending, workers } = value;
73
+ return { stage, profile, elapsedMs, timeoutMs, total, completed, pending, workers, activeCount: value.active?.length ?? 0,
74
+ active: (value.active ?? []).slice(0, 3).map(item => compactRow(item, 512)) };
75
+ }
76
+
77
+ /** Exact JSON ranges complement lossy row previews without unbounded replies. */
78
+ export function resultRecord(rows, { recordIndex, offset = 0, chars = 2000, header = {} } = {}) {
79
+ if (!Number.isInteger(recordIndex) || recordIndex < 0 || recordIndex >= rows.length || !Number.isInteger(offset) || offset < 0 || !Number.isInteger(chars) || chars < 1 || chars > 2000) throw new Error("Read a valid recordIndex with an offset and 1–2000 characters.");
80
+ const source=JSON.stringify(rows[recordIndex]);
81
+ if(offset>source.length)throw new Error("Offset exceeds the selected record.");
82
+ let end=Math.min(source.length,offset+chars);
83
+ const result=()=>({...header,recordIndex,offset,totalChars:source.length,text:source.slice(offset,end),nextOffset:end<source.length?end:null,byteLimit:resultBudgets.page});
84
+ while(jsonBytes(result())>resultBudgets.page&&end>offset)end=offset+Math.floor((end-offset)*0.8);
85
+ if(jsonBytes(result())>resultBudgets.page||end===offset&&offset<source.length)throw new Error("Record identity exceeds the response budget.");
86
+ return result();
87
+ }
@@ -5,7 +5,9 @@ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/s
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
+ import { createVerificationController, BEHAVIOR_URI } from "../verification.mjs";
9
+ import { INVESTIGATIONS_URI, investigationIndex, investigationPage, investigationSections, readInvestigationResource } from "../investigations.mjs";
10
+ import { failure } from "../evidence.mjs";
9
11
  import { inspectTokens } from "../tokens.mjs";
10
12
  import { inspectScenarios } from "../scenarios.mjs";
11
13
  import { startShowcaseHost } from "./showcase-host.mjs";
@@ -24,6 +26,9 @@ import { reconcileTokenSamples } from "../token-fixtures.mjs";
24
26
  import { createCaptureController } from "../capture.mjs";
25
27
  import { CAPTURES_URI, captureSpec, screenshotFields } from "../capture-store.mjs";
26
28
  import { previewSourceRevision } from "../preview-inputs.mjs";
29
+ import { createExecutionResults, RESULTS_URI, resultSections } from "./execution-results.mjs";
30
+ import { behaviorPage, behaviorSections } from "./behavior-pages.mjs";
31
+ import { compactProgress, resultBudgets } from "./result-pages.mjs";
27
32
 
28
33
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
29
34
  class InvalidParamsError extends ProtocolError {
@@ -38,10 +43,18 @@ function captureResponse(value, resourceLinks = false) {
38
43
  }
39
44
  const empty = z.object({}).strict();
40
45
  const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
46
+ const recordRange = { recordIndex: z.number().int().min(0).optional(), offset: z.number().int().min(0).optional(), chars: z.number().int().min(1).max(2000).optional() };
47
+ const paged = { cursor: z.string().max(300).optional(), limit: z.number().int().min(1).max(resultBudgets.maxLimit).optional() };
41
48
  const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
42
49
  const schemas = { params: z.object({ cursor: z.string().optional() }).passthrough(), result: z.object({ resultType: z.literal("complete") }).passthrough() };
43
50
  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.");
44
51
 
52
+ function resourceQuery(uri, allowed) {
53
+ const url=new URL(uri);
54
+ if([...url.searchParams.keys()].some(key=>!allowed.includes(key)))throw new Error("Unknown paged resource query.");
55
+ return Object.fromEntries([...url.searchParams].map(([key,value])=>[key,["limit","recordIndex","offset","chars"].includes(key)?Number(value):value]));
56
+ }
57
+
45
58
  export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollInterval = 1000, previewWatch, showcaseUrl, notifications, inspection, mutations = false } = {}) {
46
59
  const root = path.resolve(directory);
47
60
  const cache = inspection ?? {};
@@ -49,6 +62,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
49
62
  const captures = cache.captures ??= createCaptureController(root);
50
63
  const edits = cache.edits ??= createMutationController(root, { captures });
51
64
  const verification = cache.verification ??= createVerificationController(root);
65
+ const executionResults = cache.executionResults ??= createExecutionResults(root);
52
66
  let options = loadConfig(root);
53
67
  const signature = `${root}:${JSON.stringify(options.config)}`;
54
68
  if (cache.signature !== signature) {
@@ -108,6 +122,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
108
122
  return [
109
123
  { 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" },
110
124
  { uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
125
+ { uri: "ads://guides/evidence", name: "Failure boundaries and compact investigations", mimeType: "text/markdown" },
111
126
  { uri: "ads://guides/atomic-styles", name: "Atomic styles: composition conflicts and custom-property extraction", mimeType: "text/markdown" },
112
127
  { uri: "ads://guides/scenarios", name: "Scenario coverage and public class-helper contracts", mimeType: "text/markdown" },
113
128
  { uri: "ads://guides/captures", name: "On-demand captures and candidate visual review", mimeType: "text/markdown" },
@@ -117,38 +132,42 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
117
132
  { uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
118
133
  { uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
119
134
  { uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
135
+ { uri: RESULTS_URI, name: "Latest execution summaries and paged outcomes", mimeType: "application/json" },
136
+ { uri: INVESTIGATIONS_URI, name: "Latest per-check investigation evidence", mimeType: "application/json" },
120
137
  { uri: CAPTURES_URI, name: "On-demand current and candidate screenshots", mimeType: "application/json" },
121
138
  { uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
139
+ // Keep fallback workflow metadata discoverable in the bounded first page.
140
+ ...skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description, mimeType: "text/markdown" })),
122
141
  ...documents.filter(document => promoted.has(document.uri)).sort((a, b) => Number(b.uri === context.publicEntryUri) - Number(a.uri === context.publicEntryUri)).map(({ file: _file, ...document }) => document),
123
142
  { uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
124
143
  { uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
125
144
  ...(options.config.mcp.previewManifest ? [{ uri: PREVIEW_APP_URI, name: "Shared-resource preview App", mimeType: APP_MIME }, { uri: PREVIEW_MANIFEST_URI, name: "Preview dependency graph", mimeType: "application/json" }] : []),
126
145
  ...(options.config.mcp.previewManifest ? resourceAssets() : []),
127
146
  ...allApps().map((app) => ({ uri: appUri(app.id), name: app.title, description: app.description, mimeType: APP_MIME })),
128
- ...skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description, mimeType: "text/markdown" })),
129
147
  ...documents.filter((document) => !promoted.has(document.uri)).map(({ file: _file, ...document }) => document),
130
148
  ...[...skills.files.values()].filter((file) => !skillEntries.has(file.uri)).map(({ uri, name, mimeType }) => ({ uri, name, mimeType })),
131
149
  ];
132
150
  }
133
151
  function status() {
134
- const context = project();
152
+ const context = project(), mutationState = mutations ? edits.status() : null, behaviorState = verification.status();
135
153
  return { projectRoot: root, workflow: workflow([...previews(), ...scenarioApps()]), project: context,
136
154
  package: pkg.name, version: pkg.version, dsDir: options.config.dsDir, publicAlias: options.config.publicAlias,
137
155
  components: getCatalog().filter((item) => item.kind === "components" && item.apiKind === "ui").length,
138
156
  patterns: getCatalog().filter((item) => item.kind === "patterns" && item.apiKind === "ui").length,
139
157
  tokenCount: inspectTokens(root).tokens.length, showcaseUrl: getShowcaseUrl(),
140
158
  previewWatch: previewWatch?.status() ?? { state: "disabled" },
141
- mutations: mutations ? edits.status() : { enabled: false },
159
+ mutations: mutations ? { ...mutationState, progress: compactProgress(mutationState.progress), lastResult: executionResults.latest("mutation") } : { enabled: false },
142
160
  build: summarizeBuild(builds()),
143
161
  previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
144
162
  scenarios: { count: getRegistry().scenarios.length, diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios`, tool: "ads_show_scenario" },
145
- behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", ...verification.status() },
163
+ behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", investigationTool: "ads_investigate", investigationResourceUri: INVESTIGATIONS_URI, ...behaviorState, progress: compactProgress(behaviorState.progress), lastResult: executionResults.latest("verification"), resultsTool: "ads_results", resultsResourceUri: RESULTS_URI },
146
164
  captures: { resourceUri: CAPTURES_URI, tool: "ads_capture", clearTool: "ads_capture_clear", ...captures.status() },
147
165
  experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
148
166
  }
149
167
  async function read(uri, signal) {
150
168
  if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
151
169
  if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
170
+ if (uri === "ads://guides/evidence") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/evidence.md", import.meta.url), "utf8") }] };
152
171
  if (uri === "ads://guides/atomic-styles") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/atomic-styles.md", import.meta.url), "utf8") }] };
153
172
  if (uri === "ads://guides/scenarios") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/scenarios.md", import.meta.url), "utf8") }] };
154
173
  if (uri === "ads://guides/captures") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/captures.md", import.meta.url), "utf8") }] };
@@ -160,13 +179,19 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
160
179
  else if (uri === `${PROJECT_URI}/catalog`) value = { items: getCatalog() };
161
180
  else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
162
181
  else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
163
- else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
182
+ else if (uri === BEHAVIOR_URI || uri.startsWith(`${BEHAVIOR_URI}?`)) value = behaviorPage(options, resourceQuery(uri, ["unit", "api", "query", "section", "cursor", "limit"]));
183
+ else if (uri === INVESTIGATIONS_URI || uri.startsWith(`${INVESTIGATIONS_URI}?`)) value = investigationPage(root, resourceQuery(uri, ["unit", "check", "runId", "section", "cursor", "limit", "recordIndex", "offset", "chars"]));
184
+ else if (uri === RESULTS_URI || uri.startsWith("ads://results/")) return { contents: [executionResults.readResource(uri)] };
164
185
  else if (uri === CAPTURES_URI) value = captureIndex();
165
186
  else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
166
187
  value = readPreviewManifest(previewOptions());
167
188
  if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
168
189
  }
169
190
  if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
191
+ if (uri.startsWith("ads://investigations/") || uri.startsWith("ads://verification-artifacts/")) {
192
+ try { return {contents:[readInvestigationResource(root,uri)]}; }
193
+ catch(error){throw new ResourceNotFoundError(uri,error.message);}
194
+ }
170
195
  if (uri.startsWith("ads://captures/")) {
171
196
  try { const inventory = captureInventory(); return { contents: [captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length })] }; }
172
197
  catch (error) { if (error.code === "ADS_CAPTURE_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
@@ -198,6 +223,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
198
223
  return build;
199
224
  }
200
225
 
226
+ async function finishExecution(kind, result) {
227
+ const summary = executionResults.save(kind, result);
228
+ try { await refresh(); } catch (error) { process.stderr.write(`ads mcp: result saved; notification failed: ${error.message}\n`); }
229
+ return { ...json(summary), ...(!result.ok ? { isError: true } : {}) };
230
+ }
201
231
  async function applyEdits(args,signal,progress) {
202
232
  const result = await edits.apply(args,signal,progress);
203
233
  if (result.screenshots) await refresh();
@@ -208,7 +238,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
208
238
  await refresh(true);
209
239
  } 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.`}];}
210
240
  }
211
- return {...captureResponse(result, era === "modern"),...(!result.ok ? {isError:true} : {})};
241
+ return finishExecution("mutation", { ...(args.patches ? {units:args.patches.map(item=>item.unit),transaction:true} : {unit:args.unit}), ...result });
212
242
  }
213
243
  function tools() {
214
244
  return [
@@ -244,7 +274,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
244
274
  } },
245
275
  { name: "ads_check", description: "Run read-only core ADS architecture checks; returns blocking diagnostics separately from nonblocking advisories, with warnings before informational limits. Atomic warnings include helper/forwarded-class paths and ads://guides/atomic-styles guidance; unresolved paths do not establish safe composition. Project Oxlint, typecheck, build and browser tests remain separate.", schema: z.object({ limit: z.number().int().min(1).max(50).default(20) }).strict(), call: ({ limit }) => {
246
276
  const result = checkProject(root);
247
- 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` });
277
+ return json({ ok: result.ok, acceptance:result.acceptance, 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` });
248
278
  } },
249
279
  { 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 })) }) },
250
280
  { 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 }) => {
@@ -272,22 +302,12 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
272
302
  }
273
303
  return json({ widget: { resourceUri: TOKENS_APP_URI }, category: null, tokenCount: inspectTokens(root).tokens.length, dataUri: `${PROJECT_URI}/tokens`, showcaseUrl: getShowcaseUrl() });
274
304
  } },
275
- { 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 }) => {
276
- const inventory = behaviorStatus(options);
277
- 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()));
278
- return json({ total: matches.length, contracts: matches.slice(0, limit).map(item => {
279
- if (unit) return item;
280
- const apis = Object.values(item.subjects).filter(subject => subject.api).map(subject => subject.api);
281
- const demonstrations = [...new Set(Object.values(item.demonstrations).flat())];
282
- return { unit: item.unit, file: item.file, apis: apis.slice(0, 20), apiCount: apis.length,
283
- guarantees: item.guarantees.slice(0, 5).map(guarantee => ({ id: guarantee.id, result: guarantee.result })), guaranteeCount: item.guarantees.length,
284
- demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
285
- }), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
286
- } },
287
- { 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. Progress and per-check timings are available through ads_status; a profile timeout bounds the whole run. 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, progress) => {
305
+ { name: "ads_behaviors", description: "Discover behavior owners, then page an exact unit's checks, guarantees, subjects, demonstrations or profiles. Summary listings never embed runner logs. Default 10/max 20 entries and 8 KiB JSON per page; continue with nextCursor and identical filters. Cursors expire when discovery/evidence changes. Read sourceFile with ads_read_file for a complete contract. Metadata and reachable calls do not prove execution; use ads_verify for registered checks.", schema: z.object({ query: z.string().max(200).optional(), unit: z.string().min(1).max(256).optional(), api: z.string().max(200).optional(), section: z.enum(behaviorSections).optional(), ...paged }).strict(), call: args => json(behaviorPage(options,args)) },
306
+ { name: "ads_results", title: "Read execution outcomes", description: "Read saved ads_verify/ads_apply_patch/ads_apply_transaction outcomes without rerunning anything. Pass the returned resultId; select outcomes, checks, diagnostics, advisories, warnings, changes, captures, acceptance, investigation or selection. Default 10/max 20 rows, at most 8 KiB JSON per page. Follow nextCursor with identical section/status. Rows marked detailTruncated can be read in full using recordIndex and offset/chars (max 2000); follow nextOffset. Only the latest verification and mutation snapshots are retained; replaced IDs fail explicitly. For one outcome's browser evidence use ads_investigate with its exact unit/check/runId.", schema: z.object({ resultId: z.string().uuid(), section: z.enum(resultSections).optional(), status: z.enum(["all","passed","not_passed"]).optional(), ...paged, ...recordRange }).strict(), call: args => json(executionResults.page(args)) },
307
+ { name: "ads_investigate", title: "Inspect check evidence", description: "Read a summary for an exact unit/check, or list latest investigations when no check is selected. Pass the outcome's runId to reject superseded evidence. Summary is at most 4 KiB JSON; details, requests, browserErrors and artifacts are separate paged sections (default 10/max 20 entries, 8 KiB). Read truncated rows with recordIndex/offset/chars and the exact runId. Artifact URIs are read separately. Does not rerun checks or infer root cause. Only the latest bundle per check is retained, including candidate runs.", schema: z.object({unit:z.string().min(1).max(256).optional(),check:z.string().min(1).max(100).optional(),runId:z.string().min(1).max(100).optional(),section:z.enum(investigationSections).optional(),...paged,...recordRange}).strict(), call: args => json(investigationPage(root,args)) },
308
+ { 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. Progress and per-check timings are available through ads_status; a profile timeout bounds the whole run. 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. Replies contain counts, acceptance, at most three failure summaries and a resultId (4 KiB JSON maximum). Page outcomes with ads_results; inspect one exact unit/check/runId with ads_investigate. 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, progress) => {
288
309
  const result = await verification.run(args, signal, progress);
289
- await refresh();
290
- return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
310
+ return finishExecution("verification", {unit:args.unit,...result});
291
311
  } },
292
312
  { name: "ads_units", description: "Discover editable ADS units and their files: components/patterns with colocated previews; shared controllers; composition; tokens; foundation shared files; documentation including shared credits/license notices; 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 filePolicy for supported new documentation paths and 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 }) => {
293
313
  const matches = edits.units().filter(item => unit ? item.id === unit : item.id.toLowerCase().includes(search.toLowerCase()));
@@ -299,11 +319,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
299
319
  const {patch,...result}=reconcileTokenSamples(options,args);
300
320
  return json({...result,removed:result.removed.slice(0,50),unresolved:result.unresolved.slice(0,50),unresolvedCount:result.unresolved.length,...storePreparedPatch(options,patch)});
301
321
  }},
302
- { 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. Optional screenshot:true plus captures:[{scenario,target?,theme?,viewport?}] captures current/candidate only after gates pass. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance.",
322
+ { 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. Optional screenshot:true plus captures:[{scenario,target?,theme?,viewport?}] captures current/candidate only after gates pass. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance. Reply is a 4 KiB summary; read diagnostics, outcomes and requested captures through ads_results using its resultId.",
303
323
  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(), ...screenshotFields }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile."),
304
324
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
305
325
  call: applyEdits,
306
- }, {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. Optional screenshot:true with dryRun:true and explicit captures selects current/candidate images. Prefer ads_apply_patch for independent edits.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean(),...screenshotFields}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
326
+ }, {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. Optional screenshot:true with dryRun:true and explicit captures selects current/candidate images. Prefer ads_apply_patch for independent edits. Reply is a bounded summary; page diagnostics, outcomes and captures with ads_results using the returned resultId.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean(),...screenshotFields}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
307
327
  ...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) => {
308
328
  // Validate readiness without embedding executable HTML in model results.
309
329
  const build = await previewBuild(app, signal);
@@ -327,10 +347,10 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
327
347
  let progress = 0;
328
348
  const token = context.mcpReq._meta?.progressToken;
329
349
  const notify = token === undefined ? undefined : value => {
330
- void context.mcpReq.notify({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, message: value.message ?? `${value.profile}: ${value.completed}/${value.total} completed; ${value.active.map(item => `${item.unit}#${item.check}`).join(", ") || "no active check reported"}; ${value.elapsedMs} ms elapsed` } }).catch(() => {});
350
+ void context.mcpReq.notify({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, message: value.message ?? `${value.profile}: ${value.completed}/${value.total} completed; ${(value.active ?? []).slice(0,3).map(item => `${item.unit}#${item.check}`).join(", ") || "no active check reported"}; ${value.elapsedMs} ms elapsed` } }).catch(() => {});
331
351
  };
332
352
  try { return await tool.call(input.data, context.mcpReq.signal, notify); }
333
- catch (error) { return { isError: true, content: [{ type: "text", text: error.message }], ...(error.build ? { structuredContent: { message: error.message, build: error.build } } : {}) }; }
353
+ catch (error) { return { isError: true, content: [{ type: "text", text: error.message }], structuredContent: {ok:false,message:error.message,failure:failure(error.build?"build":"runner",error.build?"preview-build":"tool-execution",error.build?.code??"ADS_TOOL_ERROR",{certainty:"observed-boundary"}),...(error.build?{build:error.build}:{})} }; }
334
354
  });
335
355
  server.setRequestHandler("resources/list", ({ params = {} }) => {
336
356
  previewWatch?.start();
@@ -338,10 +358,20 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
338
358
  const listing = page(resources(), cursor, "resources");
339
359
  return { resources: listing.items, ...(listing.nextCursor ? { nextCursor: listing.nextCursor } : {}) };
340
360
  });
341
- server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [{ uriTemplate: "ads://captures/{key}/{slot}", name: "Current/candidate screenshot", description: "Exact keys and available slots come from ads://project/captures. Slots are replaced in place, not revision history.", mimeType: "image/png" }] }));
361
+ server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [
362
+ { uriTemplate: "ads://captures/{key}/{slot}", name: "Current/candidate screenshot", description: "Exact keys and available slots come from ads://project/captures. Slots are replaced in place, not revision history.", mimeType: "image/png" },
363
+ { uriTemplate: "ads://results/{resultId}", name: "Saved execution summary", description: "Use resultId from execution or ads://project/results. Query section/status/cursor/limit for pages, or section/recordIndex/offset/chars for exact record ranges. Only the latest result of each kind remains available.", mimeType: "application/json" },
364
+ { uriTemplate: "ads://investigations/{unit}/{check}", name: "Latest check investigation", description: "Use encoded unit/check segments from ads://project/investigations. One latest bundle per check, including candidate verification.", mimeType: "application/json" },
365
+ ] }));
342
366
  server.setRequestHandler("resources/read", ({ params: { uri } }, context) => read(uri, context.mcpReq.signal));
343
367
  server.setRequestHandler("resources/subscribe", ({ params: { uri } }) => {
344
368
  if (!resources().some((entry) => entry.uri === uri)) {
369
+ if(uri.startsWith("ads://results/")) {
370
+ try {executionResults.readResource(uri);subscribed.add(uri);return {};}catch{throw new ResourceNotFoundError(uri,"Unknown or superseded execution result.");}
371
+ }
372
+ if(uri.startsWith("ads://investigations/")) {
373
+ try {readInvestigationResource(root,uri);subscribed.add(uri);return {};}catch{throw new ResourceNotFoundError(uri,"Unknown check investigation.");}
374
+ }
345
375
  if (!uri.startsWith("ads://captures/")) throw new ResourceNotFoundError(uri, "Unknown ADS resource subscription.");
346
376
  try { const inventory = captureInventory(); captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length }); } catch { throw new ResourceNotFoundError(uri, "Unknown or absent screenshot slot."); }
347
377
  }
@@ -382,6 +412,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
382
412
  let previousBuild = JSON.stringify(builds());
383
413
  let previousCaptures = captures.store.fingerprint();
384
414
  let previousCaptureUris = captures.store.resourceUris();
415
+ const investigationFingerprint=()=>fileFingerprint([path.join(root,".ds/investigations.json")]);
416
+ let previousInvestigations=investigationFingerprint();
417
+ let previousResults=executionResults.fingerprint();
385
418
  async function refresh(force = false) {
386
419
  // Polling covers replaced files and new directories consistently across OSes.
387
420
  cache.documents = undefined;
@@ -394,7 +427,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
394
427
  const artifactsChanged = artifacts !== previousArtifacts;
395
428
  const assetsChanged = assets !== previousAssets;
396
429
  const capturesChanged = captures.store.fingerprint() !== previousCaptures;
397
- if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged && !capturesChanged) return false;
430
+ const investigationsChanged=investigationFingerprint()!==previousInvestigations;
431
+ const resultsChanged=executionResults.fingerprint()!==previousResults;
432
+ if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged && !capturesChanged && !investigationsChanged && !resultsChanged) return false;
398
433
  if (filesChanged) {
399
434
  const updated = loadConfig(root);
400
435
  await previewWatch?.reconfigure?.(updated.config.mcp);
@@ -411,7 +446,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
411
446
  previousAssets = assetFingerprint();
412
447
  previousBuild = JSON.stringify(builds());
413
448
  const targets = filesChanged ? era === "modern" ? resources().map((entry) => entry.uri).filter((uri) => !skills.files.has(uri)) : [...subscribed]
414
- : [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));
449
+ : [BUILD_URI, BEHAVIOR_URI, INVESTIGATIONS_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));
450
+ if(resultsChanged){targets.push(...[RESULTS_URI,...executionResults.index().results.map(entry=>entry.resourceUri),...[...subscribed].filter(uri=>uri.startsWith("ads://results/"))].filter(uri=>era==="modern"||subscribed.has(uri)));previousResults=executionResults.fingerprint();}
451
+ if(investigationsChanged){targets.push(...[INVESTIGATIONS_URI,...investigationIndex(root).entries.map(entry=>entry.resourceUri),...[...subscribed].filter(uri=>uri.startsWith("ads://investigations/"))].filter(uri=>era==="modern"||subscribed.has(uri)));previousInvestigations=investigationFingerprint();}
415
452
  if (capturesChanged || filesChanged) {
416
453
  const current = (captures.store.fingerprint() ? captureIndex().captures : []).flatMap(record => [record.current?.resourceUri, record.candidate?.resourceUri].filter(Boolean));
417
454
  targets.push(...[CAPTURES_URI, ...previousCaptureUris, ...current].filter(uri => era === "modern" || subscribed.has(uri)));
@@ -0,0 +1,39 @@
1
+ import { ident, lexer, parse, string, walk, generate } from "css-tree";
2
+
3
+ // CSS Properties and Values API Level 1 syntax types, not project token roles.
4
+ const types=new Set(["angle","color","custom-ident","image","integer","length","length-percentage","number","percentage","resolution","string","time","transform-function","transform-list","url"]);
5
+ const relative=/^(?:r?em|r?ex|r?cap|r?ch|r?ic|r?lh|[sld]?v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max))$/i;
6
+ const descriptors=new Set(["syntax","inherits","initial-value"]);
7
+
8
+ /** Registration metadata has its own CSS grammar; it is not an element style. */
9
+ export function propertyRegistration(rule) {
10
+ const name=ident.decode(rule.params.trim());
11
+ if(!name.startsWith("--")||name==="--"||lexer.matchType("custom-property-name",rule.params).error)throw new Error("@property needs one literal custom-property name.");
12
+ for(let parent=rule.parent;parent&&parent.type!=="root";parent=parent.parent)if(parent.type!=="atrule"||["property","font-face"].includes(parent.name.toLowerCase())||/keyframes$/i.test(parent.name))throw new Error("@property is document-global. Place it at template/sheet level or inside conditional at-rules, not element selectors or another descriptor block.");
13
+ const values={};
14
+ for(const node of rule.nodes??[]){
15
+ if(node.type==="comment")continue;
16
+ if(node.type!=="decl"||!descriptors.has(node.prop.toLowerCase())||node.important||Object.hasOwn(values,node.prop.toLowerCase()))throw new Error("@property accepts only one syntax, inherits and initial-value descriptor; no styles, nested rules or !important.");
17
+ values[node.prop.toLowerCase()]=node.value;
18
+ }
19
+ const syntaxAst=parse(values.syntax??"",{context:"value"}),syntaxNode=syntaxAst.children.first;
20
+ if(syntaxAst.children.size!==1||syntaxNode?.type!=="String")throw new Error("@property syntax must be one CSS string.");
21
+ const syntax=string.decode(generate(syntaxNode)).trim();
22
+ if(syntax!=="*"&&(!syntax||!syntax.split("|").every(part=>{
23
+ part=part.trim();const multiplier=/[+#]$/.test(part)?part.at(-1):"",base=multiplier?part.slice(0,-1).trim():part,match=/^<([a-z-]+)>$/.exec(base);
24
+ return match?types.has(match[1])&&!(match[1]==="transform-list"&&multiplier):!!base&&!lexer.matchType("custom-ident",base).error;
25
+ })))throw new Error("Invalid @property syntax. Use the CSS Properties and Values API grammar, not a project-specific type.");
26
+ if(!/^(?:true|false)$/i.test(values.inherits??""))throw new Error("@property requires inherits: true or false.");
27
+ if(values["initial-value"]===undefined&&syntax!=="*")throw new Error("Typed @property registrations require initial-value.");
28
+ if(values["initial-value"]!==undefined){
29
+ const initial=parse(values["initial-value"],{context:"value"});
30
+ if(syntax!=="*"){
31
+ walk(initial,node=>{
32
+ if(node.type==="Function"&&/^(?:var|env|attr)$/i.test(node.name)||node.type==="Dimension"&&relative.test(node.unit)||node.type==="Identifier"&&/^(?:currentcolor|inherit|initial|unset|revert(?:-layer)?)$/i.test(node.name))throw new Error("Typed @property initial-value must be computationally independent: no var(), relative units or currentColor. Keep design assignments token-backed outside this descriptor.");
33
+ });
34
+ const match=lexer.match(syntax,initial);
35
+ if(match.error)throw new Error("@property initial-value must match its declared syntax.");
36
+ }
37
+ }
38
+ return {name,signature:JSON.stringify([syntax,values.inherits.toLowerCase(),values["initial-value"]===undefined?null:generate(parse(values["initial-value"],{context:"value"}))])};
39
+ }
@@ -0,0 +1,140 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import postcss from "postcss";
4
+ import { isDevelopmentFile, isInside } from "./config.mjs";
5
+ import { publicApi } from "./public-api.mjs";
6
+ import { createSourceValues, reference, literal, isFunction } from "./source-values.mjs";
7
+ import { uiOperation } from "./ui-framework.mjs";
8
+ import { nestedSelectors, selectorWitness } from "./selector-match.mjs";
9
+
10
+ /** Native semantics may be composed through actual public runtime styles.
11
+ * A matching tag relationship is evidence of ownership, not visual correctness. */
12
+ export function createScenarioSelectors(program, options) {
13
+ const checker = program.getTypeChecker(), templates = new WeakMap(), cache = new WeakMap();
14
+ function imported(node, name, seen = new Set()) {
15
+ if (!node || seen.has(node)) return false;
16
+ seen.add(node);
17
+ const base = ts.isPropertyAccessExpression(node) ? node.expression : node;
18
+ return checker.getSymbolAtLocation(base)?.declarations?.some(declaration => {
19
+ if (ts.isImportSpecifier(declaration)) return (declaration.propertyName ?? declaration.name).text === name && ["@linaria/atomic", "@linaria/core"].includes(declaration.parent.parent.parent.moduleSpecifier.text);
20
+ if (ts.isNamespaceImport(declaration) && ts.isPropertyAccessExpression(node)) return node.name.text === name && ["@linaria/atomic", "@linaria/core"].includes(declaration.parent.parent.moduleSpecifier.text);
21
+ return ts.isVariableDeclaration(declaration) && imported(declaration.initializer, name, seen);
22
+ }) ?? false;
23
+ }
24
+ const isCx = node => imported(node, "cx");
25
+ const values = createSourceValues(program, options, { strict: true, preserveCall: node => isCx(node) || uiOperation(node, checker) === "createElement" });
26
+ const { state, next, resolve, get, keys, props, returns } = values;
27
+ const publicNodes = new Set(publicApi(program, options).filter(item => item.renderable).map(item => resolve(reference(item.declaration.name ?? item.declaration), state())).filter(value => value.kind === "node").map(value => value.node));
28
+ const runtime = node => !isDevelopmentFile(node.getSourceFile().fileName, options) && ["components", "patterns"].some(folder => isInside(path.join(options.dsRoot, folder), node.getSourceFile().fileName));
29
+ function selectors(node) {
30
+ if (templates.has(node)) return templates.get(node);
31
+ const result = []; templates.set(node, result);
32
+ if (!runtime(node) || !ts.isNoSubstitutionTemplateLiteral(node.template)) return result;
33
+ try {
34
+ function walk(container, parents) {
35
+ for (const child of container.nodes ?? []) {
36
+ if (child.type === "rule") {
37
+ const selected = nestedSelectors(parents, child.selector);
38
+ if (child.nodes.some(value => value.type === "decl" && !value.prop.startsWith("--"))) result.push(...selected);
39
+ walk(child, selected);
40
+ } else if (child.type === "atrule" && !/(?:^|-)keyframes$/.test(child.name)) walk(child, parents);
41
+ }
42
+ }
43
+ walk(postcss.parse(node.template.text), ["&"]);
44
+ } catch { /* The CSS syntax gate owns malformed templates. */ }
45
+ return result;
46
+ }
47
+ function classes(input, context) {
48
+ const value = resolve(input, next(context));
49
+ if (value.kind === "union") {
50
+ const all = value.values.map(branch => classes(branch, next(context)));
51
+ return all[0]?.filter(entry => all.every(branch => branch.includes(entry))) ?? [];
52
+ }
53
+ if (value.kind !== "node") return [];
54
+ if (ts.isTaggedTemplateExpression(value.node) && imported(value.node.tag, "css")) return [value.node];
55
+ if (ts.isCallExpression(value.node) && isCx(value.node.expression)) return value.node.arguments.flatMap(argument => classes(reference(argument, value.env, value.via), next(context)));
56
+ return [];
57
+ }
58
+ function element(node, env, via) {
59
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
60
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
61
+ const attributes = props(opening, env, via);
62
+ const children = ts.isJsxElement(node) ? node.children.filter(child => !ts.isJsxText(child) || child.text.trim()).filter(child => !ts.isJsxExpression(child) || child.expression) : [];
63
+ if (children.length) attributes.layers.push(new Map([["children", children.length === 1 ? reference(children[0], env, via) : { kind: "array", values: children.map(child => reference(child, env, via)) }]]));
64
+ return { tag: reference(opening.tagName, env, via), attributes, intrinsic: ts.isIdentifier(opening.tagName) && /^[a-z]/.test(opening.tagName.text) ? opening.tagName.text : null };
65
+ }
66
+ if (ts.isCallExpression(node) && uiOperation(node.expression, checker) === "createElement") {
67
+ const attributes = { kind: "object", layers: node.arguments[1] && node.arguments[1].kind !== ts.SyntaxKind.NullKeyword ? [reference(node.arguments[1], env, via)] : [] };
68
+ const children = node.arguments.slice(2).map(child => reference(child, env, via));
69
+ if (children.length) attributes.layers.push(new Map([["children", children.length === 1 ? children[0] : { kind: "array", values: children }]]));
70
+ return { tag: reference(node.arguments[0], env, via), attributes, intrinsic: ts.isStringLiteralLike(node.arguments[0]) ? node.arguments[0].text : null };
71
+ }
72
+ return null;
73
+ }
74
+ return site => {
75
+ if (cache.has(site)) return cache.get(site);
76
+ const native = ts.isJsxOpeningElement(site) ? site.parent : site;
77
+ let steps = 0, limited = false;
78
+ const witnesses = [];
79
+ function render(input, parent, context, owned = false) {
80
+ if (++steps > 700 || context.depth > 40) { limited = true; return []; }
81
+ const value = resolve(input, next(context));
82
+ context = value.context ?? input.context ?? context;
83
+ if (value.kind === "union") {
84
+ // Unknown render alternatives cannot silently authorize a native control.
85
+ limited = true; return [];
86
+ }
87
+ if (value.kind === "array") return value.values.flatMap(child => render(child, parent, next(context), owned));
88
+ if (value.kind !== "node") return [];
89
+ const {node, env, via} = value;
90
+ if (ts.isJsxExpression(node)) return render(reference(node.expression, env, via), parent, next(context), owned);
91
+ if (ts.isJsxFragment(node)) return node.children.flatMap(child => render(reference(child, env, via), parent, next(context), owned));
92
+ if (ts.isArrayLiteralExpression(node)) return node.elements.flatMap(child => render(reference(child, env, via), parent, next(context), owned));
93
+ const view = element(node, env, via);
94
+ if (!view) return [];
95
+ const target = view.intrinsic ? literal(view.intrinsic) : resolve(view.tag, next(context));
96
+ if (["provider", "context"].includes(target.kind)) return render(get(view.attributes, "children", next(context)), parent, next(context), owned);
97
+ if (target.kind === "node" && isFunction(target.node)) {
98
+ const outputs = returns(target, [view.attributes], next(context), "component");
99
+ if (outputs.length !== 1) { limited = true; return []; }
100
+ return render(outputs[0], parent, next(context), owned || publicNodes.has(target.node));
101
+ }
102
+ if (target.kind !== "literal" || typeof target.value !== "string") return [];
103
+ const attributes = new Map(),ownedAttributes=new Set();
104
+ for (const name of keys(view.attributes, next(context))) {
105
+ const input=get(view.attributes,name,next(context));
106
+ const value = resolve(input, next(context));
107
+ attributes.set(name, value.kind === "literal" ? value.value : undefined);
108
+ if(owned&&runtime(node)&&input.kind==="node"&&ts.isStringLiteralLike(input.node)&&runtime(input.node))ownedAttributes.add(name);
109
+ }
110
+ const result = {tag:target.value.toLowerCase(),attributes,ownedAttributes,parent,children:[],site:node,styles:owned ? classes(get(view.attributes,"className",next(context)),next(context)) : []};
111
+ if (node === native) witnesses.push(result);
112
+ result.children = render(get(view.attributes,"children",next(context)),result,next(context),owned);
113
+ return [result];
114
+ }
115
+ function proves(roots) {
116
+ function all(node) { return [node,...node.children.flatMap(all)]; }
117
+ const nodes = roots.flatMap(all);
118
+ return witnesses.some(witness => nodes.some(anchor => anchor.styles.some(template => selectors(template).some(selector => nodes.some(target => {
119
+ // Styles must target the native node itself or public UI inside it.
120
+ let inside = target === witness;
121
+ for (let parent = target.parent; parent && !inside; parent = parent.parent) inside = parent === witness;
122
+ return inside && selectorWitness(selector,target,anchor,witness);
123
+ })))));
124
+ }
125
+ // First follow the native wrapper into its public contents. This also works
126
+ // when a scenario helper returns it and is invoked under a public container.
127
+ let roots = render(reference(native),null,state());
128
+ let ok = proves(roots);
129
+ if (!ok) {
130
+ let parent = native.parent;
131
+ while (parent && (ts.isJsxExpression(parent) || ts.isParenthesizedExpression(parent))) parent = parent.parent;
132
+ if (parent && element(parent,new Map(),[])) {
133
+ const view = element(parent,new Map(),[]), target = resolve(view.tag,state());
134
+ if (target.kind === "node" && publicNodes.has(target.node)) { witnesses.length=0; steps=0; limited=false; roots=render(reference(parent),null,state()); ok=proves(roots); }
135
+ }
136
+ }
137
+ const result = {ok, reasons:ok ? [] : [limited ? "Selector composition reached an unresolved render branch or source work limit." : "No attached public-runtime Linaria selector proves this native tag or runtime-part relationship."], evidence:ok ? {kind:"selector-composition", established:["public-runtime-style-provenance","native-position-ownership"], unverified:["computed-styles","interaction","accessibility"]} : undefined};
138
+ cache.set(site,result);return result;
139
+ };
140
+ }
@@ -120,7 +120,7 @@ export function createScenarioTrace(program, options) {
120
120
  let declarations = 0;
121
121
  const template = node.template;
122
122
  const text = ts.isNoSubstitutionTemplateLiteral(template) ? template.text : template.head.text + template.templateSpans.map(span => `__ads_expression__${span.literal.text}`).join("");
123
- try { postcss.parse(text).walkDecls(() => ++declarations); } catch { /* Source CSS guards own malformed templates. */ }
123
+ try { postcss.parse(text).walkDecls(declaration => { for(let parent=declaration.parent;parent;parent=parent.parent)if(parent.type==="atrule"&&/^(?:property|font-face|(?:-\w+-)?keyframes)$/i.test(parent.name))return; ++declarations; }); } catch { /* Source CSS guards own malformed templates. */ }
124
124
  if (owned(source, via) && declarations) return result(true, true);
125
125
  reasons.add("The returned style must originate in runtime component/pattern Linaria through a public callable helper.");
126
126
  return result(false);