@reqvire-org/ads 0.3.3 → 0.3.5

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 (52) hide show
  1. package/README.md +12 -4
  2. package/dist/behaviors.mjs +157 -0
  3. package/dist/bootstrap-ds/ADS.md +7 -0
  4. package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  5. package/dist/check.mjs +7 -2
  6. package/dist/cli.mjs +17 -0
  7. package/dist/config.mjs +19 -3
  8. package/dist/css-values.mjs +26 -0
  9. package/dist/index.mjs +2 -0
  10. package/dist/init.mjs +1 -1
  11. package/dist/mcp/build-status.mjs +10 -3
  12. package/dist/mcp/catalog.mjs +11 -8
  13. package/dist/mcp/context.mjs +2 -2
  14. package/dist/mcp/mutations.mjs +10 -0
  15. package/dist/mcp/preview-owner-guard.mjs +29 -0
  16. package/dist/mcp/preview-watch.mjs +28 -4
  17. package/dist/mcp/preview-widget.bundle.js +2 -2
  18. package/dist/mcp/prompts.mjs +2 -2
  19. package/dist/mcp/server.mjs +56 -19
  20. package/dist/mcp/showcase-host.mjs +9 -5
  21. package/dist/public-api.mjs +39 -0
  22. package/dist/scenarios-compiler.mjs +17 -2
  23. package/dist/scenarios.mjs +27 -39
  24. package/dist/skills/README.md +3 -3
  25. package/dist/skills/ads-break-ui/SKILL.md +22 -0
  26. package/dist/skills/ads-designer/SKILL.md +23 -1
  27. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +7 -0
  28. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  29. package/dist/skills/ads-designer/references/cli.md +1 -1
  30. package/dist/skills/ads-designer/references/design-craft.md +1 -1
  31. package/dist/skills/ads-import/SKILL.md +22 -0
  32. package/dist/skills/ads-import/references/cli.md +1 -1
  33. package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +28 -6
  34. package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
  35. package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
  36. package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +11 -0
  37. package/dist/skills/ads-motion/SKILL.md +22 -0
  38. package/dist/tokens.mjs +3 -2
  39. package/dist/units.mjs +20 -9
  40. package/dist/verification/browser.mjs +27 -0
  41. package/dist/verification/playwright-reporter.cjs +20 -0
  42. package/dist/verification.mjs +214 -0
  43. package/docs/ADS.md +27 -2
  44. package/docs/ARCHITECTURE.md +39 -1
  45. package/docs/BEHAVIORS.md +230 -0
  46. package/docs/CHANGELOG.md +36 -0
  47. package/docs/GUIDE.md +16 -1
  48. package/docs/MCP.md +46 -7
  49. package/package.json +4 -2
  50. package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
  51. /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
  52. /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
@@ -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" ? 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
@@ -88,6 +88,21 @@ design-system/
88
88
  main.tsx
89
89
  ```
90
90
 
91
+ Private controllers remain in that component/pattern owner. Shared controllers use
92
+ `controllers/<Name>/` with an index or same-name runtime entry and independently
93
+ guarded public re-exports. They may use the configured framework and declared
94
+ dependencies, plain TypeScript and browser APIs; CSS/Linaria and visual DOM belong
95
+ to consumers. They require no artificial visual scenario.
96
+
97
+ Optional colocated `behavior.json` (flat: `<Owner>.behavior.json`) registers
98
+ subjects, guarantees and owner-local checks against project-configured runner
99
+ profiles. `exercises` records public behavioral APIs demonstrated by a scenario;
100
+ `covers` retains its rendered UI contract. ADS validates references and reachable
101
+ uses; execution evidence is separate and becomes stale when its inputs change.
102
+ Runtime imports/re-exports of manifests, tests, fixtures and scenarios are rejected.
103
+ See [controllers and behavioral verification](BEHAVIORS.md) for the schema,
104
+ framework/dependency gates and MCP execution contract.
105
+
91
106
  Flat owners may use `Button.scenario.tsx` and `Button.fixture.ts` instead.
92
107
  `Scenario.tsx` exports a default independently mountable view and literal metadata
93
108
  `export const scenario = { title: "Button states", covers: ["Button"] };`.
@@ -160,7 +175,17 @@ explains the model.
160
175
 
161
176
  Consume token references instead of raw visual values. Color formulas, effects,
162
177
  durations, easing, typography, sizing, and layering values belong in the token
163
- source.
178
+ source. Absolute and font-relative length literals require token references,
179
+ including `ch`, `ex`, `cap`, `ic`, `lh` and their root-relative forms, even inside
180
+ `calc()`, `min()` or fallbacks. CSS numeric notation does not change ownership.
181
+ Unitless geometry, percentages, grid fractions and viewport/container relationships
182
+ retain their structural use.
183
+
184
+ Angle units (`deg`, `grad`, `rad`, `turn`) remain available for geometric rotation,
185
+ skew, orientation and complete turns. Static checks do not infer whether an angle
186
+ is art direction: put a tunable visual angle in the project's tokens when it is
187
+ part of that design contract. Angle-bearing color effects still require tokens.
188
+ These categories follow [CSS Values and Units](https://www.w3.org/TR/css-values-4/#lengths).
164
189
 
165
190
  Token names and ownership prefixes are fixed by ADS architecture, not project
166
191
  configuration. `--ds-*` belongs to components, `--ux-*` to patterns, and
@@ -220,7 +245,7 @@ motion values.
220
245
 
221
246
  External content workflows are agent skills; there is no CLI
222
247
  `ads import` command. Use the [skill guide](../skills/README.md) for installation,
223
- selection, import, assimilation, stress testing, and motion workflows.
248
+ selection, import, migration, stress testing, and motion workflows.
224
249
 
225
250
  Record the source revision and integrate selected source, dependencies, public
226
251
  exports, assets, documentation, and applicable rules. Preserve destination token
@@ -149,6 +149,33 @@ preserving compatible consumer APIs. Markup, accessibility, events, layout, and
149
149
  motion still need verification when an implementation changes. Imported or
150
150
  adapted source carries its credits and applicable notices in its local guide.
151
151
 
152
+ ## Controllers own reusable behavior
153
+
154
+ A private controller stays beside its component or pattern. Shared behavior uses
155
+ `controllers/<Name>/` as an independent edit owner. Controllers may be plain
156
+ functions/classes, framework hooks or context providers; sharing code does not
157
+ implicitly share instances. Use the configured framework and declared project
158
+ dependencies, with no runtime supplied by the MCP host. Shared controllers do not
159
+ import styled consumers or introduce another skin layer. Existing measurement and
160
+ style-write boundaries still apply.
161
+
162
+ Optional owner-local `behavior.json` contracts identify public APIs or private
163
+ source/symbols, named guarantees and registered execution checks. Scenario
164
+ `exercises` links public behavior APIs to reachable demonstrations; `covers`
165
+ continues to describe rendered public UI. Controllers need no artificial standalone
166
+ visual scenario. Static registration is separate from current passing evidence.
167
+
168
+ Manifests, checks, scenarios and fixtures are development inputs; production
169
+ imports only actual components/controllers and their styles. Browser checks can
170
+ exercise the real MCP App. React development StrictMode uses a project development
171
+ harness, since production previews do not replay those effects. The
172
+ [behavior contract](BEHAVIORS.md) defines profiles, execution and evidence freshness.
173
+
174
+ The normal compiled import graph delivers controller dependencies through MCP
175
+ resources. There is no eager global controller registry. ESM splitting and selected
176
+ asset loading remain in place; tree shaking depends on actual imports, exports and
177
+ side effects, rather than the controller directory name.
178
+
152
179
  ## The showcase is the shared design workspace
153
180
 
154
181
  Humans and coding agents inspect the same project through MCP. The showcase is
@@ -289,11 +316,22 @@ Bounded tools establish what exists; explicit resource reads provide detail.
289
316
  Prompts and packaged skills explain how to reason through Data, UX and Skin and
290
317
  how to inspect the real implementation before changing it.
291
318
 
319
+ The MCP runtime owns its browser hosts and preview builder lifetime. Closing the
320
+ client connection releases them together; a separate ownership guard can stop
321
+ detached builders after abrupt owner death. A preview process must not outlive
322
+ its owner and continue publishing stale output.
323
+ Build reports expose queued, validation, compilation and publication progress.
324
+ A timed-out preview request marks the builder stalled; the next explicit request
325
+ restarts it within the same MCP session. A running process alone is not freshness.
326
+
292
327
  Opt-in MCP editing makes validation part of the write operation. The agent sends
293
328
  a Git unified diff for one logical ADS unit: its implementation and owned preview
294
329
  context, or the shared token foundation. ADS resolves file ownership, reconstructs
295
330
  candidate files locally and validates a temporary project copy with architecture
296
- checks, registered lint rules and project validation scripts. This keeps edits
331
+ checks, registered lint rules and project validation scripts. Affected registered
332
+ behavior checks, including consumers of a changed shared controller, also run in
333
+ the isolated candidate project. Passing checks describe
334
+ their registered assertions, not every natural-language claim. This keeps edits
297
335
  focused without requiring entire file bodies in model context. Peer units and
298
336
  unrelated owners remain outside the selected unit's edit boundary. Shared foundation,
299
337
  branding and guides have their own units. Project configuration, registered guards
@@ -0,0 +1,230 @@
1
+ # Controllers and behavioral verification
2
+
3
+ Controllers own reusable UI behavior: state, subscriptions, DOM measurements and
4
+ lifecycle coordination. A controller may be a class, function, hook, state
5
+ machine or context provider. Linaria and tokens own its consumers' visual design.
6
+
7
+ Private controllers stay inside their component/pattern mutation unit. Shared
8
+ controllers use `design-system/controllers/<Name>/` with an `index` entry or a
9
+ runtime file matching the directory name. The unit ID is `controllers/<Name>`.
10
+ Its implementation, tests and guides can change together, including only its own
11
+ required public re-exports. A shared controller needs no artificial visual preview.
12
+ Consumers normally create separate instances; sharing implementation does not
13
+ implicitly share state.
14
+
15
+ Use the project's `framework` from `ds.config.json` for framework-specific APIs.
16
+ Plain TypeScript, browser APIs and local behavior helpers remain supported.
17
+ Declare external dependencies in the project package. ADS does not install them
18
+ or assume a renderer/library supplied by an MCP host. The framework field selects
19
+ compilation/mounting; controller checks also validate runtime imports. Type-only
20
+ UI references are allowed, but shared controllers cannot import component/pattern
21
+ runtime implementations. Keep business data in application adapters. Measured
22
+ style writes retain exact `boundaries.styleWrites` properties; they do not allow
23
+ shared-token overrides or an extra styling layer.
24
+ These gates inspect supported direct imports, markup and style boundaries.
25
+ External package internals and delegated DOM behavior still require source,
26
+ build and browser review; declaring a dependency does not prove it is headless.
27
+
28
+ ## Development boundary
29
+
30
+ Production includes real components, controllers, tokens and compiled styles.
31
+ Scenarios and fixtures are included only in previews. Behavior manifests and test
32
+ files are development/tooling inputs. Runtime imports and public re-exports of
33
+ those inputs are rejected, including dynamic imports and aliases.
34
+
35
+ ## Register guarantees
36
+
37
+ An optional `behavior.json` belongs to one directory unit. Flat units use
38
+ `<Owner>.behavior.json`. Existing projects without manifests remain valid and have
39
+ no implied behavioral verification. A manifest is declarative and contains no
40
+ executable command:
41
+
42
+ ```json
43
+ {
44
+ "schemaVersion": 1,
45
+ "subjects": {
46
+ "controller": { "source": "./scroller-controller.ts", "symbol": "ScrollerController" },
47
+ "scroll-api": { "api": "useMessageScroller" }
48
+ },
49
+ "guarantees": [
50
+ {
51
+ "id": "prepend-anchor",
52
+ "description": "Prepending history preserves the visible anchor after layout settles.",
53
+ "subjects": ["controller", "scroll-api"],
54
+ "checks": ["prepend-anchor"]
55
+ }
56
+ ],
57
+ "checks": {
58
+ "prepend-anchor": {
59
+ "file": "./behavior.spec.ts",
60
+ "case": "prepend-anchor",
61
+ "scenario": "components/MessageScroller",
62
+ "profile": "widget"
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Names above are illustrative project choices. A subject identifies a public export
69
+ through `api`, or an owner-local implementation `source` with an optional top-level
70
+ `symbol`. Private subjects do not become public API. A guarantee references known
71
+ subjects and checks. Each check names an owner-local test/spec file, stable case
72
+ ID, registered profile and optional discovered scenario. Several guarantees may
73
+ reference one check. Repeated execution identities use the same check ID.
74
+
75
+ Static validation establishes registration and ownership, not that the guarantee
76
+ is true. README prose is not automatically certified. Link specific guarantees
77
+ when describing the evidence available for a behavior.
78
+
79
+ ## Link a public API to a demonstration
80
+
81
+ Scenario `covers` retains its existing render-reference contract. Optional
82
+ `exercises` identifies public behavioral APIs:
83
+
84
+ ```tsx
85
+ export const scenario = {
86
+ title: "Message scrolling",
87
+ covers: ["MessageScroller", "MessageScrollerViewport"],
88
+ exercises: ["useMessageScroller", "useMessageScrollerScrollable"],
89
+ };
90
+ ```
91
+
92
+ Call hooks in a rendered local scenario adapter. Calls/construction are matched by
93
+ resolved symbol, not a `use*` naming convention. An unused import, dead adapter or
94
+ metadata string does not count. Dynamically passed behavior references produce a
95
+ review advisory when a direct call cannot be established; verify those through
96
+ registered execution checks rather than changing the API for the analyzer.
97
+
98
+ Public APIs appear in `ads_catalog` with `apiKind: "ui"` or `"behavior"` and their
99
+ scenario links. `ads_behaviors` joins contracts to demonstrations and evidence.
100
+ A demonstrated API is not automatically a verified guarantee.
101
+
102
+ ## Register execution profiles
103
+
104
+ Profiles are explicit trusted development commands in `ds.config.json`. They use
105
+ literal executable/argument arrays, no shell interpolation or model-supplied
106
+ command. For a project with an installed Playwright test runner:
107
+
108
+ ```json
109
+ {
110
+ "mcp": {
111
+ "behaviorProfiles": {
112
+ "widget": {
113
+ "runner": "playwright",
114
+ "command": "npm",
115
+ "args": ["run", "test:behavior", "--"],
116
+ "mode": "browser",
117
+ "timeoutMs": 60000
118
+ }
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ Here `test:behavior` invokes the project's local `playwright test`. Its Playwright
125
+ configuration must discover colocated spec files. Configure browser installation
126
+ and any required test setup in that project. The ADS tooling host gains no
127
+ framework or browser dependency from a profile.
128
+
129
+ The Playwright adapter appends selected file paths, its reporter and a generated
130
+ artifact directory. Use native test tags for stable case identity:
131
+
132
+ ```ts
133
+ import { test, expect } from "@playwright/test";
134
+ import { openBehaviorScenario } from "@reqvire-org/ads/verification";
135
+
136
+ test("history preserves the visible anchor", { tag: "@ads:prepend-anchor" }, async ({ page }) => {
137
+ const frame = await openBehaviorScenario(page, "prepend-anchor");
138
+ // Perform real interactions and assert the project's guarantee here.
139
+ await expect(frame.getByRole("region", { name: "Messages" })).toBeVisible();
140
+ });
141
+ ```
142
+
143
+ The shown assertion is only a harness example; it does not establish anchoring.
144
+ Write meaningful assertions for the registered guarantee. The helper waits for
145
+ the current selected scenario inside the real sandboxed MCP App. It uses `load`
146
+ and explicit readiness, not `networkidle`: MCP keeps event connections open.
147
+
148
+ `mode: "browser"` starts an isolated managed showcase on an automatic port. Test
149
+ requests contain its scenario URLs, and cleanup closes its host/compiler tree.
150
+ Mutation candidates use their own source, generated graph and server; a passing
151
+ test against an older fixed-port live showcase cannot validate a candidate.
152
+
153
+ Use `mode: "strict"` only for React with a project development harness that mounts
154
+ the relevant API in StrictMode. The production MCP App does not replay development
155
+ effects. This profile does not receive a production showcase URL from the helper.
156
+ For Preact, register its actual lifecycle tests without claiming React StrictMode.
157
+ The profile names record the chosen environment; ADS does not infer that a test's
158
+ assertions cover every lifecycle condition.
159
+
160
+ Other runners use `runner: "protocol"` (the default). ADS provides
161
+ `ADS_VERIFY_REQUEST` and `ADS_VERIFY_REPORT` absolute paths. Read the request's
162
+ `schemaVersion`, unique `runId`, `projectRoot`, `framework`, `profile`, `mode`,
163
+ selected `checks` and optional `scenarios` URLs. Write a bounded JSON report:
164
+
165
+ ```json
166
+ {
167
+ "schemaVersion": 1,
168
+ "runId": "<the request's runId>",
169
+ "cases": [
170
+ {
171
+ "id": "prepend-anchor",
172
+ "file": "design-system/components/MessageScroller/behavior.spec.ts",
173
+ "status": "passed"
174
+ }
175
+ ]
176
+ }
177
+ ```
178
+
179
+ Case statuses are `passed`, `failed` or `skipped`, with optional `message`.
180
+ `file` is project-relative. Nonzero process exit, missing cases, skipped cases,
181
+ wrong run IDs and unavailable reports do not pass. Test commands are trusted
182
+ project code; an adapter cannot prove the quality or honesty of their assertions.
183
+
184
+ ## Execute and inspect evidence
185
+
186
+ Use `ads_behaviors` for bounded discovery; request an exact unit for its contract.
187
+ General listings return counts and short API/guarantee/scenario summaries. Exact
188
+ owner requests return the registered details; source reads remain available for
189
+ bounded inspection of large contracts. No full guide or test body is injected
190
+ into discovery results.
191
+ Call `ads_verify({unit, checks?})` for selected registered checks. Read
192
+ `ads://project/behaviors` for current evidence or `ads://guides/behaviors` for this
193
+ guide. CLI/CI equivalents are:
194
+
195
+ ```sh
196
+ ads behaviors . --json
197
+ ads verify . --unit controllers/ScrollFollow
198
+ ads verify . --unit components/MessageScroller --check prepend-anchor --json
199
+ ```
200
+
201
+ Execution has bounded output, a configured timeout, cancellation and process-tree
202
+ ownership. Evidence records case/profile identity, source/dependency revision,
203
+ completion time, report path and the available preview revision. Later input
204
+ changes mark results stale; changes during execution prevent a current pass.
205
+ Guarantees report passing only when all their checks have current passing evidence.
206
+ Passing applies to those cases and profiles, not every application state.
207
+
208
+ Revisions include owned files, literal import dependencies, local CSS imports and
209
+ asset URLs, token foundation, project/runner configuration, lockfiles and tooling.
210
+ Computed module selection conservatively includes all ADS owners. Environment,
211
+ network and other unregistered external inputs remain the runner's responsibility;
212
+ an evidence record is not a proof about arbitrary external state.
213
+
214
+ Guarded mutations execute affected registered behavior contracts after core,
215
+ Oxlint and configured project checks, before writing source. Imported shared
216
+ controller changes also select dependent contracts conservatively. Rejected and
217
+ dry-run patches publish no source changes. Verification output stays under `.ds/`.
218
+
219
+ ## Packaging and performance
220
+
221
+ Controllers follow normal component/scenario imports into the project's compiled
222
+ resource graph. Their framework dependencies are bundled by the project compiler
223
+ and served through MCP resources inside the App iframe; external hosts install
224
+ nothing. The selected scenario loads its dependency closure. Shared modules are
225
+ cached within that App instance, and each separately opened App has its own runtime.
226
+
227
+ Keep ESM modules and explicit imports. Avoid an eagerly imported controller
228
+ registry. Tree shaking and shared chunks follow the project bundler: class methods,
229
+ side effects, global CSS and dynamic maps can retain extra code. Inspect emitted
230
+ resources instead of assuming a controller produces a minimal bundle.
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.3.5
6
+
7
+ - Bound preview-watch test teardown, publish selection requests atomically and
8
+ wait for the changed preview output before checking revision/queue order.
9
+ Cap the release test stage so failed cleanup cannot hold publication indefinitely.
10
+
11
+ - Stabilize the development StrictMode browser harness across dependency-optimizer
12
+ reloads. Verify the broken controller fails its cleanup assertion and the
13
+ repaired controller passes. Version 0.3.4 was not published after CI caught the
14
+ harness navigation race.
15
+
16
+ - Add shared controller units, framework/dependency and headless ownership checks,
17
+ while retaining private controllers in their consumers' units.
18
+ - Add optional owner-local behavior contracts, scenario `exercises`, public hook
19
+ discovery, `ads_behaviors`/`ads_verify`, revision-bound results and subscribed
20
+ evidence resources. Guarded edits run affected registered checks in candidates.
21
+ - Add registered protocol/Playwright execution profiles with cancellation,
22
+ process-tree cleanup, real sandboxed-App checks and development StrictMode
23
+ harness guidance. Keep behavior metadata/tests outside production imports.
24
+ - Rename the external-library adaptation workflow and MCP prompt to `ads-migrate`;
25
+ retain `ads-import` for ADS-compatible source and token transfers.
26
+ - Expose compiler progress phases and restart a timed-out builder on the next
27
+ explicit preview request without requiring an MCP reconnect.
28
+
29
+ - Close the raw `ch` sizing gap across absolute/font-relative CSS length units,
30
+ nested calculations and numeric notation. Share unit categories with dimension
31
+ token validation; keep structural angles and responsive geometry available.
32
+ Preserve the demo's text measures in owned tokens with preview coverage.
33
+
34
+ - Close the whole MCP runtime on stdio wire closure, including broken output,
35
+ and stop browser sockets, subscriptions and preview processes together.
36
+ - Add an independent preview ownership guard so abrupt MCP process or process-group
37
+ death also terminates detached builders and their descendants.
38
+
3
39
  ## 0.3.3
4
40
 
5
41
  - Reject unused inline-style permissions and imperative property permissions.