@reqvire-org/ads 0.3.12 → 0.3.14

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 (46) hide show
  1. package/README.md +20 -1
  2. package/dist/bootstrap-ds/ADS.md +4 -1
  3. package/dist/capture/worker.mjs +38 -0
  4. package/dist/capture-store.mjs +123 -0
  5. package/dist/capture.mjs +117 -0
  6. package/dist/check.mjs +8 -2
  7. package/dist/config.mjs +6 -1
  8. package/dist/mcp/context.mjs +1 -1
  9. package/dist/mcp/mutations.mjs +25 -5
  10. package/dist/mcp/server.mjs +62 -15
  11. package/dist/scenario-slots.mjs +124 -0
  12. package/dist/scenarios.mjs +9 -3
  13. package/dist/showcase/client.bundle.js +18 -18
  14. package/dist/showcase/style.css +2 -0
  15. package/dist/skills/ads-break-ui/SKILL.md +6 -1
  16. package/dist/skills/ads-break-ui/references/captures.md +147 -0
  17. package/dist/skills/ads-break-ui/references/guarded-editing.md +72 -3
  18. package/dist/skills/ads-break-ui/references/scenarios.md +40 -1
  19. package/dist/skills/ads-designer/SKILL.md +6 -1
  20. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +4 -1
  21. package/dist/skills/ads-designer/references/captures.md +147 -0
  22. package/dist/skills/ads-designer/references/guarded-editing.md +72 -3
  23. package/dist/skills/ads-designer/references/scenarios.md +40 -1
  24. package/dist/skills/ads-import/SKILL.md +6 -1
  25. package/dist/skills/ads-import/references/captures.md +147 -0
  26. package/dist/skills/ads-import/references/guarded-editing.md +72 -3
  27. package/dist/skills/ads-import/references/scenarios.md +40 -1
  28. package/dist/skills/ads-migrate/SKILL.md +6 -1
  29. package/dist/skills/ads-migrate/references/captures.md +147 -0
  30. package/dist/skills/ads-migrate/references/guarded-editing.md +72 -3
  31. package/dist/skills/ads-migrate/references/scenarios.md +40 -1
  32. package/dist/skills/ads-motion/SKILL.md +6 -1
  33. package/dist/skills/ads-motion/references/captures.md +147 -0
  34. package/dist/skills/ads-motion/references/guarded-editing.md +72 -3
  35. package/dist/skills/ads-motion/references/scenarios.md +40 -1
  36. package/dist/source-values.mjs +2 -1
  37. package/dist/verification/browser.mjs +8 -3
  38. package/dist/verification/playwright-reporter.cjs +23 -8
  39. package/dist/verification.mjs +142 -33
  40. package/docs/ADS.md +10 -2
  41. package/docs/ARCHITECTURE.md +43 -2
  42. package/docs/BEHAVIORS.md +66 -7
  43. package/docs/CHANGELOG.md +27 -0
  44. package/docs/GUIDE.md +48 -1
  45. package/docs/MCP.md +54 -1
  46. package/package.json +1 -1
@@ -19,13 +19,55 @@ const slash = file => file.replaceAll("\\", "/");
19
19
  const resultFile = root => path.join(root, ".ds/behavior-results.json");
20
20
 
21
21
  /** Follow real project imports; package/configuration changes also invalidate evidence. */
22
- function inputContext(options) { return { inventory: inspectUnits(options), scenarios: inspectScenarios(options).scenarios }; }
22
+ function inputContext(options) {
23
+ const sources = new Map(), digests = new Map(), packages = new Map(), resolve = importResolver(options), resolutions = new Map();
24
+ return { inventory: inspectUnits(options), scenarios: inspectScenarios(options).scenarios,
25
+ seeds: [...configurationFiles(options)].map(file => path.join(options.root, file)).concat(sourceFiles(path.join(options.dsRoot, options.config.tokenDir)), path.join(options.dsRoot, options.config.stylesEntry)),
26
+ digest(file) { if (!digests.has(file)) digests.set(file, hash(readFileSync(file))); return digests.get(file); },
27
+ ast(file) { if (!sources.has(file)) sources.set(file, ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true)); return sources.get(file); },
28
+ resolve(specifier, file) { const key = `${file}\0${specifier}`; if (!resolutions.has(key)) resolutions.set(key, resolve(specifier, file)); return resolutions.get(key); },
29
+ hasSideEffects(file, record) {
30
+ let directory = path.dirname(file);
31
+ while (isInside(options.root, directory)) {
32
+ const manifest = path.join(directory, "package.json");
33
+ if (existsSync(manifest)) {
34
+ record(manifest);
35
+ if (!packages.has(manifest)) { try { packages.set(manifest, JSON.parse(readFileSync(manifest, "utf8")).sideEffects); } catch { packages.set(manifest, true); } }
36
+ const policy = packages.get(manifest), relative = slash(path.relative(directory, file));
37
+ if (policy === false) return false;
38
+ // Only the simple package sideEffects glob subset is narrowed. Unknown
39
+ // syntax and undeclared policies preserve the entire dependency graph.
40
+ if (!Array.isArray(policy) || policy.some(pattern => typeof pattern !== "string" || !/^[\w./*?-]+$/.test(pattern))) return true;
41
+ return policy.some(pattern => {
42
+ pattern = pattern.replace(/^\.\//, ""); if (!pattern.includes("/")) pattern = `**/${pattern}`;
43
+ const expression = pattern.match(/\*\*\/|\*\*|\*|\?|[^*?]+/g)?.map(part => part === "**/" ? "(?:.*/)?" : part === "**" ? ".*" : part === "*" ? "[^/]*" : part === "?" ? "[^/]" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
44
+ return new RegExp(`^${expression}$`).test(relative);
45
+ });
46
+ }
47
+ directory = path.dirname(directory);
48
+ }
49
+ return true;
50
+ },
51
+ };
52
+ }
23
53
 
24
54
  export function behaviorInputs(options, contract, check, context = inputContext(options)) {
25
- const files = new Set(), resolve = importResolver(options);
26
- function visit(file) {
55
+ const files = new Set(), demands = new Map(), effectsVisited = new Set();
56
+ const resolve = context.resolve;
57
+ function effects(file) {
58
+ if (effectsVisited.has(file) || !isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
59
+ effectsVisited.add(file);
60
+ if (context.hasSideEffects(file, visit)) { visit(file); return; }
61
+ if (!/\.[cm]?[jt]sx?$/.test(file) || !lstatSync(file).isFile()) return;
62
+ if (lstatSync(file).isSymbolicLink() || realpathSync(file) !== file) throw new Error(`Behavior inputs cannot resolve through symlinks: ${file}`);
63
+ for (const statement of context.ast(file).statements) if ((ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier)) for (const target of resolve(statement.moduleSpecifier.text, file)) effects(target);
64
+ }
65
+ function visit(file, names = null) {
27
66
  file = path.resolve(file);
28
- if (files.has(file) || !isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
67
+ if (!isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
68
+ const prior = demands.get(file);
69
+ if (demands.has(file) && (prior === null || names && names.every(name => prior.has(name)))) return;
70
+ demands.set(file, names === null ? null : new Set([...(prior ?? []), ...names]));
29
71
  if (lstatSync(file).isSymbolicLink() || realpathSync(file) !== file) throw new Error(`Behavior inputs cannot resolve through symlinks: ${file}`);
30
72
  if (!lstatSync(file).isFile()) return;
31
73
  files.add(file);
@@ -39,12 +81,31 @@ export function behaviorInputs(options, contract, check, context = inputContext(
39
81
  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
82
  }
41
83
  if (!/\.[cm]?[jt]sx?$/.test(file)) return;
42
- const ast = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
84
+ const ast = context.ast(file);
85
+ const barrel = names && ast.statements.every(statement => ts.isEmptyStatement(statement) || ts.isImportDeclaration(statement) && !statement.importClause || ts.isExportDeclaration(statement) && (!statement.exportClause || ts.isNamedExports(statement.exportClause)) && (!statement.exportClause || !statement.exportClause.elements.length || statement.moduleSpecifier));
86
+ if (barrel) {
87
+ for (const statement of ast.statements) {
88
+ if (!statement.moduleSpecifier || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
89
+ for (const target of resolve(statement.moduleSpecifier.text, file)) {
90
+ if (ts.isImportDeclaration(statement) || !statement.exportClause) visit(target);
91
+ else {
92
+ const imported = statement.exportClause.elements.filter(entry => names.includes(entry.name.text)).map(entry => (entry.propertyName ?? entry.name).text);
93
+ if (imported.length) visit(target, imported);
94
+ effects(target);
95
+ }
96
+ }
97
+ }
98
+ return;
99
+ }
43
100
  function walk(node) {
44
101
  let specifier;
45
102
  if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
46
103
  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);
104
+ if (specifier) {
105
+ const clause = ts.isImportDeclaration(node) && node.importClause;
106
+ const names = clause && !clause.namedBindings ? clause.name ? ["default"] : null : clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? [...(clause.name ? ["default"] : []), ...clause.namedBindings.elements.map(entry => (entry.propertyName ?? entry.name).text)] : null;
107
+ for (const target of resolve(specifier, file)) visit(target, names);
108
+ }
48
109
  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
110
  if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0]))) {
50
111
  // Computed module selection cannot provide a precise dependency closure.
@@ -56,13 +117,11 @@ export function behaviorInputs(options, contract, check, context = inputContext(
56
117
  walk(ast);
57
118
  }
58
119
  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));
120
+ for (const file of context.seeds) visit(file);
62
121
  const scenario = check.scenario && context.scenarios.find(item => item.id === check.scenario);
63
122
  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]);
123
+ const rows = [...files].sort().map(file => [slash(path.relative(options.root, file)), context.digest(file)]);
124
+ 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)) ? context.digest(path.join(options.root, file)) : null]);
66
125
  rows.push(["profile", JSON.stringify(options.config.mcp.behaviorProfiles[check.profile])], ["node", process.version]);
67
126
  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
127
  return { revision: hash(JSON.stringify(rows)), files: rows.map(([file]) => file) };
@@ -104,9 +163,11 @@ export function affectedBehaviorUnits(options, changedFiles) {
104
163
  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
164
  }
106
165
 
107
- function execute(profile, args, root, env, signal) {
166
+ export function executeDevelopmentCommand(profile, args, root, env, signal) {
108
167
  return new Promise((resolve, reject) => {
109
- if (signal.aborted) { reject(new Error("Behavior verification cancelled.")); return; }
168
+ const label = profile.label ?? "Behavior verification";
169
+ if (signal.aborted) { reject(new Error(`${label} cancelled.`)); return; }
170
+ const startedAt = Date.now(), timeoutMs = profile.timeoutMs ?? 60000;
110
171
  let output = "", failure, child, guard, done = false;
111
172
  const kill = () => {
112
173
  if (!child?.pid) return;
@@ -121,28 +182,47 @@ function execute(profile, args, root, env, signal) {
121
182
  guard.once("error", error => { failure = error; kill(); });
122
183
  guard.once("exit", () => { if (!done) { failure ??= new Error("Verification ownership guard exited unexpectedly."); kill(); } });
123
184
  }
124
- const abort = () => { failure = new Error("Behavior verification cancelled."); kill(); };
185
+ const abort = () => { failure = Object.assign(new Error(`${label} cancelled.`), { reason: "cancelled" }); kill(); };
125
186
  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);
187
+ const timeout = setTimeout(() => { failure = Object.assign(new Error(`${label} timed out after ${timeoutMs} ms for the complete profile run, not each check.`), { reason: "timeout", timeoutMs }); kill(); }, timeoutMs);
127
188
  for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { output = (output + chunk.toString()).slice(-12000); });
128
189
  child.once("error", error => { failure = error; });
129
190
  child.once("exit", kill);
130
191
  child.once("close", code => {
131
192
  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 });
193
+ const elapsedMs = Date.now() - startedAt;
194
+ if (failure) reject(Object.assign(failure, { output, elapsedMs }));
195
+ else resolve({ code, output, elapsedMs });
134
196
  });
135
197
  });
136
198
  }
137
199
 
200
+ function readReport(file, runId) {
201
+ if (!existsSync(file) || realpathSync(file) !== file || !lstatSync(file).isFile() || lstatSync(file).size > 1024 * 1024) throw new Error("Runner produced no valid bounded verification report. A successful exit alone is not passing evidence.");
202
+ const value = JSON.parse(readFileSync(file, "utf8"));
203
+ if (value.schemaVersion !== 1 || value.runId !== runId || !Array.isArray(value.cases) || value.cases.length > 500 || value.active !== undefined && (!Array.isArray(value.active) || value.active.length > 100)) throw new Error("Runner report has the wrong run ID/schema or too many cases.");
204
+ return value;
205
+ }
206
+
207
+ function runProgress(group, report, startedAt, profile, timeoutMs) {
208
+ const selected = group.map(check => ({ unit: check.unit, check: check.id, id: check.case, file: slash(path.posix.join(path.posix.dirname(slash(check.contract.file)), check.file)) }));
209
+ const cases = selected.map(check => {
210
+ const matches = report?.cases.filter(item => item?.id === check.id && item.file === check.file) ?? [];
211
+ const running = report?.active?.find(item => item?.id === check.id && item.file === check.file);
212
+ return { unit: check.unit, check: check.check, state: running ? "running" : matches.length ? "completed" : Array.isArray(report?.active) ? "not_started" : "not_reported", ...(running ? { elapsedMs: Math.max(0, Date.now() - Date.parse(running.startedAt)) || 0, workerIndex: running.workerIndex } : {}), ...(matches.length ? { reportedStatus: matches.at(-1).status, durationMs: matches.reduce((sum, item) => sum + (Number.isFinite(item.durationMs) ? Math.max(0, item.durationMs) : 0), 0) } : {}) };
213
+ });
214
+ return { profile, elapsedMs: Date.now() - startedAt, timeoutMs, total: cases.length, completed: cases.filter(item => item.state === "completed").length, active: cases.filter(item => item.state === "running"), pending: cases.filter(item => ["not_started", "not_reported"].includes(item.state)).length, cases, ...(Number.isSafeInteger(report?.workers) ? { workers: report.workers } : {}) };
215
+ }
216
+
138
217
  /** Registered development commands are trusted project code, never model-supplied commands. */
139
- export function createVerificationController(root) {
218
+ export function createVerificationController(root, { reportRoot = root, onProgress } = {}) {
140
219
  root = path.resolve(root);
141
- let active;
220
+ reportRoot = path.resolve(reportRoot);
221
+ let active, progress;
142
222
  return {
143
- status: () => ({ running: !!active }),
223
+ status: () => ({ running: !!active, ...(active && progress ? { progress: { ...progress, elapsedMs: Date.now() - progress.startedAt } } : {}) }),
144
224
  async close() { active?.abort(); await active?.completion; },
145
- async run({ unit, units, checks, persist = true } = {}, signal) {
225
+ async run({ unit, units, checks, persist = true } = {}, signal, notifyProgress) {
146
226
  if (active) return { ok: false, diagnostics: [{ rule: "behavior-busy", message: "A behavior verification is already running." }], results: [] };
147
227
  active = new AbortController();
148
228
  const controller = active;
@@ -162,7 +242,7 @@ export function createVerificationController(root) {
162
242
  for (const profileName of new Set(selection.map(check => check.profile))) {
163
243
  const profile = options.config.mcp.behaviorProfiles[profileName];
164
244
  const group = selection.filter(check => check.profile === profileName);
165
- const directory = path.join(root, ".ds/verification", randomUUID());
245
+ const directory = path.join(reportRoot, ".ds/verification", randomUUID());
166
246
  mkdirSync(directory, { recursive: true });
167
247
  if (realpathSync(directory) !== directory) throw new Error("Verification output cannot resolve through a symlink.");
168
248
  const runId = randomUUID(), report = path.join(directory, "report.json"), request = path.join(directory, "request.json");
@@ -179,31 +259,60 @@ export function createVerificationController(root) {
179
259
  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 };
180
260
  writeFileSync(request, JSON.stringify(requestContext));
181
261
  const args = [...profile.args];
182
- 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")}`);
262
+ if (profile.runner === "playwright") {
263
+ const tags = [...new Set(group.map(check => check.case))].map(id => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
264
+ args.push(...new Set(group.map(check => slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file))))), `--grep=(?:^|\\s)@ads:(?:${tags.join("|")})(?=\\s|$)`, `--reporter=${fileURLToPath(new URL("./verification/playwright-reporter.cjs", import.meta.url))}`, `--output=${path.join(directory, "artifacts")}`);
265
+ }
183
266
  let run, reportValue, runnerError;
267
+ const startedAt = Date.now();
268
+ const update = value => {
269
+ progress = { ...runProgress(group, value, startedAt, profileName, profile.timeoutMs ?? 60000), startedAt };
270
+ onProgress?.(progress); notifyProgress?.(progress);
271
+ };
272
+ update();
273
+ let lastSnapshot = "", lastUpdate = Date.now();
274
+ const monitor = setInterval(() => {
275
+ let value;
276
+ try { value = readReport(report, runId); } catch { /* An absent/invalid intermediate snapshot cannot provide evidence. */ }
277
+ const snapshot = JSON.stringify(value);
278
+ if (snapshot !== lastSnapshot || Date.now() - lastUpdate >= 5000) { lastSnapshot = snapshot; lastUpdate = Date.now(); update(value); }
279
+ }, 250);
184
280
  try {
185
- run = await execute(profile, args, root, { ADS_VERIFY_REQUEST: request, ADS_VERIFY_REPORT: report, ADS_VERIFY_MODE: requestContext.mode }, signal);
186
- 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.");
187
- reportValue = JSON.parse(readFileSync(report, "utf8"));
188
- 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.");
189
- } catch (error) { runnerError = error; reportValue = undefined; diagnostics.push({ rule: "behavior-runner", message: error.message, output: error.output?.slice(-4000), profile: profileName }); }
281
+ run = await executeDevelopmentCommand(profile, args, root, { ADS_VERIFY_REQUEST: request, ADS_VERIFY_REPORT: report, ADS_VERIFY_MODE: requestContext.mode }, signal);
282
+ } catch (error) { runnerError = error; }
283
+ finally { clearInterval(monitor); }
284
+ try { reportValue = readReport(report, runId); }
285
+ catch (error) { runnerError ??= error; }
286
+ update(reportValue);
287
+ if (runnerError) {
288
+ const detail = `${progress.completed}/${progress.total} checks completed; ${progress.pending} without a completed/active result. Active: ${progress.active.map(item => `${item.unit}#${item.check} (${Math.round(item.elapsedMs)} ms)`).join(", ") || "none reported"}.`;
289
+ diagnostics.push({ rule: "behavior-runner", message: `${runnerError.message} ${detail}`, reason: runnerError.reason, elapsedMs: runnerError.elapsedMs ?? Date.now() - startedAt, progress, output: runnerError.output?.slice(-4000), profile: profileName });
290
+ }
190
291
  const suiteErrors = Array.isArray(reportValue?.errors) ? reportValue.errors.slice(0, 5).map(error => String(error).slice(0, 1000)) : [];
191
- const runnerFailure = run && (run.code !== 0 || suiteErrors.length) ? `Registered runner ${profileName} ${run.code !== 0 ? `exited with code ${run.code ?? "unknown"}` : "reported a suite error"}. ${suiteErrors.join("; ") || reportValue?.cases.filter(item => item?.status === "failed").slice(0, 3).map(item => `${item.id}: ${item.message || "failed without a case diagnostic"}`).join("; ") || "Inspect the runner output and verification report."}`.slice(0, 3000) : undefined;
292
+ const incompleteReport = reportValue?.complete === false || progress.pending > 0 || progress.active.length > 0;
293
+ // Exit 1 is the normal test-failure exit. A complete report with actual
294
+ // assertion failures still contains independent, valid passing outcomes.
295
+ // Unexplained exits, suite errors and interrupted reports grant no pass.
296
+ const caseFailureExit = run?.code === 1 && !runnerError && !suiteErrors.length && !incompleteReport && reportValue?.cases.some(item => item?.status === "failed");
297
+ const runnerFailure = run && (run.code !== 0 || suiteErrors.length || incompleteReport) ? `Registered runner ${profileName} ${run.code !== 0 ? `exited with code ${run.code ?? "unknown"}` : incompleteReport ? "left an incomplete report" : "reported a suite error"}. ${suiteErrors.join("; ") || reportValue?.cases.filter(item => item?.status === "failed").slice(0, 3).map(item => `${item.id}: ${item.message || "failed without a case diagnostic"}`).join("; ") || "Inspect the runner output and verification report."}`.slice(0, 3000) : undefined;
192
298
  if (runnerFailure) diagnostics.push({ rule: "behavior-runner", message: runnerFailure, output: run.output?.slice(-4000), profile: profileName });
193
299
  const afterOptions = loadConfig(root), afterContext = inputContext(afterOptions);
194
300
  for (const check of group) {
195
301
  const file = slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file)));
196
302
  const matches = reportValue?.cases.filter(item => item && item.id === check.case && item.file === file) ?? [];
197
303
  const reportedStatus = matches.length && matches.every(item => item.status === "passed") ? "passed" : matches.length && matches.every(item => item.status === "skipped") ? "skipped" : "failed";
198
- let status = !run || runnerFailure ? "failed" : reportedStatus;
304
+ const blockedRun = !!runnerError || !!runnerFailure && !caseFailureExit;
305
+ let status = matches.some(item => item.status === "failed") ? "failed" : !matches.length || blockedRun ? reportValue ? "incomplete" : "failed" : reportedStatus;
199
306
  if (runnerError?.code === "ENOENT") status = "unavailable";
200
307
  const current = behaviorInputs(afterOptions, check.contract, check, afterContext).revision === check.inputRevision;
201
308
  if (!current) status = "stale";
202
309
  if (signal.aborted) status = "cancelled";
203
310
  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(),
204
311
  previewRevision: profile.mode === "browser" ? host?.previewRevision() ?? readPreviewManifest(options)?.revision ?? null : null,
205
- ...(runnerFailure ? { reportedStatus, runnerExitCode: run.code } : {}),
206
- message: runnerError?.message ?? (matches.filter(item => item.status !== "passed").map(item => String(item.message || `Case reported ${item.status} without a diagnostic.`).slice(0, 2000)).join("\n") || (!matches.length ? "The selected case did not execute/report. Missing and skipped cases do not pass." : runnerFailure ? `This case reported ${reportedStatus}, but the complete run did not pass. ${runnerFailure}` : "")), report: slash(path.relative(root, report)) });
312
+ ...(matches.length ? { reportedStatus, durationMs: matches.reduce((sum, item) => sum + (Number.isFinite(item.durationMs) ? Math.max(0, item.durationMs) : 0), 0) } : {}),
313
+ ...(runnerFailure ? { runnerExitCode: run.code } : {}),
314
+ execution: progress.cases.find(item => item.unit === check.unit && item.check === check.id)?.state,
315
+ message: [matches.filter(item => item.status !== "passed").map(item => String(item.message || `Case reported ${item.status} without a diagnostic.`).slice(0, 2000)).join("\n"), runnerError ? `${runnerError.message} This check ${matches.length ? `reported ${reportedStatus}` : `was ${progress.cases.find(item => item.unit === check.unit && item.check === check.id)?.state ?? "not reported"}`}; the complete run did not pass.` : !matches.length ? "The selected case did not execute/report. Missing and skipped cases do not pass." : runnerFailure ? `This case reported ${reportedStatus}, but the complete run did not pass. ${runnerFailure}` : ""].filter(Boolean).join("\n"), report: slash(path.relative(reportRoot, report)) });
207
316
  }
208
317
  if (signal.aborted) break;
209
318
  }
@@ -217,7 +326,7 @@ export function createVerificationController(root) {
217
326
  }
218
327
  return { ok: diagnostics.length === 0 && results.length > 0 && results.every(result => result.status === "passed" && result.current), diagnostics, results };
219
328
  } catch (error) { return { ok: false, diagnostics: [{ rule: "behavior-verification", message: error.message }], results }; }
220
- finally { try { await host?.close(); } finally { if (active === controller) active = undefined; finish(); } }
329
+ finally { try { await host?.close(); } finally { if (active === controller) { active = undefined; progress = undefined; } finish(); } }
221
330
  },
222
331
  };
223
332
  }
package/docs/ADS.md CHANGED
@@ -107,6 +107,11 @@ framework/dependency gates and MCP execution contract.
107
107
  Flat owners may use `Button.scenario.tsx` and `Button.fixture.ts` instead.
108
108
  `Scenario.tsx` exports a default independently mountable view and literal metadata
109
109
  `export const scenario = { title: "Button states", covers: ["Button"] };`.
110
+ Optional static `captureTargets` maps up to 20 names to CSS selectors scoped to
111
+ that scenario's App. These support explicitly requested element screenshots; they
112
+ do not grant coverage or styling exceptions. PNGs and current/candidate metadata
113
+ are generated development evidence, outside source ownership and product bundles.
114
+ See the portable [capture workflow](../skills/ads-designer/references/captures.md).
110
115
  Token entries under the configured token directory declare
111
116
  `export const scenario = { title: fixture.title, covers: ["TokenSamples"], samples: fixture.samples };`.
112
117
  `TokenSamples` is any project-owned public review pattern, not a required name.
@@ -159,7 +164,10 @@ or applies the right CSS property. Browser review still verifies the samples.
159
164
 
160
165
  Scenarios and fixture helpers are development-only wherever colocated. Runtime
161
166
  source and the public barrel cannot import or re-export them. Showcase/scenario
162
- code defines no CSS, custom classes or native controls; use public ADS APIs.
167
+ code defines no CSS, custom classes or standalone native controls; use public ADS
168
+ APIs. A single direct native child can supply semantics to a public runtime slot
169
+ when source tracing proves that owner clones the exact child with its own props.
170
+ An `asChild` flag alone is not evidence; scenario styling remains forbidden.
163
171
  Scenarios may consume public class helpers whose returned styles trace to owned
164
172
  Linaria. Stable hooks in that owner may accompany real styles; strings forwarded
165
173
  from scenarios, hook-only returns and untraceable outputs remain invalid.
@@ -378,7 +386,7 @@ The checks enforce the architecture's ownership boundaries:
378
386
  | Public API and dependencies | Consumers use public ADS imports; ADS implementations and showcase do not import application source; runtime code does not import showcase fixtures |
379
387
  | Interaction policy | Primitive state variables stay inside components; non-state customization hooks require explicit registration |
380
388
  | Renderer exceptions | Inline styles and imperative writes require used, narrow configured boundaries; stale entries fail; public props omit inherited style and reject open-ended visual strings |
381
- | Preview ownership | Scenarios define no CSS, custom classes, or native controls; all declared tokens and public renderable UI have reachable registered scenarios; token references must exist; legacy catalogue pages are checked when present |
389
+ | Preview ownership | Scenarios define no CSS, custom classes, or standalone native controls; native slot children require public runtime cloning evidence; all declared tokens and public renderable UI have reachable registered scenarios; token references must exist; legacy catalogue pages are checked when present |
382
390
  | Assets | Source/public collisions and configured generated artifacts in source are rejected |
383
391
 
384
392
  Pointer capture without locally visible capture-loss cleanup produces a
@@ -303,6 +303,10 @@ Opaque conditional branches do not grant speculative coverage. Scenarios may
303
303
  consume deliberate public class helpers when their returned styles retain
304
304
  runtime-owned Linaria provenance; stable owner-authored hooks may accompany real
305
305
  styles. This preserves ownership rather than granting scenario styling freedom.
306
+ A native element may provide semantics as the single direct child of a public
307
+ runtime slot when source tracing proves it is cloned with that owner's props.
308
+ This preserves polymorphic composition without allowing standalone scenario
309
+ controls, scenario styling or name-based exceptions.
306
310
  The skill-local [scenario guide](../skills/ads-designer/references/scenarios.md)
307
311
  explains evidence, limits and diagnostics.
308
312
 
@@ -336,6 +340,39 @@ The MCP server serves compiled bytes without evaluating project TSX. Compiler
336
340
  adapters normalize each entry to mount/unmount, retaining native React or Preact
337
341
  semantics; choosing a framework does not install one into the tooling host.
338
342
 
343
+ ## On-demand visual evidence
344
+
345
+ The capture unit is a registered, independently mountable scenario or one of its
346
+ project-declared `captureTargets`. Components, patterns, compositions and token
347
+ previews use the same real MCP App, fixtures and adapter as interactive review.
348
+ Shared controllers/hooks need a visible demonstration. Capture introduces no
349
+ tool-owned component templates, styling or fixture inference.
350
+
351
+ ADS owns isolated preview hosting, App readiness and an owned Playwright process;
352
+ the receiving project supplies Playwright and its installed browser. The capture
353
+ host is the existing minimal MCP client/bridge with navigation hidden, giving the
354
+ sandboxed App the requested viewport. Project runtime, fonts and styles remain
355
+ inside the App resource graph. The image excludes showcase chrome.
356
+
357
+ Captures exist only when requested. `ads_capture` replaces current and removes any
358
+ candidate for that scenario/target. Guarded patch/transaction dry runs can opt into
359
+ `screenshot:true` with explicit selected captures: after the normal gates, ADS
360
+ renders current and candidate under matching settings without changing live source.
361
+ A new scenario has no fabricated baseline. Final freshness/conflict checks precede
362
+ image publication. Matching accepted source promotes candidate to current.
363
+
364
+ There are at most two slots per target: current and candidate. Revisions/settings
365
+ are metadata, not new resource identities. There is no previous slot, accumulating
366
+ history or TTL. Stale source is identified explicitly; deleted targets are collected.
367
+ PNG/pixel/store limits and explicit clearing bound generated storage. These images
368
+ are visual evidence for one state, not passing behavioral guarantees.
369
+
370
+ One MCP capture index and a PNG resource template expose stable image slots.
371
+ Subscriptions receive resource updates when images change or disappear. The pair
372
+ is published as one atomic generated record under `.ds/captures/` and stays outside
373
+ source ownership and product bundles. Detailed usage lives in each portable skill's
374
+ [capture reference](../skills/ads-designer/references/captures.md).
375
+
339
376
  ## Build checks keep the architecture in place
340
377
 
341
378
  The project build runs ADS checks before producing application or showcase
@@ -369,8 +406,12 @@ context, or the shared token foundation. ADS resolves file ownership, reconstruc
369
406
  candidate files locally and validates a temporary project copy with architecture
370
407
  checks, registered lint rules and project validation scripts. Affected registered
371
408
  behavior checks, including consumers of a changed shared controller, also run in
372
- the isolated candidate project. Passing checks describe
373
- their registered assertions, not every natural-language claim. This keeps edits
409
+ the isolated candidate project. The project owns its test code and runner policy;
410
+ ADS owns affected-check selection, candidate hosting, progress, evidence freshness
411
+ and process cleanup. One profile timeout bounds its entire runner process. Parallel
412
+ workers can share that candidate host with independent App state. Partial outcomes
413
+ remain diagnostic when execution is interrupted; they cannot approve a mutation.
414
+ Passing checks describe their registered assertions, not every natural-language claim. This keeps edits
374
415
  focused without requiring entire file bodies in model context. Peer units and
375
416
  unrelated owners remain outside the selected unit's edit boundary. Shared foundation,
376
417
  branding and guides have their own units. Shared credits and text license notices
package/docs/BEHAVIORS.md CHANGED
@@ -139,17 +139,57 @@ configuration must discover colocated spec files. Configure browser installation
139
139
  and any required test setup in that project. The ADS tooling host gains no
140
140
  framework or browser dependency from a profile.
141
141
 
142
- The Playwright adapter appends selected file paths, its reporter and a generated
143
- artifact directory.
144
-
145
- Runner failures remain blocking even when some selected cases reported passed.
146
- Those results retain `reportedStatus` and `runnerExitCode`, and explain the suite
147
- failure rather than returning an empty message. Suite-level browser errors and
142
+ The Playwright adapter appends selected file paths and case-tag filtering, its
143
+ reporter and a generated artifact directory. Unselected cases in the same spec
144
+ do not run. It preserves the project's worker configuration.
145
+
146
+ `timeoutMs` bounds the complete runner process for one profile, including runner
147
+ startup, hooks, retries and all selected cases. It is not a per-test timeout.
148
+ Playwright's own test/hook timeouts remain separate project settings. Set the
149
+ profile budget from measured suite durations with headroom; increasing it does
150
+ not repair a stalled assertion or cleanup.
151
+
152
+ Independent specs can use multiple Playwright workers against the same managed
153
+ candidate host. Each check needs its own page/context and state; avoid fixed ports,
154
+ shared mutable fixtures and test-order dependencies. Start with a small worker
155
+ count and measure on the target machine. Files normally run in parallel; tests
156
+ within one file normally remain sequential. ADS does not force parallelism or
157
+ change verification policy. See [Playwright parallelism](https://playwright.dev/docs/test-parallel).
158
+ Prefer readiness/assertion polling over arbitrary sleeps. Timer-only guarantees
159
+ may use [Playwright's clock](https://playwright.dev/docs/clock); retain real time
160
+ when browser motion, pointer gestures or layout timing are what the check verifies.
161
+
162
+ An ordinary test-failure exit (code 1) with a complete report and actual failing
163
+ cases preserves independent case outcomes: passing checks stay `passed`, failed
164
+ checks stay `failed`, and overall verification still blocks the mutation. The
165
+ report must account for every selected check, with no active cases or suite errors.
166
+ The same rule supports older custom reports without an explicit `complete` field
167
+ after normal process completion. An unexplained nonzero exit cannot certify a
168
+ pass, even if all reported cases claim success. Results retain `reportedStatus`
169
+ and `runnerExitCode` and explain the overall failure. Suite-level browser errors and
148
170
  bounded runner output appear in diagnostics. `openBehaviorScenario` includes the
149
171
  scenario ID and failed browser requests in startup failures; App resource errors
150
172
  identify the resource URI. Failed assertions and startup errors are not retried
151
173
  automatically. Fix or investigate the failure before explicitly verifying again.
152
174
 
175
+ The reporter atomically publishes partial snapshots at test start/end. While a
176
+ run is active, `ads_status.behaviors.progress` exposes the profile, runner budget,
177
+ elapsed time, completed/active/pending counts, worker count when available and
178
+ case identities/timings. Candidate mutation verification exposes the same data at
179
+ `ads_status.mutations.progress`. Clients requesting MCP progress receive updates
180
+ and a heartbeat during the runner phase; client-side request timeouts still apply.
181
+ `durationMs` records completed case execution, including attempts, separately from
182
+ the suite's wall time. Parallel case durations can sum to more than wall time.
183
+
184
+ After a deadline, valid completed outcomes remain visible through `reportedStatus`
185
+ and durations, alongside active and unstarted case identities. A passed case from
186
+ an interrupted run has blocking status `incomplete`; real assertion failures retain
187
+ their own messages. No incomplete run can accept a mutation or certify a guarantee.
188
+ Reports and artifacts stay under the live project's `.ds/verification/` even after
189
+ the candidate copy is removed. This is diagnostic output, not passing live-source
190
+ evidence. Old/custom runners without incremental progress use `not_reported` for
191
+ cases whose execution state cannot be established.
192
+
153
193
  Use native test tags for stable case identity:
154
194
 
155
195
  ```ts
@@ -210,7 +250,13 @@ selected `checks` and optional `scenarios` URLs. Write a bounded JSON report:
210
250
  }
211
251
  ```
212
252
 
213
- Case statuses are `passed`, `failed` or `skipped`, with optional `message`.
253
+ Case statuses are `passed`, `failed` or `skipped`, with optional `message` and
254
+ `durationMs`. Protocol runners may atomically publish intermediate reports with
255
+ `complete:false` and an `active` list of `{id,file,startedAt,workerIndex?}`, then
256
+ publish `complete:true` at completion. Older final reports without `complete`
257
+ remain supported after normal process completion. An explicitly incomplete report
258
+ cannot pass even with exit code zero. Partial reports retain the same schema/run
259
+ identity and size limits as final reports.
214
260
  `file` is project-relative. Nonzero process exit, missing cases, skipped cases,
215
261
  wrong run IDs and unavailable reports do not pass. Test commands are trusted
216
262
  project code; an adapter cannot prove the quality or honesty of their assertions.
@@ -245,10 +291,23 @@ Computed module selection conservatively includes all ADS owners. Environment,
245
291
  network and other unregistered external inputs remain the runner's responsibility;
246
292
  an evidence record is not a proof about arbitrary external state.
247
293
 
294
+ For named imports through simple re-export barrels, ADS follows selected bindings
295
+ and retains dependencies with declared side effects. This narrowing requires a
296
+ package `sideEffects:false` or a supported simple `sideEffects` glob array; preserve
297
+ CSS/global effects in that policy. Unknown policies/patterns, wildcard exports and
298
+ computed imports remain conservative. Barrel source changes still invalidate
299
+ dependents. ADS caches source parsing, resolution and digests within an inspection
300
+ only; later runs read current source again.
301
+
248
302
  Guarded mutations execute affected registered behavior contracts after core,
249
303
  Oxlint and configured project checks, before writing source. Imported shared
250
304
  controller changes also select dependent contracts conservatively. Rejected and
251
305
  dry-run patches publish no source changes. Verification output stays under `.ds/`.
306
+ To check a prepared draft, request its diff and pass the returned `patchFile` to
307
+ `ads_apply_patch` with `dryRun:true`. This checks the isolated candidate, including
308
+ affected behavior contracts and its configured browser preview. Candidate evidence
309
+ is not persisted as passing evidence for live source. `ads_verify` selects checks
310
+ for current source and has no draft argument.
252
311
 
253
312
  ## Packaging and performance
254
313
 
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.14
4
+
5
+ - Preserve independent passing behavior outcomes in complete runs with failed
6
+ assertions while keeping overall verification and mutations blocked. Retain
7
+ incomplete status for missing results, suite errors and unexplained exits.
8
+ - Recognize native scenario children through source-verified public runtime slot
9
+ composition, including React/Preact cloning and local prop mergers. Keep
10
+ standalone controls, custom scenario styling and unproven slot paths rejected;
11
+ update diagnostics, architecture and portable skill guidance generically.
12
+
13
+ ## 0.3.13
14
+
15
+ - Add on-demand scenario/named-element PNG captures and screenshot-enabled guarded
16
+ dry runs. Expose stable current/candidate resources, freshness and notifications;
17
+ standalone captures clear earlier candidates, matching applies promote them, and
18
+ bounded storage keeps no previous/history/TTL. Document architecture and portable
19
+ skill usage without introducing project-specific renderers or fixtures.
20
+ - Report behavior progress, worker activity and per-case durations, preserving
21
+ partial outcomes and candidate artifacts on timeout while blocking incomplete
22
+ runs. Send requested MCP progress updates and expose candidate progress in status.
23
+ - Filter selected Playwright case tags, preserve project worker configuration and
24
+ verify concurrent Apps against one candidate host. Narrow named barrel dependencies
25
+ under declared side-effect policies and reuse parsing/digests within an inspection.
26
+ - Explain draft behavior verification through diff preparation and candidate
27
+ dry runs in all five portable skills. Distinguish candidate checks from
28
+ selected live-source verification through `ads_verify`.
29
+
3
30
  ## 0.3.12
4
31
 
5
32
  - Let the documentation unit create shared credits, conventional root license
package/docs/GUIDE.md CHANGED
@@ -5,7 +5,54 @@ contains a Node CLI, shared token contract, static checks, an Oxlint plugin, and
5
5
  empty foundation scaffolding. It includes no component or pattern catalogue,
6
6
  fonts, mock screens, or UI runtime dependency.
7
7
 
8
-
8
+ ## Use ADS through MCP
9
+
10
+ Connect your coding agent using the [MCP client setup](MCP.md#connect-codex-or-claude).
11
+ The client launches `ads mcp` in your project; add `--mutations` to enable guarded
12
+ source editing. Initialization also supplies `ads-mcp` and `ads-read-mcp` launch
13
+ scripts. The server starts its showcase and configured preview compiler, so use
14
+ the returned showcase URL rather than starting a separate preview application.
15
+
16
+ An agent uses the MCP tools to inspect, edit and review the project:
17
+
18
+ 1. Call `ads_status` to identify the connected project, available commands,
19
+ mutation mode, showcase URL and build freshness.
20
+ 2. Find public UI/behavior APIs with `ads_catalog`, inspect relevant definitions
21
+ with `ads_tokens`, and discover preview IDs with `ads_scenarios`. Read one
22
+ relevant workflow through `ads_skills` and `ads_read_skill` before editing.
23
+ 3. Open a discovered preview with `ads_show_scenario` or `ads_show_tokens`. These
24
+ render the project's actual scenario, fixtures and adapter through MCP Apps.
25
+ Use the returned showcase link when the client cannot render Apps.
26
+ 4. When editing is enabled, discover ownership with `ads_units`, read relevant
27
+ source through `ads_read_file`, and submit an owner Git patch through
28
+ `ads_apply_patch`. Use `ads_apply_transaction` for genuinely coupled owners.
29
+ Prepare/diff a local draft with `ads_prepare_patch` when useful. Dry-run first;
30
+ ADS checks an isolated candidate before any live source is written.
31
+ 5. Review diagnostics, registered behavior evidence and the rendered result.
32
+ `ads_behaviors` discovers guarantees; `ads_verify` executes selected registered
33
+ checks. Recheck `ads_status.build` after changes; notifications alone do not
34
+ establish current output.
35
+
36
+ For an on-demand screenshot, call `ads_capture` with a discovered scenario ID and
37
+ optionally a named `captureTargets` element, theme and viewport. To compare a
38
+ proposed change before applying it, add `screenshot:true` and an explicit
39
+ `captures:[{scenario:"<discovered ID>"}]` selection to the patch/transaction dry run.
40
+ Normal patches and dry runs create no screenshots. Capture uses project-installed
41
+ Playwright and its browser; ADS installs neither.
42
+
43
+ Read `ads://project/captures` and the returned PNG image URIs through MCP
44
+ `resources/read`. Each target keeps only current and candidate: a successful
45
+ standalone screenshot replaces current and clears candidate; a screenshot-enabled
46
+ dry run replaces the pair; applying matching source promotes candidate. Source
47
+ changes mark images stale. There is no previous slot, history or TTL.
48
+ `ads_capture_clear` discards candidate or clears both images. Subscribe to resource
49
+ updates when supported, and explicitly reread otherwise. Screenshots provide visual
50
+ evidence for one state; they do not replace behavior checks.
51
+
52
+ The [MCP guide](MCP.md) details tool/resource schemas, prompts, skills and
53
+ notifications. The portable [capture guide](../skills/ads-designer/references/captures.md)
54
+ explains screenshot setup, named targets, limits and comparison usage; MCP serves
55
+ the same guide at `ads://guides/captures`.
9
56
 
10
57
  ## Implementation support
11
58