@reqvire-org/ads 0.3.15 → 0.3.17

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 (41) hide show
  1. package/README.md +3 -0
  2. package/dist/check.mjs +13 -8
  3. package/dist/evidence.mjs +18 -0
  4. package/dist/investigations.mjs +70 -0
  5. package/dist/mcp/context.mjs +1 -1
  6. package/dist/mcp/mutations.mjs +34 -17
  7. package/dist/mcp/preview-widget.bundle.js +12 -12
  8. package/dist/mcp/resource-loader.js +3 -3
  9. package/dist/mcp/server.mjs +30 -8
  10. package/dist/scenario-selectors.mjs +140 -0
  11. package/dist/selector-match.mjs +94 -0
  12. package/dist/showcase/client.bundle.js +18 -18
  13. package/dist/showcase/resource-reader.js +37 -0
  14. package/dist/skills/ads-break-ui/SKILL.md +3 -0
  15. package/dist/skills/ads-break-ui/references/evidence.md +73 -0
  16. package/dist/skills/ads-break-ui/references/guarded-editing.md +12 -0
  17. package/dist/skills/ads-break-ui/references/scenarios.md +32 -0
  18. package/dist/skills/ads-designer/SKILL.md +3 -0
  19. package/dist/skills/ads-designer/references/evidence.md +73 -0
  20. package/dist/skills/ads-designer/references/guarded-editing.md +12 -0
  21. package/dist/skills/ads-designer/references/scenarios.md +32 -0
  22. package/dist/skills/ads-import/SKILL.md +3 -0
  23. package/dist/skills/ads-import/references/evidence.md +73 -0
  24. package/dist/skills/ads-import/references/guarded-editing.md +12 -0
  25. package/dist/skills/ads-import/references/scenarios.md +32 -0
  26. package/dist/skills/ads-migrate/SKILL.md +3 -0
  27. package/dist/skills/ads-migrate/references/evidence.md +73 -0
  28. package/dist/skills/ads-migrate/references/guarded-editing.md +12 -0
  29. package/dist/skills/ads-migrate/references/scenarios.md +32 -0
  30. package/dist/skills/ads-motion/SKILL.md +3 -0
  31. package/dist/skills/ads-motion/references/evidence.md +73 -0
  32. package/dist/skills/ads-motion/references/guarded-editing.md +12 -0
  33. package/dist/skills/ads-motion/references/scenarios.md +32 -0
  34. package/dist/verification/browser.mjs +27 -8
  35. package/dist/verification/playwright-reporter.cjs +39 -1
  36. package/dist/verification.mjs +32 -9
  37. package/docs/ARCHITECTURE.md +29 -3
  38. package/docs/BEHAVIORS.md +35 -2
  39. package/docs/CHANGELOG.md +21 -0
  40. package/docs/MCP.md +13 -0
  41. package/package.json +1 -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);
@@ -6,6 +6,8 @@ import { z } from "zod";
6
6
  import { checkProject } from "../check.mjs";
7
7
  import { loadConfig, sourceFiles } from "../config.mjs";
8
8
  import { behaviorStatus, createVerificationController, BEHAVIOR_URI } from "../verification.mjs";
9
+ import { INVESTIGATIONS_URI, investigationIndex, readInvestigation, 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";
@@ -108,6 +110,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
108
110
  return [
109
111
  { 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
112
  { uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
113
+ { uri: "ads://guides/evidence", name: "Failure boundaries and compact investigations", mimeType: "text/markdown" },
111
114
  { uri: "ads://guides/atomic-styles", name: "Atomic styles: composition conflicts and custom-property extraction", mimeType: "text/markdown" },
112
115
  { uri: "ads://guides/scenarios", name: "Scenario coverage and public class-helper contracts", mimeType: "text/markdown" },
113
116
  { uri: "ads://guides/captures", name: "On-demand captures and candidate visual review", mimeType: "text/markdown" },
@@ -117,15 +120,17 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
117
120
  { uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
118
121
  { uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
119
122
  { uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
123
+ { uri: INVESTIGATIONS_URI, name: "Latest per-check investigation evidence", mimeType: "application/json" },
120
124
  { uri: CAPTURES_URI, name: "On-demand current and candidate screenshots", mimeType: "application/json" },
121
125
  { uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
126
+ // Keep fallback workflow metadata discoverable in the bounded first page.
127
+ ...skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description, mimeType: "text/markdown" })),
122
128
  ...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
129
  { uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
124
130
  { uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
125
131
  ...(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
132
  ...(options.config.mcp.previewManifest ? resourceAssets() : []),
127
133
  ...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
134
  ...documents.filter((document) => !promoted.has(document.uri)).map(({ file: _file, ...document }) => document),
130
135
  ...[...skills.files.values()].filter((file) => !skillEntries.has(file.uri)).map(({ uri, name, mimeType }) => ({ uri, name, mimeType })),
131
136
  ];
@@ -142,13 +147,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
142
147
  build: summarizeBuild(builds()),
143
148
  previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
144
149
  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() },
150
+ behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", investigationTool: "ads_investigate", investigationResourceUri: INVESTIGATIONS_URI, ...verification.status() },
146
151
  captures: { resourceUri: CAPTURES_URI, tool: "ads_capture", clearTool: "ads_capture_clear", ...captures.status() },
147
152
  experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
148
153
  }
149
154
  async function read(uri, signal) {
150
155
  if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
151
156
  if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
157
+ 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
158
  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
159
  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
160
  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") }] };
@@ -161,12 +167,17 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
161
167
  else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
162
168
  else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
163
169
  else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
170
+ else if (uri === INVESTIGATIONS_URI) value = investigationIndex(root);
164
171
  else if (uri === CAPTURES_URI) value = captureIndex();
165
172
  else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
166
173
  value = readPreviewManifest(previewOptions());
167
174
  if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
168
175
  }
169
176
  if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
177
+ if (uri.startsWith("ads://investigations/") || uri.startsWith("ads://verification-artifacts/")) {
178
+ try { return {contents:[readInvestigationResource(root,uri)]}; }
179
+ catch(error){throw new ResourceNotFoundError(uri,error.message);}
180
+ }
170
181
  if (uri.startsWith("ads://captures/")) {
171
182
  try { const inventory = captureInventory(); return { contents: [captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length })] }; }
172
183
  catch (error) { if (error.code === "ADS_CAPTURE_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
@@ -244,7 +255,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
244
255
  } },
245
256
  { 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
257
  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` });
258
+ 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
259
  } },
249
260
  { 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
261
  { 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 }) => {
@@ -284,7 +295,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
284
295
  demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
285
296
  }), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
286
297
  } },
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) => {
298
+ { name: "ads_investigate", title: "Inspect check evidence", description: "Read the latest compact investigation for an exact registered unit/check, including candidate runs. Returns failure boundary, exact input/observed preview revisions, source/failing step, observed viewport, failed startup requests and available artifact resource URIs. Does not rerun tests or infer root cause. Bundles replace the previous entry per check; compare revisions with ads_behaviors before claiming freshness.", schema: z.object({unit:z.string().min(1).max(256),check:z.string().min(1).max(100)}).strict(), call: args => json(readInvestigation(root,args)) },
299
+ { 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. Results separate failure boundaries and explicit acceptance scope; inspect retained source/request/artifact evidence 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
300
  const result = await verification.run(args, signal, progress);
289
301
  await refresh();
290
302
  return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
@@ -330,7 +342,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
330
342
  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(() => {});
331
343
  };
332
344
  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 } } : {}) }; }
345
+ 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
346
  });
335
347
  server.setRequestHandler("resources/list", ({ params = {} }) => {
336
348
  previewWatch?.start();
@@ -338,10 +350,16 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
338
350
  const listing = page(resources(), cursor, "resources");
339
351
  return { resources: listing.items, ...(listing.nextCursor ? { nextCursor: listing.nextCursor } : {}) };
340
352
  });
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" }] }));
353
+ server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [
354
+ { 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" },
355
+ { 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" },
356
+ ] }));
342
357
  server.setRequestHandler("resources/read", ({ params: { uri } }, context) => read(uri, context.mcpReq.signal));
343
358
  server.setRequestHandler("resources/subscribe", ({ params: { uri } }) => {
344
359
  if (!resources().some((entry) => entry.uri === uri)) {
360
+ if(uri.startsWith("ads://investigations/")) {
361
+ try {readInvestigationResource(root,uri);subscribed.add(uri);return {};}catch{throw new ResourceNotFoundError(uri,"Unknown check investigation.");}
362
+ }
345
363
  if (!uri.startsWith("ads://captures/")) throw new ResourceNotFoundError(uri, "Unknown ADS resource subscription.");
346
364
  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
365
  }
@@ -382,6 +400,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
382
400
  let previousBuild = JSON.stringify(builds());
383
401
  let previousCaptures = captures.store.fingerprint();
384
402
  let previousCaptureUris = captures.store.resourceUris();
403
+ const investigationFingerprint=()=>fileFingerprint([path.join(root,".ds/investigations.json")]);
404
+ let previousInvestigations=investigationFingerprint();
385
405
  async function refresh(force = false) {
386
406
  // Polling covers replaced files and new directories consistently across OSes.
387
407
  cache.documents = undefined;
@@ -394,7 +414,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
394
414
  const artifactsChanged = artifacts !== previousArtifacts;
395
415
  const assetsChanged = assets !== previousAssets;
396
416
  const capturesChanged = captures.store.fingerprint() !== previousCaptures;
397
- if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged && !capturesChanged) return false;
417
+ const investigationsChanged=investigationFingerprint()!==previousInvestigations;
418
+ if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged && !capturesChanged && !investigationsChanged) return false;
398
419
  if (filesChanged) {
399
420
  const updated = loadConfig(root);
400
421
  await previewWatch?.reconfigure?.(updated.config.mcp);
@@ -411,7 +432,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
411
432
  previousAssets = assetFingerprint();
412
433
  previousBuild = JSON.stringify(builds());
413
434
  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));
435
+ : [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));
436
+ 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
437
  if (capturesChanged || filesChanged) {
416
438
  const current = (captures.store.fingerprint() ? captureIndex().captures : []).flatMap(record => [record.current?.resourceUri, record.candidate?.resourceUri].filter(Boolean));
417
439
  targets.push(...[CAPTURES_URI, ...previousCaptureUris, ...current].filter(uri => era === "modern" || subscribed.has(uri)));
@@ -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
+ }
@@ -0,0 +1,94 @@
1
+ import selectorParser from "postcss-selector-parser";
2
+
3
+ const states = new Set([":hover", ":active", ":focus", ":focus-visible", ":focus-within", ":visited", ":link", ":any-link", ":enabled", ":disabled", ":checked", ":indeterminate", ":required", ":optional", ":valid", ":invalid", ":placeholder-shown", ":open"]);
4
+
5
+ /** Match bounded source DOM witnesses, not a browser cascade. Ownership requires
6
+ * a positive native tag or a :has binding to a real runtime-owned child marker. */
7
+ export function selectorWitness(selector, target, anchor, native) {
8
+ let steps = 0;
9
+ const yes = typed => ({ typed });
10
+ const and = (a, b) => a === false || b === false ? false : !a || !b ? undefined : yes(a.typed || b.typed);
11
+ const any = results => results.find(value => value?.typed) ?? results.find(Boolean) ?? (results.includes(undefined) ? undefined : false);
12
+ const descendants = (element, found = []) => {
13
+ if (found.length > 128) return found;
14
+ for (const child of element.children) { found.push(child); descendants(child, found); }
15
+ return found;
16
+ };
17
+ function simple(node, element, scope, binding) {
18
+ if (++steps > 2000 || !element) return undefined;
19
+ if (node.type === "comment") return yes(false);
20
+ if (node.type === "nesting") return element === anchor ? yes(false) : false;
21
+ if (node.type === "universal") return yes(false);
22
+ if (node.type === "tag") return !node.namespace && node.value.toLowerCase() === element.tag ? yes(element === native) : false;
23
+ if (node.type === "attribute") {
24
+ if (node.namespace || !element.attributes.has(node.attribute)) return false;
25
+ const actual = element.attributes.get(node.attribute);
26
+ if (actual === undefined) return undefined;
27
+ const owned=binding&&element.parent===native&&element.ownedAttributes?.has(node.attribute);
28
+ if (!node.operator) return yes(owned);
29
+ if (node.operator !== "=" || node.insensitive) return undefined;
30
+ return String(actual) === node.value ? yes(owned) : false;
31
+ }
32
+ if (node.type === "class" || node.type === "id") {
33
+ const value = element.attributes.get(node.type === "class" ? "className" : "id");
34
+ if (typeof value !== "string") return undefined;
35
+ return (node.type === "class" ? value.split(/\s+/).includes(node.value) : value === node.value) ? yes(false) : false;
36
+ }
37
+ if (node.type !== "pseudo" || node.value.startsWith("::")) return undefined;
38
+ if (node.value === ":scope") return element === scope ? yes(false) : false;
39
+ if (states.has(node.value) && !node.nodes?.length) return yes(false);
40
+ if ([":is", ":where"].includes(node.value)) return any(node.nodes.map(branch => match(branch.nodes, element, scope, binding)));
41
+ if (node.value === ":not") {
42
+ const matched = any(node.nodes.map(branch => match(branch.nodes, element, scope)));
43
+ return matched === false ? yes(false) : matched ? false : undefined;
44
+ }
45
+ if (node.value === ":has") {
46
+ const children = descendants(element);
47
+ if (children.length > 128) return undefined;
48
+ return any(node.nodes.flatMap(branch => {
49
+ const nodes = [selectorParser.pseudo({ value: ":scope" }), ...(branch.nodes[0]?.type === "combinator" ? [] : [selectorParser.combinator({ value: " " })]), ...branch.nodes];
50
+ return children.map(child => match(nodes, child, element,element===native));
51
+ }));
52
+ }
53
+ return undefined;
54
+ }
55
+ function match(nodes, element, scope, binding=false) {
56
+ if (++steps > 2000 || !element) return undefined;
57
+ const groups = [[]], combinators = [];
58
+ for (const node of nodes) {
59
+ if (node.type === "combinator") { combinators.push(node.value.trim() || " "); groups.push([]); }
60
+ else groups.at(-1).push(node);
61
+ }
62
+ if (groups.some(group => !group.length)) return undefined;
63
+ function part(index, current) {
64
+ if (!current || ++steps > 2000) return current ? undefined : false;
65
+ let result = yes(false);
66
+ for (const node of groups[index]) result = and(result, simple(node, current, scope,binding));
67
+ if (!result || !index) return result;
68
+ const candidates = [];
69
+ if (combinators[index - 1] === ">") candidates.push(current.parent);
70
+ else if (combinators[index - 1] === " ") { for (let parent = current.parent; parent && candidates.length < 32; parent = parent.parent) candidates.push(parent); }
71
+ else if (["+", "~"].includes(combinators[index - 1])) {
72
+ const siblings = current.parent?.children ?? [], position = siblings.indexOf(current);
73
+ candidates.push(...siblings.slice(combinators[index - 1] === "+" ? Math.max(0, position - 1) : 0, position));
74
+ } else return undefined;
75
+ return and(result, any(candidates.map(candidate => part(index - 1, candidate))));
76
+ }
77
+ return part(groups.length - 1, element);
78
+ }
79
+ try { return selectorParser().astSync(selector).nodes.some(branch => match(branch.nodes, target, anchor)?.typed); }
80
+ catch { return false; }
81
+ }
82
+
83
+ /** Resolve CSS nesting with selector ASTs; never substitute project names. */
84
+ export function nestedSelectors(parents, selector) {
85
+ const children = selectorParser().astSync(selector).nodes;
86
+ const result = [];
87
+ for (const parent of parents) for (const child of children) {
88
+ if (result.length >= 32) return [];
89
+ const copy = child.clone(); let nested = false;
90
+ copy.walkNesting(node => { nested = true; node.replaceWith(selectorParser.pseudo({ value: ":is", nodes: selectorParser().astSync(parent).nodes.map(value => value.clone()) })); });
91
+ result.push(nested ? copy.toString() : `${parent} ${copy}`);
92
+ }
93
+ return result;
94
+ }