@reqvire-org/ads 0.3.3 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +17 -5
  2. package/dist/behaviors.mjs +157 -0
  3. package/dist/bootstrap-ds/ADS.md +16 -3
  4. package/dist/bootstrap-ds/README.md +10 -0
  5. package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  6. package/dist/boundaries.mjs +3 -0
  7. package/dist/check.mjs +20 -2
  8. package/dist/cli.mjs +17 -0
  9. package/dist/config.mjs +23 -3
  10. package/dist/css-values.mjs +26 -0
  11. package/dist/index.mjs +2 -0
  12. package/dist/init.mjs +1 -1
  13. package/dist/mcp/build-status.mjs +17 -7
  14. package/dist/mcp/catalog.mjs +11 -8
  15. package/dist/mcp/context.mjs +3 -3
  16. package/dist/mcp/edit-preparation.mjs +111 -0
  17. package/dist/mcp/mutations.mjs +64 -38
  18. package/dist/mcp/preview-owner-guard.mjs +29 -0
  19. package/dist/mcp/preview-watch.mjs +38 -8
  20. package/dist/mcp/preview-widget.bundle.js +2 -2
  21. package/dist/mcp/prompts.mjs +2 -2
  22. package/dist/mcp/server.mjs +88 -41
  23. package/dist/mcp/showcase-host.mjs +9 -5
  24. package/dist/mcp/text-diff.mjs +19 -0
  25. package/dist/preview-resources.mjs +45 -8
  26. package/dist/preview-scope.mjs +30 -0
  27. package/dist/public-api.mjs +39 -0
  28. package/dist/scenarios-compiler.mjs +41 -19
  29. package/dist/scenarios.mjs +72 -42
  30. package/dist/scoped-tokens.mjs +57 -0
  31. package/dist/skills/README.md +3 -3
  32. package/dist/skills/ads-break-ui/SKILL.md +26 -2
  33. package/dist/skills/ads-break-ui/references/guarded-editing.md +44 -0
  34. package/dist/skills/ads-break-ui/references/verification.md +6 -0
  35. package/dist/skills/ads-designer/SKILL.md +27 -3
  36. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +16 -3
  37. package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
  38. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  39. package/dist/skills/ads-designer/references/cli.md +1 -1
  40. package/dist/skills/ads-designer/references/design-craft.md +1 -1
  41. package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
  42. package/dist/skills/ads-import/SKILL.md +26 -2
  43. package/dist/skills/ads-import/references/cli.md +1 -1
  44. package/dist/skills/ads-import/references/guarded-editing.md +44 -0
  45. package/dist/skills/ads-import/references/tokens.md +4 -2
  46. package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +32 -8
  47. package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
  48. package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
  49. package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
  50. package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +11 -0
  51. package/dist/skills/ads-motion/SKILL.md +26 -2
  52. package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
  53. package/dist/token-fixtures.mjs +30 -0
  54. package/dist/tokens.mjs +11 -4
  55. package/dist/units.mjs +35 -9
  56. package/dist/verification/browser.mjs +27 -0
  57. package/dist/verification/playwright-reporter.cjs +20 -0
  58. package/dist/verification.mjs +214 -0
  59. package/docs/ADS.md +72 -4
  60. package/docs/ARCHITECTURE.md +60 -1
  61. package/docs/BEHAVIORS.md +241 -0
  62. package/docs/CHANGELOG.md +56 -0
  63. package/docs/GUIDE.md +24 -4
  64. package/docs/MCP.md +106 -12
  65. package/package.json +4 -2
  66. package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
  67. /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
  68. /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
package/dist/tokens.mjs CHANGED
@@ -4,7 +4,9 @@ import postcss from "postcss";
4
4
  import valueParser from "postcss-value-parser";
5
5
  import { loadConfig, sourceFiles } from "./config.mjs";
6
6
  import { foundationFiles, isSharedToken, tokenContract } from "./token-policy.mjs";
7
+ import { dimensionValue, numericCssUnit } from "./css-values.mjs";
7
8
  import { checkComponentVariable } from "./boundaries.mjs";
9
+ import { scopedTokens, scopedTokenOwner } from "./scoped-tokens.mjs";
8
10
 
9
11
  export { tokenContract };
10
12
 
@@ -33,8 +35,8 @@ function matchesType(value, type) {
33
35
  if (/^(?:inherit|initial|unset|revert(?:-layer)?)$/.test(value)) return true;
34
36
  switch (type) {
35
37
  case "color": return /^(?:#[\da-f]{3,8}|[a-z][\w-]*|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\([\s\S]+\))$/i.test(value);
36
- case "dimension": return /^[-+]?(?:\d*\.)?\d+(?:px|r?em|ch|ex|cap|lh|rlh|v[wh]|[sld]v[wh]|vmin|vmax|cm|mm|in|pt|pc|%)$/i.test(value) || /^[-+]?0(?:\.0+)?$/.test(value);
37
- case "duration": return /^[-+]?(?:\d*\.)?\d+m?s$/i.test(value);
38
+ case "dimension": return dimensionValue(value);
39
+ case "duration": return ["s", "ms"].includes(numericCssUnit(value));
38
40
  case "number": return /^[-+]?(?:\d*\.)?\d+$/.test(value);
39
41
  case "easing": return /^(?:linear|ease(?:-in|-out|-in-out)?|step-start|step-end|(?:cubic-bezier|steps|linear)\([\s\S]+\))$/.test(value);
40
42
  case "shadow": return value === "none" || /(?:\d(?:px|rem|em)|var\()/.test(value);
@@ -48,7 +50,10 @@ export function inspectTokens(directory) {
48
50
  const { root, dsRoot, config } = options;
49
51
  const definitions = new Map();
50
52
  const diagnostics = [];
53
+ const scoped = scopedTokens(options);
54
+ diagnostics.push(...scoped.diagnostics);
51
55
  const files = sourceFiles(path.join(dsRoot, config.tokenDir)).filter((file) => file.endsWith(".css"));
56
+ files.push(...scoped.definitions.map(entry => path.join(root, entry.file)));
52
57
  files.sort((a, b) => foundationFiles.indexOf(path.basename(a)) - foundationFiles.indexOf(path.basename(b)) || a.localeCompare(b));
53
58
  for (const file of files) {
54
59
  let css;
@@ -67,6 +72,7 @@ export function inspectTokens(directory) {
67
72
  column: decl.source.start.column,
68
73
  scope: context(decl),
69
74
  value: decl.value,
75
+ ...(scoped.definitions.some(entry => entry.file === path.relative(root, file).replaceAll("\\", "/")) ? { owner:scoped.definitions.find(entry => entry.file === path.relative(root, file).replaceAll("\\", "/")).unit } : {}),
70
76
  };
71
77
  if (!definitions.has(decl.prop)) definitions.set(decl.prop, []);
72
78
  definitions.get(decl.prop).push(definition);
@@ -106,7 +112,8 @@ export function inspectTokens(directory) {
106
112
  }
107
113
  const resolvedReferences = new Map();
108
114
  for (const ref of references(entry.value)) {
109
- if (!isSharedToken(ref.name)) report("token-name", name, entry, `${name} references ${ref.name}, which does not follow ADS's fixed token naming contract.`);
115
+ const local = scopedTokenOwner(options, ref.name);
116
+ if (local ? entry.owner !== local.unit : !isSharedToken(ref.name)) report("token-name", name, entry, `${name} references ${ref.name} outside its shared/owner token contract.`);
110
117
  if (!scope.has(ref.name) && !ref.fallback) report("token-reference", name, entry, `${name} references undefined ${ref.name}.`);
111
118
  else if (scope.has(ref.name)) resolvedReferences.set(ref.name, resolveAlias(ref.name, [...chain, name]));
112
119
  }
@@ -136,7 +143,7 @@ export function inspectTokens(directory) {
136
143
  }
137
144
  return {
138
145
  contractVersion: tokenContract.version,
139
- tokens: [...definitions].sort(([a], [b]) => a.localeCompare(b)).map(([name, entries]) => ({ name, ...tokenContract.tokens[name], definitions: entries })),
146
+ tokens: [...definitions].sort(([a], [b]) => a.localeCompare(b)).map(([name, entries]) => ({ name, ...tokenContract.tokens[name], ...(scopedTokenOwner(options, name) ? {owner:scopedTokenOwner(options, name).unit, namespace:scopedTokenOwner(options, name).namespace} : {owner:"tokens"}), definitions: entries })),
140
147
  diagnostics,
141
148
  };
142
149
  }
package/dist/units.mjs CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import ts from "typescript";
4
4
  import postcss from "postcss";
5
- import { isInside } from "./config.mjs";
5
+ import { isDevelopmentFile, isInside } from "./config.mjs";
6
6
  import { importResolver } from "./boundaries.mjs";
7
7
  import { isProjectDocument, projectGuideFiles } from "./project-documents.mjs";
8
8
  import { foundationFiles, foundationImportTarget } from "./token-policy.mjs";
@@ -16,14 +16,21 @@ export function configurationFiles(options) {
16
16
  const { root, config, sourceRoot, dsRoot } = options;
17
17
  const files = new Set(["ds.config.json", "package.json"]);
18
18
  for (const entry of readdirSync(root, { withFileTypes: true })) {
19
- if (entry.isFile() && /^(?:(?:ts|js)config[^/]*\.jsonc?|(?:vite|vitest|linaria|babel|postcss|biome|oxlint)[^/]*\.(?:jsonc?|[cm]?[jt]s)|\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?)$/.test(entry.name)) files.add(entry.name);
19
+ if (entry.isFile() && /^(?:(?:ts|js)config[^/]*\.jsonc?|(?:vite|vitest|playwright|linaria|babel|postcss|biome|oxlint)[^/]*\.(?:jsonc?|[cm]?[jt]s)|\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?)$/.test(entry.name)) files.add(entry.name);
20
20
  }
21
21
  const resolve = importResolver(options), visited = new Set();
22
22
  function visit(file, entrypoint = false, typeConfig = false) {
23
23
  if (!isInside(root, file) || file === root || /(?:^|[\\/])(?:node_modules|\.git|\.ds|dist)(?:[\\/]|$)/.test(path.relative(root, file)) || visited.has(file) || !existsSync(file)) return;
24
+ // Registering a test command must not reassign its colocated checks or
25
+ // fixtures to configuration. Those remain in the behavior owner's patch.
26
+ if (isInside(dsRoot, file) && isDevelopmentFile(file, options)) return;
24
27
  if (!entrypoint && isInside(sourceRoot, file) && !isInside(dsRoot, file)) return;
25
- if (!entrypoint && isInside(dsRoot, file) && /^(?:components|patterns|composition|tokens)[\\/]/.test(path.relative(dsRoot, file))) {
26
- if (/\.[jt]sx$|\.(?:scenario|fixture)\./.test(file)) return;
28
+ if (!entrypoint && isInside(dsRoot, file) && /^(?:components|patterns|controllers|composition|tokens)[\\/]/.test(path.relative(dsRoot, file))) {
29
+ if (isInside(path.join(dsRoot, "controllers"), file) || isDevelopmentFile(file, options)) return;
30
+ const folder = path.dirname(file), stem = path.basename(file).split(".")[0];
31
+ const anchored = ["index", path.basename(folder)].some(name => [".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"].some(extension => existsSync(path.join(folder, name + extension))));
32
+ const sidecar = path.basename(file).split(".").length > 2 && [".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"].some(extension => existsSync(path.join(folder, stem + extension)));
33
+ if (anchored || sidecar || /\.[jt]sx$/.test(file)) return;
27
34
  if (source.test(file) && /(?:from\s*["'](?:react|preact|@linaria\/)|\b(?:require|import)\s*\(\s*["'](?:react|preact|@linaria\/))/.test(readFileSync(file,"utf8"))) return;
28
35
  }
29
36
  visited.add(file);
@@ -65,6 +72,10 @@ export function configurationFiles(options) {
65
72
  if (["npm", "bun"].includes(watch.command) && watch.args[0] === "run") script(watch.args[1]);
66
73
  else for (const argument of [watch.command, ...watch.args]) if (/\.[cm]?[jt]sx?$/.test(argument)) visit(path.resolve(root, argument), true);
67
74
  }
75
+ for (const profile of Object.values(config.mcp.behaviorProfiles)) {
76
+ if (["npm", "bun"].includes(profile.command) && profile.args[0] === "run") script(profile.args[1]);
77
+ else for (const argument of [profile.command, ...profile.args]) if (/\.[cm]?[jt]sx?$/.test(argument)) visit(path.resolve(root, argument), true);
78
+ }
68
79
  return files;
69
80
  }
70
81
 
@@ -89,15 +100,16 @@ export function inspectUnits(options) {
89
100
  const sameName = name => name.replace(/[-_]/g, "").toLowerCase();
90
101
  const flatFamilies = new Set(files.filter(file => /\.scenario\.[cm]?[jt]sx?$/.test(file)).map(file => path.posix.dirname(file)));
91
102
  const directories = new Set(files.filter(file => /(?:^|\/)Scenario\.[cm]?[jt]sx?$/.test(file)).map(file => slash(path.dirname(file))));
103
+ for (const file of files.filter(file => /^(?:components|patterns)\/.+\/tokens\.json$/.test(file))) directories.add(path.posix.dirname(file));
92
104
  for (const file of files) {
93
- if (!/^(?:components|patterns|composition)\//.test(file) || !source.test(file)) continue;
105
+ if (!/^(?:components|patterns|controllers|composition)\//.test(file) || !source.test(file)) continue;
94
106
  const directory = slash(path.dirname(file)), name = path.basename(file).replace(source, "");
95
107
  if (!directory.includes("/") || /\.(?:scenario|fixture|scenario-adapter)$/.test(name)) continue;
96
108
  if (flatFamilies.has(directory)) continue;
97
109
  if (name === "index" || sameName(name) === sameName(path.posix.basename(directory)) || name === "fixture") directories.add(directory);
98
110
  }
99
111
  const directoryUnits = [...directories].sort((a, b) => b.length - a.length);
100
- const flatUnits = [...new Set(files.filter(file => /^(?:components|patterns|composition)\//.test(file) && source.test(file) && !directoryUnits.some(directory => file.startsWith(directory + "/"))).map(file => {
112
+ const flatUnits = [...new Set(files.filter(file => /^(?:components|patterns|controllers|composition)\//.test(file) && source.test(file) && !directoryUnits.some(directory => file.startsWith(directory + "/"))).map(file => {
101
113
  const base = path.basename(file).split(".")[0];
102
114
  return slash(path.join(path.dirname(file), base));
103
115
  }))].sort((a, b) => b.length - a.length);
@@ -113,7 +125,7 @@ export function inspectUnits(options) {
113
125
  if (relative.startsWith(tokenDir + "/")) return "tokens";
114
126
  if (relative.startsWith("assets/")) return relative.split("/").length > 2 ? relative.split("/").slice(0, 2).join("/") : "assets";
115
127
  if (!relative.includes("/")) return /(?:\.(?:md|txt)$|^LICENSE$)/i.test(relative) ? "documentation" : "foundation";
116
- if (!/^(?:components|patterns|composition)\//.test(relative)) return relative.split("/")[0];
128
+ if (!/^(?:components|patterns|controllers|composition)\//.test(relative)) return relative.split("/")[0];
117
129
  for (const directory of directoryUnits) if (relative.startsWith(directory + "/")) return directory;
118
130
  for (const unit of flatUnits) if (relative.startsWith(unit + "/") || relative.startsWith(unit + ".")) return unit;
119
131
  const base = path.basename(relative).split(".")[0];
@@ -134,6 +146,12 @@ export function inspectUnits(options) {
134
146
  }
135
147
  for (const unit of units.values()) if (boundaryCompanion(unit.id)) {
136
148
  (unit.companionFiles ??= []).push({ path: "ds.config.json", allowedChanges: "Only this owner's inlineStyleFiles and boundaries.styleWrites entries, together with its source change; preserve all other configuration and peer permissions." });
149
+ if (/^(?:components|patterns|controllers|composition)\//.test(unit.id)) {
150
+ for (let directory = unit.id; directory !== "."; directory = path.posix.dirname(directory)) {
151
+ const marker = path.join(dsRoot, directory, ".gitkeep");
152
+ if (existsSync(marker) && readFileSync(marker).length === 0) (unit.companionFiles ??= []).push({ path: slash(path.relative(root, marker)), allowedChanges: "Delete this empty placeholder only while adding real source in the selected owner below it." });
153
+ }
154
+ }
137
155
  }
138
156
  return { units: [...units.values()].sort((a, b) => a.id.localeCompare(b.id)), owner, publicEntry, stylesEntry, tokenDir,
139
157
  relative: file => slash(path.relative(dsRoot, path.resolve(root, file))) };
@@ -142,7 +160,7 @@ export function inspectUnits(options) {
142
160
  function boundaryCompanion(unit) { return !["configuration", "documentation", "assets"].includes(unit.split("/")[0]); }
143
161
 
144
162
  export function validateUnitId(unit) {
145
- if (typeof unit !== "string" || unit.length > 256 || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(unit) || ["components", "patterns", "composition"].includes(unit)) throw fail("Select one ADS unit ID from ads_units or an unoccupied owner path following its colocation convention.");
163
+ if (typeof unit !== "string" || unit.length > 256 || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(unit) || ["components", "patterns", "controllers", "composition"].includes(unit)) throw fail("Select one ADS unit ID from ads_units or an unoccupied owner path following its colocation convention.");
146
164
  }
147
165
 
148
166
  /** Reject arbitrary file batches, even when every path is inside the ADS. */
@@ -150,6 +168,14 @@ export function checkUnitFiles(options, unit, changes, inventory = inspectUnits(
150
168
  validateUnitId(unit);
151
169
  for (const change of changes) {
152
170
  const relative = inventory.relative(change.path);
171
+ if (path.posix.basename(relative) === ".gitkeep") {
172
+ const directory = path.posix.dirname(relative);
173
+ if (change.originalContent !== "" || change.content !== null || !/^(?:components|patterns|controllers|composition)\//.test(relative)
174
+ || !(unit === directory || unit.startsWith(directory + "/"))
175
+ || !changes.some(item => item.originalContent === null && item.content !== null && source.test(item.path)
176
+ && (inventory.relative(item.path).startsWith(unit + "/") || inventory.relative(item.path).startsWith(unit + ".")))) throw fail("Only delete an existing empty .gitkeep while adding source in the selected owner beneath it. Other hidden-file changes are not allowed.", change.path);
177
+ continue;
178
+ }
153
179
  if (allowNew && unit === "configuration") continue; // The candidate must register every setup/extension file.
154
180
  if (change.path === "ds.config.json" && boundaryCompanion(unit)) continue; // Validated against only this owner below.
155
181
  if (relative === inventory.publicEntry) continue;
@@ -207,7 +233,7 @@ export function checkUnitCandidate(afterOptions, unit, changes, before) {
207
233
  }
208
234
  const exportChange = changes.find(change => before.relative(change.path) === before.publicEntry);
209
235
  if (exportChange) {
210
- if (!["components", "patterns"].includes(unit.split("/")[0])) throw fail("Only a component/pattern unit may update its public re-exports.", exportChange.path);
236
+ if (!["components", "patterns", "controllers"].includes(unit.split("/")[0])) throw fail("Only a component/pattern/controller unit may update its public re-exports.", exportChange.path);
211
237
  const current = exportChange.originalContent ?? "", proposed = exportChange.content;
212
238
  if (proposed === null) throw fail("The shared public entry cannot be deleted.", exportChange.path);
213
239
  const resolve = importResolver(afterOptions);
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /** Development-only Playwright helper. No browser or framework dependency is installed. */
4
+ export function verificationRequest() {
5
+ if (!process.env.ADS_VERIFY_REQUEST) throw new Error("Run this check through ads_verify so the request and selected profile are explicit.");
6
+ return JSON.parse(readFileSync(process.env.ADS_VERIFY_REQUEST, "utf8"));
7
+ }
8
+
9
+ export async function openBehaviorScenario(page, checkId, { timeout = 30000 } = {}) {
10
+ const request = verificationRequest();
11
+ const check = request.checks.find(check => check.id === checkId);
12
+ const url = check && request.scenarios[check.scenario];
13
+ if (!url) throw new Error("This check has no managed browser scenario. StrictMode profiles must supply a project development harness; production preview bundles do not replay React development effects.");
14
+ await page.goto(url, { waitUntil: "load", timeout });
15
+ const end = Date.now() + timeout;
16
+ while (Date.now() < end) {
17
+ for (const frame of page.frames().filter(frame => frame !== page.mainFrame())) {
18
+ const state = await frame.evaluate(() => ({ state: document.documentElement.dataset.adsPreviewState, id: document.documentElement.dataset.adsScenario, message: document.getElementById("ads-preview-status")?.textContent })).catch(() => ({}));
19
+ if (state.id === check.scenario && state.state === "ready") return frame;
20
+ if (state.state === "error") throw new Error(state.message || "The MCP App failed before verification.");
21
+ }
22
+ const error = await page.evaluate(() => document.querySelector('#status[data-error="true"]')?.textContent).catch(() => null);
23
+ if (error) throw new Error(error);
24
+ await page.waitForTimeout(50);
25
+ }
26
+ throw new Error("Timed out waiting for the selected MCP App scenario to become current and ready. Inspect ads_status and ads://project/build.");
27
+ }
@@ -0,0 +1,20 @@
1
+ const { readFileSync, writeFileSync } = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ /** Case identity is an explicit project test tag: @ads:<case-id>. */
5
+ module.exports = class AdsBehaviorReporter {
6
+ constructor() {
7
+ this.request = JSON.parse(readFileSync(process.env.ADS_VERIFY_REQUEST, "utf8"));
8
+ this.cases = [];
9
+ }
10
+ onTestEnd(test, result) {
11
+ const file = path.relative(this.request.projectRoot, test.location.file).replaceAll("\\", "/");
12
+ for (const tag of test.tags ?? []) {
13
+ if (!tag.startsWith("@ads:")) continue;
14
+ this.cases.push({ id: tag.slice(5), file, status: result.status === "passed" && test.expectedStatus === "passed" ? "passed" : ["skipped", "interrupted"].includes(result.status) ? "skipped" : "failed", message: result.errors?.map(error => error.message).join("\n").slice(0, 4000) });
15
+ }
16
+ }
17
+ onEnd() {
18
+ writeFileSync(process.env.ADS_VERIFY_REPORT, JSON.stringify({ schemaVersion: 1, runId: this.request.runId, cases: this.cases.slice(0, 500) }));
19
+ }
20
+ };
@@ -0,0 +1,214 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import ts from "typescript";
7
+ import postcss from "postcss";
8
+ import valueParser from "postcss-value-parser";
9
+ import { loadConfig, sourceFiles, isInside } from "./config.mjs";
10
+ import { importResolver } from "./boundaries.mjs";
11
+ import { inspectUnits, configurationFiles } from "./units.mjs";
12
+ import { inspectBehaviors } from "./behaviors.mjs";
13
+ import { inspectScenarios } from "./scenarios.mjs";
14
+ import { readPreviewManifest } from "./preview-resources.mjs";
15
+
16
+ export const BEHAVIOR_URI = "ads://project/behaviors";
17
+ const hash = bytes => createHash("sha256").update(bytes).digest("hex");
18
+ const slash = file => file.replaceAll("\\", "/");
19
+ const resultFile = root => path.join(root, ".ds/behavior-results.json");
20
+
21
+ /** Follow real project imports; package/configuration changes also invalidate evidence. */
22
+ function inputContext(options) { return { inventory: inspectUnits(options), scenarios: inspectScenarios(options).scenarios }; }
23
+
24
+ export function behaviorInputs(options, contract, check, context = inputContext(options)) {
25
+ const files = new Set(), resolve = importResolver(options);
26
+ function visit(file) {
27
+ file = path.resolve(file);
28
+ if (files.has(file) || !isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
29
+ if (lstatSync(file).isSymbolicLink() || realpathSync(file) !== file) throw new Error(`Behavior inputs cannot resolve through symlinks: ${file}`);
30
+ if (!lstatSync(file).isFile()) return;
31
+ files.add(file);
32
+ const localAsset = specifier => {
33
+ if (!specifier || /^(?:[a-z][\w+.-]*:|\/|#)/i.test(specifier)) return;
34
+ visit(path.resolve(path.dirname(file), specifier.replace(/[?#].*$/, "")));
35
+ };
36
+ if (file.endsWith(".css")) {
37
+ const css = postcss.parse(readFileSync(file, "utf8"), { from: file });
38
+ css.walkAtRules("import", rule => { const first = valueParser(rule.params).nodes.find(node => node.type !== "space" && node.type !== "comment"); if (first?.type === "string") localAsset(first.value); });
39
+ css.walk(node => { if (node.type === "decl" || node.type === "atrule") valueParser(node.type === "decl" ? node.value : node.params).walk(value => { if (value.type === "function" && value.value.toLowerCase() === "url") localAsset(value.nodes.filter(part => part.type !== "space").map(part => part.value).join("")); }); });
40
+ }
41
+ if (!/\.[cm]?[jt]sx?$/.test(file)) return;
42
+ const ast = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
43
+ function walk(node) {
44
+ let specifier;
45
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
46
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) specifier = node.arguments[0].text;
47
+ if (specifier) for (const target of resolve(specifier, file)) visit(target);
48
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "URL" && node.arguments?.[0] && ts.isStringLiteralLike(node.arguments[0])) localAsset(node.arguments[0].text);
49
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0]))) {
50
+ // Computed module selection cannot provide a precise dependency closure.
51
+ // Conservatively invalidate against every owned ADS input instead.
52
+ for (const unit of context.inventory.units) for (const owned of unit.files) visit(path.join(options.root, owned));
53
+ }
54
+ ts.forEachChild(node, walk);
55
+ }
56
+ walk(ast);
57
+ }
58
+ for (const file of context.inventory.units.find(unit => unit.id === contract.unit)?.files ?? []) visit(path.join(options.root, file));
59
+ for (const file of configurationFiles(options)) visit(path.join(options.root, file));
60
+ for (const file of sourceFiles(path.join(options.dsRoot, options.config.tokenDir))) visit(file);
61
+ visit(path.join(options.dsRoot, options.config.stylesEntry));
62
+ const scenario = check.scenario && context.scenarios.find(item => item.id === check.scenario);
63
+ if (scenario) visit(path.join(options.root, scenario.file));
64
+ const rows = [...files].sort().map(file => [slash(path.relative(options.root, file)), hash(readFileSync(file))]);
65
+ for (const file of ["package-lock.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "node_modules/.package-lock.json"]) rows.push([file, existsSync(path.join(options.root, file)) ? hash(readFileSync(path.join(options.root, file))) : null]);
66
+ rows.push(["profile", JSON.stringify(options.config.mcp.behaviorProfiles[check.profile])], ["node", process.version]);
67
+ for (const file of ["./verification.mjs", "./verification/browser.mjs", "./verification/playwright-reporter.cjs"]) rows.push([`tool:${file}`, hash(readFileSync(new URL(file, import.meta.url)))]);
68
+ return { revision: hash(JSON.stringify(rows)), files: rows.map(([file]) => file) };
69
+ }
70
+
71
+ function saved(root) {
72
+ try {
73
+ const file = resultFile(root);
74
+ if (realpathSync(file) !== file || lstatSync(file).size > 2 * 1024 * 1024) return {};
75
+ const value = JSON.parse(readFileSync(file, "utf8"));
76
+ return value.schemaVersion === 1 && value.results && typeof value.results === "object" && !Array.isArray(value.results) ? value.results : {};
77
+ } catch { return {}; }
78
+ }
79
+ const identity = (unit, check) => `${unit}#${check}`;
80
+
81
+ export function behaviorStatus(options) {
82
+ const inventory = inspectBehaviors(options), evidence = saved(options.root);
83
+ const context = inventory.contracts.length ? inputContext(options) : null;
84
+ return { ...inventory, contracts: inventory.contracts.map(contract => {
85
+ const checks = Object.fromEntries(Object.entries(contract.checks).map(([id, check]) => {
86
+ const previous = evidence[identity(contract.unit, id)];
87
+ const revision = behaviorInputs(options, contract, check, context).revision;
88
+ const current = previous?.revision === revision && previous.profile === check.profile && previous.file === check.file && previous.case === check.case;
89
+ return [id, { ...check, result: previous ? { ...previous, current, status: current ? previous.status : "stale" } : { status: "not_run", current: false } }];
90
+ }));
91
+ return { ...contract, checks, guarantees: contract.guarantees.map(guarantee => {
92
+ const results = guarantee.checks.map(id => checks[id]?.result ?? { status: "unavailable", current: false });
93
+ const status = results.every(result => result.current && result.status === "passed") ? "passed" : results.find(result => result.status !== "passed")?.status ?? "not_run";
94
+ return { ...guarantee, result: { status, current: results.every(result => result.current) } };
95
+ }) };
96
+ }) };
97
+ }
98
+
99
+ export function affectedBehaviorUnits(options, changedFiles) {
100
+ const changed = new Set(changedFiles.map(file => slash(path.relative(options.root, path.resolve(options.root, file)))));
101
+ const contracts = inspectBehaviors(options).contracts;
102
+ if (!contracts.length) return [];
103
+ const context = inputContext(options);
104
+ return contracts.filter(contract => Object.values(contract.checks).some(check => behaviorInputs(options, contract, check, context).files.some(file => changed.has(file)))).map(contract => contract.unit);
105
+ }
106
+
107
+ function execute(profile, args, root, env, signal) {
108
+ return new Promise((resolve, reject) => {
109
+ if (signal.aborted) { reject(new Error("Behavior verification cancelled.")); return; }
110
+ let output = "", failure, child, guard, done = false;
111
+ const kill = () => {
112
+ if (!child?.pid) return;
113
+ if (process.platform === "win32") { const stop = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); stop.on("error", () => child.kill("SIGKILL")); }
114
+ else try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") failure ??= error; }
115
+ };
116
+ child = spawn(profile.command === "node" ? process.execPath : profile.command, args, { cwd: root, detached: process.platform !== "win32", shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
117
+ env: { ...process.env, ...env, PATH: [path.join(root, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) } });
118
+ if (child.pid) {
119
+ guard = spawn(process.execPath, [fileURLToPath(new URL("./mcp/preview-owner-guard.mjs", import.meta.url)), String(child.pid)], { detached: process.platform !== "win32", stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
120
+ guard.stdin.on("error", () => {});
121
+ guard.once("error", error => { failure = error; kill(); });
122
+ guard.once("exit", () => { if (!done) { failure ??= new Error("Verification ownership guard exited unexpectedly."); kill(); } });
123
+ }
124
+ const abort = () => { failure = new Error("Behavior verification cancelled."); kill(); };
125
+ signal.addEventListener("abort", abort, { once: true });
126
+ const timeout = setTimeout(() => { failure = new Error("Behavior verification timed out. Inspect the registered runner and its browser/server cleanup."); kill(); }, profile.timeoutMs ?? 60000);
127
+ for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { output = (output + chunk.toString()).slice(-12000); });
128
+ child.once("error", error => { failure = error; });
129
+ child.once("exit", kill);
130
+ child.once("close", code => {
131
+ done = true; kill(); guard?.stdin.end("disarm\n"); clearTimeout(timeout); signal.removeEventListener("abort", abort);
132
+ if (failure) reject(Object.assign(failure, { output }));
133
+ else resolve({ code, output });
134
+ });
135
+ });
136
+ }
137
+
138
+ /** Registered development commands are trusted project code, never model-supplied commands. */
139
+ export function createVerificationController(root) {
140
+ root = path.resolve(root);
141
+ let active;
142
+ return {
143
+ status: () => ({ running: !!active }),
144
+ async close() { active?.abort(); await active?.completion; },
145
+ async run({ unit, units, checks, persist = true } = {}, signal) {
146
+ if (active) return { ok: false, diagnostics: [{ rule: "behavior-busy", message: "A behavior verification is already running." }], results: [] };
147
+ active = new AbortController();
148
+ const controller = active;
149
+ signal = signal ? AbortSignal.any([signal, active.signal]) : active.signal;
150
+ let finish; controller.completion = new Promise(resolve => { finish = resolve; });
151
+ let host;
152
+ const results = [], diagnostics = [];
153
+ try {
154
+ const options = loadConfig(root), inventory = inspectBehaviors(options);
155
+ if (inventory.diagnostics.length) return { ok: false, diagnostics: inventory.diagnostics, results };
156
+ const selectedUnits = new Set(units ?? (unit ? [unit] : inventory.contracts.map(item => item.unit)));
157
+ const contracts = inventory.contracts.filter(item => selectedUnits.has(item.unit));
158
+ if (!contracts.length || [...selectedUnits].some(id => !contracts.some(contract => contract.unit === id))) return { ok: false, diagnostics: [{ rule: "behavior-selection", message: "No behavior contract matches the complete selection. Discover exact owner/check IDs with ads_behaviors." }], results };
159
+ const context = inputContext(options);
160
+ const selection = contracts.flatMap(contract => Object.entries(contract.checks).filter(([id]) => !checks || checks.includes(id)).map(([id, check]) => ({ unit: contract.unit, id, ...check, inputRevision: behaviorInputs(options, contract, check, context).revision, contract })));
161
+ if (!selection.length || checks?.some(id => !selection.some(check => check.id === id))) return { ok: false, diagnostics: [{ rule: "behavior-selection", message: "Unknown check selection. Use check IDs returned by ads_behaviors." }], results };
162
+ for (const profileName of new Set(selection.map(check => check.profile))) {
163
+ const profile = options.config.mcp.behaviorProfiles[profileName];
164
+ const group = selection.filter(check => check.profile === profileName);
165
+ const directory = path.join(root, ".ds/verification", randomUUID());
166
+ mkdirSync(directory, { recursive: true });
167
+ if (realpathSync(directory) !== directory) throw new Error("Verification output cannot resolve through a symlink.");
168
+ const runId = randomUUID(), report = path.join(directory, "report.json"), request = path.join(directory, "request.json");
169
+ const scenarios = {};
170
+ if (profile.mode === "browser" && group.some(check => check.scenario)) {
171
+ const { startAdsMcp } = await import("./mcp/server.mjs");
172
+ host ??= await startAdsMcp(root, { stdio: false, showcase: true });
173
+ for (const check of group.filter(check => check.scenario)) { const url = new URL(host.url); url.searchParams.set("scenario", check.scenario); scenarios[check.scenario] = url.href; }
174
+ }
175
+ const requestContext = { schemaVersion: 1, runId, projectRoot: root, framework: options.config.framework, profile: profileName, mode: profile.mode ?? "unit", checks: group.map(({ contract: _contract, ...check }) => check), scenarios };
176
+ writeFileSync(request, JSON.stringify(requestContext));
177
+ const args = [...profile.args];
178
+ if (profile.runner === "playwright") args.push(...new Set(group.map(check => slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file))))), `--reporter=${fileURLToPath(new URL("./verification/playwright-reporter.cjs", import.meta.url))}`, `--output=${path.join(directory, "artifacts")}`);
179
+ let run, reportValue, runnerError;
180
+ try {
181
+ run = await execute(profile, args, root, { ADS_VERIFY_REQUEST: request, ADS_VERIFY_REPORT: report, ADS_VERIFY_MODE: requestContext.mode }, signal);
182
+ if (!existsSync(report) || realpathSync(report) !== report || lstatSync(report).size > 1024 * 1024) throw new Error("Runner produced no valid bounded verification report. A successful exit alone is not passing evidence.");
183
+ reportValue = JSON.parse(readFileSync(report, "utf8"));
184
+ if (reportValue.schemaVersion !== 1 || reportValue.runId !== runId || !Array.isArray(reportValue.cases) || reportValue.cases.length > 500) throw new Error("Runner report has the wrong run ID/schema or too many cases.");
185
+ } catch (error) { runnerError = error; diagnostics.push({ rule: "behavior-runner", message: error.message, output: error.output?.slice(-4000), profile: profileName }); }
186
+ const afterOptions = loadConfig(root), afterContext = inputContext(afterOptions);
187
+ for (const check of group) {
188
+ const file = slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file)));
189
+ const matches = reportValue?.cases.filter(item => item && item.id === check.case && item.file === file) ?? [];
190
+ let status = !run || run.code !== 0 ? "failed" : matches.length && matches.every(item => item.status === "passed") ? "passed" : matches.length && matches.every(item => item.status === "skipped") ? "skipped" : "failed";
191
+ if (runnerError?.code === "ENOENT") status = "unavailable";
192
+ const current = behaviorInputs(afterOptions, check.contract, check, afterContext).revision === check.inputRevision;
193
+ if (!current) status = "stale";
194
+ if (signal.aborted) status = "cancelled";
195
+ results.push({ unit: check.unit, check: check.id, file: check.file, case: check.case, profile: profileName, status, current, revision: check.inputRevision, runId, completedAt: new Date().toISOString(),
196
+ previewRevision: profile.mode === "browser" ? host?.previewRevision() ?? readPreviewManifest(options)?.revision ?? null : null,
197
+ message: runnerError?.message ?? (!matches.length ? "The selected case did not execute/report. Missing and skipped cases do not pass." : matches.filter(item => item.status !== "passed").map(item => String(item.message ?? item.status).slice(0, 2000)).join("\n")), report: slash(path.relative(root, report)) });
198
+ }
199
+ if (signal.aborted) break;
200
+ }
201
+ if (persist) {
202
+ const evidence = saved(root);
203
+ for (const result of results) evidence[identity(result.unit, result.check)] = result;
204
+ const file = resultFile(root), temporary = `${file}.${randomUUID()}.tmp`;
205
+ const content = JSON.stringify({ schemaVersion: 1, results: evidence });
206
+ if (Buffer.byteLength(content) > 2 * 1024 * 1024) throw new Error("Verification evidence exceeds 2 MiB. Remove obsolete generated evidence and retry.");
207
+ try { writeFileSync(temporary, content); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); }
208
+ }
209
+ return { ok: diagnostics.length === 0 && results.length > 0 && results.every(result => result.status === "passed" && result.current), diagnostics, results };
210
+ } catch (error) { return { ok: false, diagnostics: [{ rule: "behavior-verification", message: error.message }], results }; }
211
+ finally { try { await host?.close(); } finally { if (active === controller) active = undefined; finish(); } }
212
+ },
213
+ };
214
+ }
package/docs/ADS.md CHANGED
@@ -7,7 +7,8 @@ UX, and Skin pillars. [GUIDE.md](GUIDE.md) gives setup and configuration steps.
7
7
  ## Ownership boundary
8
8
 
9
9
  MCP source editing is opt-in through `ads mcp --mutations`. Its write operation
10
- accepts a Git unified diff for one ADS unit and validates its source, scenario and
10
+ accepts a Git unified diff for one ADS unit (or bounded owner patches for coupled
11
+ transactions) and validates its source, scenario and
11
12
  fixture changes against core checks, registered Oxlint rules and project checks
12
13
  before application. This includes
13
14
  foundation, documentation, original assets and an explicit configuration unit for
@@ -88,6 +89,21 @@ design-system/
88
89
  main.tsx
89
90
  ```
90
91
 
92
+ Private controllers remain in that component/pattern owner. Shared controllers use
93
+ `controllers/<Name>/` with an index or same-name runtime entry and independently
94
+ guarded public re-exports. They may use the configured framework and declared
95
+ dependencies, plain TypeScript and browser APIs; CSS/Linaria and visual DOM belong
96
+ to consumers. They require no artificial visual scenario.
97
+
98
+ Optional colocated `behavior.json` (flat: `<Owner>.behavior.json`) registers
99
+ subjects, guarantees and owner-local checks against project-configured runner
100
+ profiles. `exercises` records public behavioral APIs demonstrated by a scenario;
101
+ `covers` retains its rendered UI contract. ADS validates references and reachable
102
+ uses; execution evidence is separate and becomes stale when its inputs change.
103
+ Runtime imports/re-exports of manifests, tests, fixtures and scenarios are rejected.
104
+ See [controllers and behavioral verification](BEHAVIORS.md) for the schema,
105
+ framework/dependency gates and MCP execution contract.
106
+
91
107
  Flat owners may use `Button.scenario.tsx` and `Button.fixture.ts` instead.
92
108
  `Scenario.tsx` exports a default independently mountable view and literal metadata
93
109
  `export const scenario = { title: "Button states", covers: ["Button"] };`.
@@ -131,7 +147,7 @@ mounting, correct props, every branch/state or visual parity. The older
131
147
 
132
148
  `token-scenario-reference` rejects undeclared token names in metadata and reachable
133
149
  inputs. `token-scenario-render` rejects claimed tokens with no reachable input
134
- reference; `token-scenario-coverage` requires every discovered foundation token
150
+ reference; `token-scenario-coverage` requires every discovered shared or owner-local token
135
151
  to have coverage. A new or removed token therefore needs a scenario/fixture
136
152
  update. All three are errors in `ads check`, run by `lint:ds` and the build.
137
153
  Coverage is static reachability, not proof that every branch actually renders
@@ -153,6 +169,48 @@ preview builds; review meaningful states in both themes and sizes.
153
169
 
154
170
  ## Shared token contract
155
171
 
172
+ ### Owner-local tokens
173
+
174
+ Shared semantics remain in the configured token foundation. Constants specific
175
+ to one component or pattern may live beside it:
176
+
177
+ ```text
178
+ components/Notice/
179
+ Notice.tsx # imports ./tokens.css; UI styling stays in Linaria
180
+ tokens.json # {"schemaVersion":1,"namespace":"--notice-"}
181
+ tokens.css # :root { --notice-width: 356px; }
182
+ Scenario.tsx
183
+ fixture.ts
184
+ Tokens.scenario.tsx # kind: "tokens", public review pattern + samples
185
+ Tokens.fixture.ts
186
+ ```
187
+
188
+ Flat owners use `<Owner>.tokens.json` and `<Owner>.tokens.css`. A unit has one
189
+ explicit namespace, unique across owners and separate from shared/runtime-variable
190
+ prefixes. The stylesheet contains only namespaced custom-property definitions
191
+ under root/theme conditions; imports, selectors for UI, ordinary declarations,
192
+ font faces and animations remain outside it. Its runtime owner imports it, so
193
+ product and preview consume the same values. Raw UI styling remains token-backed.
194
+
195
+ Local token aliases may reference shared tokens or their own namespace, with
196
+ undefined references and cycles rejected. Other owners consume shared tokens;
197
+ they cannot redefine, write or directly consume another owner's local namespace.
198
+ Measured runtime variables retain their existing exact `styleWrites` boundary.
199
+ `ads_tokens` returns each token's owner, definitions and local namespace.
200
+
201
+ A colocated scenario sets `kind:"tokens"` and declares real fixture `samples`
202
+ consumed by a public review pattern. Its ID still derives from its source path.
203
+ Every local token needs this real sample coverage, just like shared tokens.
204
+ Definitions, metadata, fixture and token scenario belong to the same edit unit.
205
+ Manifests/fixtures are development inputs; production includes the runtime's
206
+ imported CSS definitions, never the preview adapter or tooling manifest.
207
+
208
+ Use `ads_prepare_token_samples` to propose sample reconciliation from explicit
209
+ project property/probe bindings. The tool preserves project sample meaning and
210
+ reports missing mappings instead of choosing them from token prefixes.
211
+
212
+ ### Shared definitions
213
+
156
214
  Keep compatible token names, meanings, value types, and alias relationships;
157
215
  project values and theme overrides may differ. The
158
216
  [Data pillar](ARCHITECTURE.md#data-gives-implementations-a-common-language)
@@ -160,7 +218,17 @@ explains the model.
160
218
 
161
219
  Consume token references instead of raw visual values. Color formulas, effects,
162
220
  durations, easing, typography, sizing, and layering values belong in the token
163
- source.
221
+ source. Absolute and font-relative length literals require token references,
222
+ including `ch`, `ex`, `cap`, `ic`, `lh` and their root-relative forms, even inside
223
+ `calc()`, `min()` or fallbacks. CSS numeric notation does not change ownership.
224
+ Unitless geometry, percentages, grid fractions and viewport/container relationships
225
+ retain their structural use.
226
+
227
+ Angle units (`deg`, `grad`, `rad`, `turn`) remain available for geometric rotation,
228
+ skew, orientation and complete turns. Static checks do not infer whether an angle
229
+ is art direction: put a tunable visual angle in the project's tokens when it is
230
+ part of that design contract. Angle-bearing color effects still require tokens.
231
+ These categories follow [CSS Values and Units](https://www.w3.org/TR/css-values-4/#lengths).
164
232
 
165
233
  Token names and ownership prefixes are fixed by ADS architecture, not project
166
234
  configuration. `--ds-*` belongs to components, `--ux-*` to patterns, and
@@ -220,7 +288,7 @@ motion values.
220
288
 
221
289
  External content workflows are agent skills; there is no CLI
222
290
  `ads import` command. Use the [skill guide](../skills/README.md) for installation,
223
- selection, import, assimilation, stress testing, and motion workflows.
291
+ selection, import, migration, stress testing, and motion workflows.
224
292
 
225
293
  Record the source revision and integrate selected source, dependencies, public
226
294
  exports, assets, documentation, and applicable rules. Preserve destination token