@reqvire-org/ads 0.3.2 → 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.
- package/README.md +12 -4
- package/dist/behaviors.mjs +157 -0
- package/dist/bootstrap-ds/ADS.md +7 -0
- package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
- package/dist/boundaries.mjs +21 -23
- package/dist/check.mjs +25 -13
- package/dist/cli.mjs +18 -0
- package/dist/config.mjs +19 -3
- package/dist/css-values.mjs +26 -0
- package/dist/index.mjs +2 -0
- package/dist/init.mjs +1 -1
- package/dist/mcp/build-status.mjs +10 -3
- package/dist/mcp/catalog.mjs +11 -8
- package/dist/mcp/context.mjs +2 -2
- package/dist/mcp/mutations.mjs +11 -1
- package/dist/mcp/preview-owner-guard.mjs +29 -0
- package/dist/mcp/preview-watch.mjs +28 -4
- package/dist/mcp/preview-widget.bundle.js +2 -2
- package/dist/mcp/prompts.mjs +3 -3
- package/dist/mcp/server.mjs +61 -24
- package/dist/mcp/showcase-host.mjs +9 -5
- package/dist/oxlint.mjs +1 -1
- package/dist/public-api.mjs +39 -0
- package/dist/scenarios-compiler.mjs +17 -2
- package/dist/scenarios.mjs +27 -39
- package/dist/skills/README.md +3 -3
- package/dist/skills/ads-break-ui/SKILL.md +48 -4
- package/dist/skills/ads-designer/SKILL.md +49 -5
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +7 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
- package/dist/skills/ads-designer/references/cli.md +1 -1
- package/dist/skills/ads-designer/references/design-craft.md +1 -1
- package/dist/skills/ads-import/SKILL.md +48 -4
- package/dist/skills/ads-import/references/cli.md +1 -1
- package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +54 -10
- package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
- package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
- package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +27 -0
- package/dist/skills/ads-motion/SKILL.md +48 -4
- package/dist/style-boundaries.mjs +59 -0
- package/dist/tokens.mjs +3 -2
- package/dist/units.mjs +56 -9
- package/dist/verification/browser.mjs +27 -0
- package/dist/verification/playwright-reporter.cjs +20 -0
- package/dist/verification.mjs +214 -0
- package/docs/ADS.md +32 -3
- package/docs/ARCHITECTURE.md +45 -4
- package/docs/BEHAVIORS.md +230 -0
- package/docs/CHANGELOG.md +48 -0
- package/docs/GUIDE.md +25 -1
- package/docs/MCP.md +76 -7
- package/package.json +4 -2
- package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
- /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
- /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { isUiCssProperties, styleAccessProperty } from "./boundaries.mjs";
|
|
4
|
+
import { uiOperation } from "./ui-framework.mjs";
|
|
5
|
+
|
|
6
|
+
const nameOf = node => node && (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) ? node.text : null;
|
|
7
|
+
|
|
8
|
+
/** Audit explicit permissions without inferring project-specific style APIs. */
|
|
9
|
+
export function inspectStyleBoundaries({ root, config }, program, report) {
|
|
10
|
+
const checker = program.getTypeChecker(), usage = new Map();
|
|
11
|
+
function inspect(file) {
|
|
12
|
+
if (usage.has(file)) return usage.get(file);
|
|
13
|
+
const result = { inline: false, writes: new Set() }, source = program.getSourceFile(file);
|
|
14
|
+
function visit(node) {
|
|
15
|
+
if ((ts.isJsxAttribute(node) && nameOf(node.name) === "style") || isUiCssProperties(node, checker)) result.inline = true;
|
|
16
|
+
if (ts.isCallExpression(node) && uiOperation(node.expression, checker) === "createElement") {
|
|
17
|
+
const props = node.arguments[1];
|
|
18
|
+
if (props && ts.isObjectLiteralExpression(props) && props.properties.some(property => nameOf(property.name) === "style")) result.inline = true;
|
|
19
|
+
}
|
|
20
|
+
const property = styleAccessProperty(node);
|
|
21
|
+
if (typeof property === "string") result.writes.add(property);
|
|
22
|
+
ts.forEachChild(node, visit);
|
|
23
|
+
}
|
|
24
|
+
if (source) visit(source);
|
|
25
|
+
usage.set(file, result);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
const configFile = path.join(root, "ds.config.json");
|
|
29
|
+
for (const file of config.inlineStyleFiles) {
|
|
30
|
+
if (!inspect(path.resolve(root, file)).inline) report(configFile, "stale-style-boundary", `inlineStyleFiles entry ${file} has no JSX/factory style or UI CSSProperties use. Remove the unused permission; migrate source and its own boundary entries together in the owner's patch.`);
|
|
31
|
+
}
|
|
32
|
+
for (const { file, properties } of config.boundaries.styleWrites) {
|
|
33
|
+
const used = inspect(path.resolve(root, file)).writes;
|
|
34
|
+
for (const property of properties) if (!used.has(property)) report(configFile, "stale-style-boundary", `styleWrites entry ${file} never writes ${property}. Remove the unused property in the owner's source/boundary patch.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A local review signal, never proof that delegated cleanup is absent/correct. */
|
|
39
|
+
export function pointerCaptureAdvisories(source) {
|
|
40
|
+
const captures = [], cleanup = [];
|
|
41
|
+
function hasHandler(node) {
|
|
42
|
+
while (node && (ts.isJsxExpression(node) || ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
|
|
43
|
+
return !!node && ![ts.SyntaxKind.NullKeyword, ts.SyntaxKind.FalseKeyword].includes(node.kind) && !(ts.isIdentifier(node) && node.text === "undefined");
|
|
44
|
+
}
|
|
45
|
+
function visit(node) {
|
|
46
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
47
|
+
if (node.expression.name.text === "setPointerCapture") captures.push(node);
|
|
48
|
+
if (node.expression.name.text === "addEventListener" && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0]) && node.arguments[0].text === "lostpointercapture" && hasHandler(node.arguments[1])) cleanup.push(node);
|
|
49
|
+
}
|
|
50
|
+
if ((ts.isJsxAttribute(node) || ts.isPropertyAssignment(node) || ts.isMethodDeclaration(node)) && /^(?:onLostPointerCapture(?:Capture)?|onlostpointercapture)$/.test(nameOf(node.name) ?? "") && (ts.isMethodDeclaration(node) || hasHandler(node.initializer))) cleanup.push(node);
|
|
51
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left) && node.left.name.text === "onlostpointercapture" && hasHandler(node.right)) cleanup.push(node);
|
|
52
|
+
ts.forEachChild(node, visit);
|
|
53
|
+
}
|
|
54
|
+
visit(source);
|
|
55
|
+
return cleanup.length ? [] : captures.map(node => {
|
|
56
|
+
const position = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
57
|
+
return { rule: "pointer-capture-review", line: position.line + 1, column: position.character + 1, message: "Pointer capture has no locally visible lostpointercapture cleanup handler. Verify cancellation and capture-loss cleanup, including any delegated hook/helper. This advisory does not prove cleanup is missing or correct." };
|
|
58
|
+
});
|
|
59
|
+
}
|
package/dist/tokens.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import postcss from "postcss";
|
|
|
4
4
|
import valueParser from "postcss-value-parser";
|
|
5
5
|
import { loadConfig, sourceFiles } from "./config.mjs";
|
|
6
6
|
import { foundationFiles, isSharedToken, tokenContract } from "./token-policy.mjs";
|
|
7
|
+
import { dimensionValue, numericCssUnit } from "./css-values.mjs";
|
|
7
8
|
import { checkComponentVariable } from "./boundaries.mjs";
|
|
8
9
|
|
|
9
10
|
export { tokenContract };
|
|
@@ -33,8 +34,8 @@ function matchesType(value, type) {
|
|
|
33
34
|
if (/^(?:inherit|initial|unset|revert(?:-layer)?)$/.test(value)) return true;
|
|
34
35
|
switch (type) {
|
|
35
36
|
case "color": return /^(?:#[\da-f]{3,8}|[a-z][\w-]*|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\([\s\S]+\))$/i.test(value);
|
|
36
|
-
case "dimension": return
|
|
37
|
-
case "duration": return
|
|
37
|
+
case "dimension": return dimensionValue(value);
|
|
38
|
+
case "duration": return ["s", "ms"].includes(numericCssUnit(value));
|
|
38
39
|
case "number": return /^[-+]?(?:\d*\.)?\d+$/.test(value);
|
|
39
40
|
case "easing": return /^(?:linear|ease(?:-in|-out|-in-out)?|step-start|step-end|(?:cubic-bezier|steps|linear)\([\s\S]+\))$/.test(value);
|
|
40
41
|
case "shadow": return value === "none" || /(?:\d(?:px|rem|em)|var\()/.test(value);
|
package/dist/units.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import ts from "typescript";
|
|
4
4
|
import postcss from "postcss";
|
|
5
|
-
import { isInside } from "./config.mjs";
|
|
5
|
+
import { isDevelopmentFile, isInside } from "./config.mjs";
|
|
6
6
|
import { importResolver } from "./boundaries.mjs";
|
|
7
7
|
import { isProjectDocument, projectGuideFiles } from "./project-documents.mjs";
|
|
8
8
|
import { foundationFiles, foundationImportTarget } from "./token-policy.mjs";
|
|
@@ -16,14 +16,21 @@ export function configurationFiles(options) {
|
|
|
16
16
|
const { root, config, sourceRoot, dsRoot } = options;
|
|
17
17
|
const files = new Set(["ds.config.json", "package.json"]);
|
|
18
18
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
19
|
-
if (entry.isFile() && /^(?:(?:ts|js)config[^/]*\.jsonc?|(?:vite|vitest|linaria|babel|postcss|biome|oxlint)[^/]*\.(?:jsonc?|[cm]?[jt]s)|\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?)$/.test(entry.name)) files.add(entry.name);
|
|
19
|
+
if (entry.isFile() && /^(?:(?:ts|js)config[^/]*\.jsonc?|(?:vite|vitest|playwright|linaria|babel|postcss|biome|oxlint)[^/]*\.(?:jsonc?|[cm]?[jt]s)|\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?)$/.test(entry.name)) files.add(entry.name);
|
|
20
20
|
}
|
|
21
21
|
const resolve = importResolver(options), visited = new Set();
|
|
22
22
|
function visit(file, entrypoint = false, typeConfig = false) {
|
|
23
23
|
if (!isInside(root, file) || file === root || /(?:^|[\\/])(?:node_modules|\.git|\.ds|dist)(?:[\\/]|$)/.test(path.relative(root, file)) || visited.has(file) || !existsSync(file)) return;
|
|
24
|
+
// Registering a test command must not reassign its colocated checks or
|
|
25
|
+
// fixtures to configuration. Those remain in the behavior owner's patch.
|
|
26
|
+
if (isInside(dsRoot, file) && isDevelopmentFile(file, options)) return;
|
|
24
27
|
if (!entrypoint && isInside(sourceRoot, file) && !isInside(dsRoot, file)) return;
|
|
25
|
-
if (!entrypoint && isInside(dsRoot, file) && /^(?:components|patterns|composition|tokens)[\\/]/.test(path.relative(dsRoot, file))) {
|
|
26
|
-
if (
|
|
28
|
+
if (!entrypoint && isInside(dsRoot, file) && /^(?:components|patterns|controllers|composition|tokens)[\\/]/.test(path.relative(dsRoot, file))) {
|
|
29
|
+
if (isInside(path.join(dsRoot, "controllers"), file) || isDevelopmentFile(file, options)) return;
|
|
30
|
+
const folder = path.dirname(file), stem = path.basename(file).split(".")[0];
|
|
31
|
+
const anchored = ["index", path.basename(folder)].some(name => [".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"].some(extension => existsSync(path.join(folder, name + extension))));
|
|
32
|
+
const sidecar = path.basename(file).split(".").length > 2 && [".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"].some(extension => existsSync(path.join(folder, stem + extension)));
|
|
33
|
+
if (anchored || sidecar || /\.[jt]sx$/.test(file)) return;
|
|
27
34
|
if (source.test(file) && /(?:from\s*["'](?:react|preact|@linaria\/)|\b(?:require|import)\s*\(\s*["'](?:react|preact|@linaria\/))/.test(readFileSync(file,"utf8"))) return;
|
|
28
35
|
}
|
|
29
36
|
visited.add(file);
|
|
@@ -65,6 +72,10 @@ export function configurationFiles(options) {
|
|
|
65
72
|
if (["npm", "bun"].includes(watch.command) && watch.args[0] === "run") script(watch.args[1]);
|
|
66
73
|
else for (const argument of [watch.command, ...watch.args]) if (/\.[cm]?[jt]sx?$/.test(argument)) visit(path.resolve(root, argument), true);
|
|
67
74
|
}
|
|
75
|
+
for (const profile of Object.values(config.mcp.behaviorProfiles)) {
|
|
76
|
+
if (["npm", "bun"].includes(profile.command) && profile.args[0] === "run") script(profile.args[1]);
|
|
77
|
+
else for (const argument of [profile.command, ...profile.args]) if (/\.[cm]?[jt]sx?$/.test(argument)) visit(path.resolve(root, argument), true);
|
|
78
|
+
}
|
|
68
79
|
return files;
|
|
69
80
|
}
|
|
70
81
|
|
|
@@ -84,8 +95,20 @@ export function inspectUnits(options) {
|
|
|
84
95
|
}
|
|
85
96
|
walk(dsRoot);
|
|
86
97
|
files.sort();
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
// Runtime entry conventions keep directory ownership stable when an optional
|
|
99
|
+
// preview is removed. A flat scenario family still denotes separate owners.
|
|
100
|
+
const sameName = name => name.replace(/[-_]/g, "").toLowerCase();
|
|
101
|
+
const flatFamilies = new Set(files.filter(file => /\.scenario\.[cm]?[jt]sx?$/.test(file)).map(file => path.posix.dirname(file)));
|
|
102
|
+
const directories = new Set(files.filter(file => /(?:^|\/)Scenario\.[cm]?[jt]sx?$/.test(file)).map(file => slash(path.dirname(file))));
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
if (!/^(?:components|patterns|controllers|composition)\//.test(file) || !source.test(file)) continue;
|
|
105
|
+
const directory = slash(path.dirname(file)), name = path.basename(file).replace(source, "");
|
|
106
|
+
if (!directory.includes("/") || /\.(?:scenario|fixture|scenario-adapter)$/.test(name)) continue;
|
|
107
|
+
if (flatFamilies.has(directory)) continue;
|
|
108
|
+
if (name === "index" || sameName(name) === sameName(path.posix.basename(directory)) || name === "fixture") directories.add(directory);
|
|
109
|
+
}
|
|
110
|
+
const directoryUnits = [...directories].sort((a, b) => b.length - a.length);
|
|
111
|
+
const flatUnits = [...new Set(files.filter(file => /^(?:components|patterns|controllers|composition)\//.test(file) && source.test(file) && !directoryUnits.some(directory => file.startsWith(directory + "/"))).map(file => {
|
|
89
112
|
const base = path.basename(file).split(".")[0];
|
|
90
113
|
return slash(path.join(path.dirname(file), base));
|
|
91
114
|
}))].sort((a, b) => b.length - a.length);
|
|
@@ -101,7 +124,7 @@ export function inspectUnits(options) {
|
|
|
101
124
|
if (relative.startsWith(tokenDir + "/")) return "tokens";
|
|
102
125
|
if (relative.startsWith("assets/")) return relative.split("/").length > 2 ? relative.split("/").slice(0, 2).join("/") : "assets";
|
|
103
126
|
if (!relative.includes("/")) return /(?:\.(?:md|txt)$|^LICENSE$)/i.test(relative) ? "documentation" : "foundation";
|
|
104
|
-
if (!/^(?:components|patterns|composition)\//.test(relative)) return relative.split("/")[0];
|
|
127
|
+
if (!/^(?:components|patterns|controllers|composition)\//.test(relative)) return relative.split("/")[0];
|
|
105
128
|
for (const directory of directoryUnits) if (relative.startsWith(directory + "/")) return directory;
|
|
106
129
|
for (const unit of flatUnits) if (relative.startsWith(unit + "/") || relative.startsWith(unit + ".")) return unit;
|
|
107
130
|
const base = path.basename(relative).split(".")[0];
|
|
@@ -120,12 +143,17 @@ export function inspectUnits(options) {
|
|
|
120
143
|
const file = slash(path.relative(root, path.join(dsRoot, relative)));
|
|
121
144
|
if (!units.get(id).files.includes(file)) units.get(id).files.push(file);
|
|
122
145
|
}
|
|
146
|
+
for (const unit of units.values()) if (boundaryCompanion(unit.id)) {
|
|
147
|
+
(unit.companionFiles ??= []).push({ path: "ds.config.json", allowedChanges: "Only this owner's inlineStyleFiles and boundaries.styleWrites entries, together with its source change; preserve all other configuration and peer permissions." });
|
|
148
|
+
}
|
|
123
149
|
return { units: [...units.values()].sort((a, b) => a.id.localeCompare(b.id)), owner, publicEntry, stylesEntry, tokenDir,
|
|
124
150
|
relative: file => slash(path.relative(dsRoot, path.resolve(root, file))) };
|
|
125
151
|
}
|
|
126
152
|
|
|
153
|
+
function boundaryCompanion(unit) { return !["configuration", "documentation", "assets"].includes(unit.split("/")[0]); }
|
|
154
|
+
|
|
127
155
|
export function validateUnitId(unit) {
|
|
128
|
-
if (typeof unit !== "string" || unit.length > 256 || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(unit) || ["components", "patterns", "composition"].includes(unit)) throw fail("Select one ADS unit ID from ads_units or an unoccupied owner path following its colocation convention.");
|
|
156
|
+
if (typeof unit !== "string" || unit.length > 256 || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(unit) || ["components", "patterns", "controllers", "composition"].includes(unit)) throw fail("Select one ADS unit ID from ads_units or an unoccupied owner path following its colocation convention.");
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
/** Reject arbitrary file batches, even when every path is inside the ADS. */
|
|
@@ -134,6 +162,7 @@ export function checkUnitFiles(options, unit, changes, inventory = inspectUnits(
|
|
|
134
162
|
for (const change of changes) {
|
|
135
163
|
const relative = inventory.relative(change.path);
|
|
136
164
|
if (allowNew && unit === "configuration") continue; // The candidate must register every setup/extension file.
|
|
165
|
+
if (change.path === "ds.config.json" && boundaryCompanion(unit)) continue; // Validated against only this owner below.
|
|
137
166
|
if (relative === inventory.publicEntry) continue;
|
|
138
167
|
if (unit === "tokens" && relative === inventory.stylesEntry) continue; // Controlled foundation import bookkeeping.
|
|
139
168
|
if (allowNew && change.originalContent === null && (relative.startsWith(unit + "/") || relative.startsWith(unit + "."))) continue;
|
|
@@ -153,6 +182,24 @@ export function checkUnitCandidate(afterOptions, unit, changes, before) {
|
|
|
153
182
|
if (unit === "configuration" && (entry.id === "configuration" || after.owner(relative) === "configuration")) continue;
|
|
154
183
|
if (after.owner(relative) !== entry.id) throw fail("A patch cannot change ownership of existing peer units. Choose an unoccupied owner path.", file);
|
|
155
184
|
}
|
|
185
|
+
const boundaryChange = changes.find(change => change.path === "ds.config.json");
|
|
186
|
+
if (boundaryChange && unit !== "configuration") {
|
|
187
|
+
if (!boundaryCompanion(unit) || typeof boundaryChange.originalContent !== "string" || typeof boundaryChange.content !== "string") throw fail("This unit cannot create/delete configuration.", boundaryChange.path);
|
|
188
|
+
if (!changes.some(change => change.path !== boundaryChange.path && (before.owner(before.relative(change.path)) === unit || after.owner(after.relative(change.path)) === unit))) throw fail("Boundary companions require a source change in the selected owner.", boundaryChange.path);
|
|
189
|
+
function peerPolicy(content, inventory) {
|
|
190
|
+
const config = JSON.parse(content);
|
|
191
|
+
const owns = file => typeof file === "string" && inventory.owner(inventory.relative(file)) === unit;
|
|
192
|
+
config.inlineStyleFiles = (config.inlineStyleFiles ?? []).filter(file => !owns(file));
|
|
193
|
+
config.boundaries = { ...config.boundaries, styleWrites: (config.boundaries?.styleWrites ?? []).filter(entry => !owns(entry.file)) };
|
|
194
|
+
return config;
|
|
195
|
+
}
|
|
196
|
+
function canonical(value) {
|
|
197
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
198
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
if (JSON.stringify(canonical(peerPolicy(boundaryChange.originalContent, before))) !== JSON.stringify(canonical(peerPolicy(boundaryChange.content, after)))) throw fail("Boundary companion patches may change only inlineStyleFiles and styleWrites entries owned by the selected unit; preserve all other configuration and peer permissions.", boundaryChange.path);
|
|
202
|
+
}
|
|
156
203
|
const foundationChange = changes.find(change => before.relative(change.path) === before.stylesEntry);
|
|
157
204
|
if (unit === "tokens" && foundationChange) {
|
|
158
205
|
const { originalContent: current, content: proposed } = foundationChange;
|
|
@@ -171,7 +218,7 @@ export function checkUnitCandidate(afterOptions, unit, changes, before) {
|
|
|
171
218
|
}
|
|
172
219
|
const exportChange = changes.find(change => before.relative(change.path) === before.publicEntry);
|
|
173
220
|
if (exportChange) {
|
|
174
|
-
if (!["components", "patterns"].includes(unit.split("/")[0])) throw fail("Only a component/pattern unit may update its public re-exports.", exportChange.path);
|
|
221
|
+
if (!["components", "patterns", "controllers"].includes(unit.split("/")[0])) throw fail("Only a component/pattern/controller unit may update its public re-exports.", exportChange.path);
|
|
175
222
|
const current = exportChange.originalContent ?? "", proposed = exportChange.content;
|
|
176
223
|
if (proposed === null) throw fail("The shared public entry cannot be deleted.", exportChange.path);
|
|
177
224
|
const resolve = importResolver(afterOptions);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Development-only Playwright helper. No browser or framework dependency is installed. */
|
|
4
|
+
export function verificationRequest() {
|
|
5
|
+
if (!process.env.ADS_VERIFY_REQUEST) throw new Error("Run this check through ads_verify so the request and selected profile are explicit.");
|
|
6
|
+
return JSON.parse(readFileSync(process.env.ADS_VERIFY_REQUEST, "utf8"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function openBehaviorScenario(page, checkId, { timeout = 30000 } = {}) {
|
|
10
|
+
const request = verificationRequest();
|
|
11
|
+
const check = request.checks.find(check => check.id === checkId);
|
|
12
|
+
const url = check && request.scenarios[check.scenario];
|
|
13
|
+
if (!url) throw new Error("This check has no managed browser scenario. StrictMode profiles must supply a project development harness; production preview bundles do not replay React development effects.");
|
|
14
|
+
await page.goto(url, { waitUntil: "load", timeout });
|
|
15
|
+
const end = Date.now() + timeout;
|
|
16
|
+
while (Date.now() < end) {
|
|
17
|
+
for (const frame of page.frames().filter(frame => frame !== page.mainFrame())) {
|
|
18
|
+
const state = await frame.evaluate(() => ({ state: document.documentElement.dataset.adsPreviewState, id: document.documentElement.dataset.adsScenario, message: document.getElementById("ads-preview-status")?.textContent })).catch(() => ({}));
|
|
19
|
+
if (state.id === check.scenario && state.state === "ready") return frame;
|
|
20
|
+
if (state.state === "error") throw new Error(state.message || "The MCP App failed before verification.");
|
|
21
|
+
}
|
|
22
|
+
const error = await page.evaluate(() => document.querySelector('#status[data-error="true"]')?.textContent).catch(() => null);
|
|
23
|
+
if (error) throw new Error(error);
|
|
24
|
+
await page.waitForTimeout(50);
|
|
25
|
+
}
|
|
26
|
+
throw new Error("Timed out waiting for the selected MCP App scenario to become current and ready. Inspect ads_status and ads://project/build.");
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { readFileSync, writeFileSync } = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
/** Case identity is an explicit project test tag: @ads:<case-id>. */
|
|
5
|
+
module.exports = class AdsBehaviorReporter {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.request = JSON.parse(readFileSync(process.env.ADS_VERIFY_REQUEST, "utf8"));
|
|
8
|
+
this.cases = [];
|
|
9
|
+
}
|
|
10
|
+
onTestEnd(test, result) {
|
|
11
|
+
const file = path.relative(this.request.projectRoot, test.location.file).replaceAll("\\", "/");
|
|
12
|
+
for (const tag of test.tags ?? []) {
|
|
13
|
+
if (!tag.startsWith("@ads:")) continue;
|
|
14
|
+
this.cases.push({ id: tag.slice(5), file, status: result.status === "passed" && test.expectedStatus === "passed" ? "passed" : ["skipped", "interrupted"].includes(result.status) ? "skipped" : "failed", message: result.errors?.map(error => error.message).join("\n").slice(0, 4000) });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
onEnd() {
|
|
18
|
+
writeFileSync(process.env.ADS_VERIFY_REPORT, JSON.stringify({ schemaVersion: 1, runId: this.request.runId, cases: this.cases.slice(0, 500) }));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
import postcss from "postcss";
|
|
8
|
+
import valueParser from "postcss-value-parser";
|
|
9
|
+
import { loadConfig, sourceFiles, isInside } from "./config.mjs";
|
|
10
|
+
import { importResolver } from "./boundaries.mjs";
|
|
11
|
+
import { inspectUnits, configurationFiles } from "./units.mjs";
|
|
12
|
+
import { inspectBehaviors } from "./behaviors.mjs";
|
|
13
|
+
import { inspectScenarios } from "./scenarios.mjs";
|
|
14
|
+
import { readPreviewManifest } from "./preview-resources.mjs";
|
|
15
|
+
|
|
16
|
+
export const BEHAVIOR_URI = "ads://project/behaviors";
|
|
17
|
+
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
18
|
+
const slash = file => file.replaceAll("\\", "/");
|
|
19
|
+
const resultFile = root => path.join(root, ".ds/behavior-results.json");
|
|
20
|
+
|
|
21
|
+
/** Follow real project imports; package/configuration changes also invalidate evidence. */
|
|
22
|
+
function inputContext(options) { return { inventory: inspectUnits(options), scenarios: inspectScenarios(options).scenarios }; }
|
|
23
|
+
|
|
24
|
+
export function behaviorInputs(options, contract, check, context = inputContext(options)) {
|
|
25
|
+
const files = new Set(), resolve = importResolver(options);
|
|
26
|
+
function visit(file) {
|
|
27
|
+
file = path.resolve(file);
|
|
28
|
+
if (files.has(file) || !isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
|
|
29
|
+
if (lstatSync(file).isSymbolicLink() || realpathSync(file) !== file) throw new Error(`Behavior inputs cannot resolve through symlinks: ${file}`);
|
|
30
|
+
if (!lstatSync(file).isFile()) return;
|
|
31
|
+
files.add(file);
|
|
32
|
+
const localAsset = specifier => {
|
|
33
|
+
if (!specifier || /^(?:[a-z][\w+.-]*:|\/|#)/i.test(specifier)) return;
|
|
34
|
+
visit(path.resolve(path.dirname(file), specifier.replace(/[?#].*$/, "")));
|
|
35
|
+
};
|
|
36
|
+
if (file.endsWith(".css")) {
|
|
37
|
+
const css = postcss.parse(readFileSync(file, "utf8"), { from: file });
|
|
38
|
+
css.walkAtRules("import", rule => { const first = valueParser(rule.params).nodes.find(node => node.type !== "space" && node.type !== "comment"); if (first?.type === "string") localAsset(first.value); });
|
|
39
|
+
css.walk(node => { if (node.type === "decl" || node.type === "atrule") valueParser(node.type === "decl" ? node.value : node.params).walk(value => { if (value.type === "function" && value.value.toLowerCase() === "url") localAsset(value.nodes.filter(part => part.type !== "space").map(part => part.value).join("")); }); });
|
|
40
|
+
}
|
|
41
|
+
if (!/\.[cm]?[jt]sx?$/.test(file)) return;
|
|
42
|
+
const ast = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
|
|
43
|
+
function walk(node) {
|
|
44
|
+
let specifier;
|
|
45
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
|
|
46
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) specifier = node.arguments[0].text;
|
|
47
|
+
if (specifier) for (const target of resolve(specifier, file)) visit(target);
|
|
48
|
+
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "URL" && node.arguments?.[0] && ts.isStringLiteralLike(node.arguments[0])) localAsset(node.arguments[0].text);
|
|
49
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0]))) {
|
|
50
|
+
// Computed module selection cannot provide a precise dependency closure.
|
|
51
|
+
// Conservatively invalidate against every owned ADS input instead.
|
|
52
|
+
for (const unit of context.inventory.units) for (const owned of unit.files) visit(path.join(options.root, owned));
|
|
53
|
+
}
|
|
54
|
+
ts.forEachChild(node, walk);
|
|
55
|
+
}
|
|
56
|
+
walk(ast);
|
|
57
|
+
}
|
|
58
|
+
for (const file of context.inventory.units.find(unit => unit.id === contract.unit)?.files ?? []) visit(path.join(options.root, file));
|
|
59
|
+
for (const file of configurationFiles(options)) visit(path.join(options.root, file));
|
|
60
|
+
for (const file of sourceFiles(path.join(options.dsRoot, options.config.tokenDir))) visit(file);
|
|
61
|
+
visit(path.join(options.dsRoot, options.config.stylesEntry));
|
|
62
|
+
const scenario = check.scenario && context.scenarios.find(item => item.id === check.scenario);
|
|
63
|
+
if (scenario) visit(path.join(options.root, scenario.file));
|
|
64
|
+
const rows = [...files].sort().map(file => [slash(path.relative(options.root, file)), hash(readFileSync(file))]);
|
|
65
|
+
for (const file of ["package-lock.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "node_modules/.package-lock.json"]) rows.push([file, existsSync(path.join(options.root, file)) ? hash(readFileSync(path.join(options.root, file))) : null]);
|
|
66
|
+
rows.push(["profile", JSON.stringify(options.config.mcp.behaviorProfiles[check.profile])], ["node", process.version]);
|
|
67
|
+
for (const file of ["./verification.mjs", "./verification/browser.mjs", "./verification/playwright-reporter.cjs"]) rows.push([`tool:${file}`, hash(readFileSync(new URL(file, import.meta.url)))]);
|
|
68
|
+
return { revision: hash(JSON.stringify(rows)), files: rows.map(([file]) => file) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saved(root) {
|
|
72
|
+
try {
|
|
73
|
+
const file = resultFile(root);
|
|
74
|
+
if (realpathSync(file) !== file || lstatSync(file).size > 2 * 1024 * 1024) return {};
|
|
75
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
76
|
+
return value.schemaVersion === 1 && value.results && typeof value.results === "object" && !Array.isArray(value.results) ? value.results : {};
|
|
77
|
+
} catch { return {}; }
|
|
78
|
+
}
|
|
79
|
+
const identity = (unit, check) => `${unit}#${check}`;
|
|
80
|
+
|
|
81
|
+
export function behaviorStatus(options) {
|
|
82
|
+
const inventory = inspectBehaviors(options), evidence = saved(options.root);
|
|
83
|
+
const context = inventory.contracts.length ? inputContext(options) : null;
|
|
84
|
+
return { ...inventory, contracts: inventory.contracts.map(contract => {
|
|
85
|
+
const checks = Object.fromEntries(Object.entries(contract.checks).map(([id, check]) => {
|
|
86
|
+
const previous = evidence[identity(contract.unit, id)];
|
|
87
|
+
const revision = behaviorInputs(options, contract, check, context).revision;
|
|
88
|
+
const current = previous?.revision === revision && previous.profile === check.profile && previous.file === check.file && previous.case === check.case;
|
|
89
|
+
return [id, { ...check, result: previous ? { ...previous, current, status: current ? previous.status : "stale" } : { status: "not_run", current: false } }];
|
|
90
|
+
}));
|
|
91
|
+
return { ...contract, checks, guarantees: contract.guarantees.map(guarantee => {
|
|
92
|
+
const results = guarantee.checks.map(id => checks[id]?.result ?? { status: "unavailable", current: false });
|
|
93
|
+
const status = results.every(result => result.current && result.status === "passed") ? "passed" : results.find(result => result.status !== "passed")?.status ?? "not_run";
|
|
94
|
+
return { ...guarantee, result: { status, current: results.every(result => result.current) } };
|
|
95
|
+
}) };
|
|
96
|
+
}) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function affectedBehaviorUnits(options, changedFiles) {
|
|
100
|
+
const changed = new Set(changedFiles.map(file => slash(path.relative(options.root, path.resolve(options.root, file)))));
|
|
101
|
+
const contracts = inspectBehaviors(options).contracts;
|
|
102
|
+
if (!contracts.length) return [];
|
|
103
|
+
const context = inputContext(options);
|
|
104
|
+
return contracts.filter(contract => Object.values(contract.checks).some(check => behaviorInputs(options, contract, check, context).files.some(file => changed.has(file)))).map(contract => contract.unit);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function execute(profile, args, root, env, signal) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
if (signal.aborted) { reject(new Error("Behavior verification cancelled.")); return; }
|
|
110
|
+
let output = "", failure, child, guard, done = false;
|
|
111
|
+
const kill = () => {
|
|
112
|
+
if (!child?.pid) return;
|
|
113
|
+
if (process.platform === "win32") { const stop = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); stop.on("error", () => child.kill("SIGKILL")); }
|
|
114
|
+
else try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") failure ??= error; }
|
|
115
|
+
};
|
|
116
|
+
child = spawn(profile.command === "node" ? process.execPath : profile.command, args, { cwd: root, detached: process.platform !== "win32", shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
117
|
+
env: { ...process.env, ...env, PATH: [path.join(root, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) } });
|
|
118
|
+
if (child.pid) {
|
|
119
|
+
guard = spawn(process.execPath, [fileURLToPath(new URL("./mcp/preview-owner-guard.mjs", import.meta.url)), String(child.pid)], { detached: process.platform !== "win32", stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
|
|
120
|
+
guard.stdin.on("error", () => {});
|
|
121
|
+
guard.once("error", error => { failure = error; kill(); });
|
|
122
|
+
guard.once("exit", () => { if (!done) { failure ??= new Error("Verification ownership guard exited unexpectedly."); kill(); } });
|
|
123
|
+
}
|
|
124
|
+
const abort = () => { failure = new Error("Behavior verification cancelled."); kill(); };
|
|
125
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
126
|
+
const timeout = setTimeout(() => { failure = new Error("Behavior verification timed out. Inspect the registered runner and its browser/server cleanup."); kill(); }, profile.timeoutMs ?? 60000);
|
|
127
|
+
for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { output = (output + chunk.toString()).slice(-12000); });
|
|
128
|
+
child.once("error", error => { failure = error; });
|
|
129
|
+
child.once("exit", kill);
|
|
130
|
+
child.once("close", code => {
|
|
131
|
+
done = true; kill(); guard?.stdin.end("disarm\n"); clearTimeout(timeout); signal.removeEventListener("abort", abort);
|
|
132
|
+
if (failure) reject(Object.assign(failure, { output }));
|
|
133
|
+
else resolve({ code, output });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Registered development commands are trusted project code, never model-supplied commands. */
|
|
139
|
+
export function createVerificationController(root) {
|
|
140
|
+
root = path.resolve(root);
|
|
141
|
+
let active;
|
|
142
|
+
return {
|
|
143
|
+
status: () => ({ running: !!active }),
|
|
144
|
+
async close() { active?.abort(); await active?.completion; },
|
|
145
|
+
async run({ unit, units, checks, persist = true } = {}, signal) {
|
|
146
|
+
if (active) return { ok: false, diagnostics: [{ rule: "behavior-busy", message: "A behavior verification is already running." }], results: [] };
|
|
147
|
+
active = new AbortController();
|
|
148
|
+
const controller = active;
|
|
149
|
+
signal = signal ? AbortSignal.any([signal, active.signal]) : active.signal;
|
|
150
|
+
let finish; controller.completion = new Promise(resolve => { finish = resolve; });
|
|
151
|
+
let host;
|
|
152
|
+
const results = [], diagnostics = [];
|
|
153
|
+
try {
|
|
154
|
+
const options = loadConfig(root), inventory = inspectBehaviors(options);
|
|
155
|
+
if (inventory.diagnostics.length) return { ok: false, diagnostics: inventory.diagnostics, results };
|
|
156
|
+
const selectedUnits = new Set(units ?? (unit ? [unit] : inventory.contracts.map(item => item.unit)));
|
|
157
|
+
const contracts = inventory.contracts.filter(item => selectedUnits.has(item.unit));
|
|
158
|
+
if (!contracts.length || [...selectedUnits].some(id => !contracts.some(contract => contract.unit === id))) return { ok: false, diagnostics: [{ rule: "behavior-selection", message: "No behavior contract matches the complete selection. Discover exact owner/check IDs with ads_behaviors." }], results };
|
|
159
|
+
const context = inputContext(options);
|
|
160
|
+
const selection = contracts.flatMap(contract => Object.entries(contract.checks).filter(([id]) => !checks || checks.includes(id)).map(([id, check]) => ({ unit: contract.unit, id, ...check, inputRevision: behaviorInputs(options, contract, check, context).revision, contract })));
|
|
161
|
+
if (!selection.length || checks?.some(id => !selection.some(check => check.id === id))) return { ok: false, diagnostics: [{ rule: "behavior-selection", message: "Unknown check selection. Use check IDs returned by ads_behaviors." }], results };
|
|
162
|
+
for (const profileName of new Set(selection.map(check => check.profile))) {
|
|
163
|
+
const profile = options.config.mcp.behaviorProfiles[profileName];
|
|
164
|
+
const group = selection.filter(check => check.profile === profileName);
|
|
165
|
+
const directory = path.join(root, ".ds/verification", randomUUID());
|
|
166
|
+
mkdirSync(directory, { recursive: true });
|
|
167
|
+
if (realpathSync(directory) !== directory) throw new Error("Verification output cannot resolve through a symlink.");
|
|
168
|
+
const runId = randomUUID(), report = path.join(directory, "report.json"), request = path.join(directory, "request.json");
|
|
169
|
+
const scenarios = {};
|
|
170
|
+
if (profile.mode === "browser" && group.some(check => check.scenario)) {
|
|
171
|
+
const { startAdsMcp } = await import("./mcp/server.mjs");
|
|
172
|
+
host ??= await startAdsMcp(root, { stdio: false, showcase: true });
|
|
173
|
+
for (const check of group.filter(check => check.scenario)) { const url = new URL(host.url); url.searchParams.set("scenario", check.scenario); scenarios[check.scenario] = url.href; }
|
|
174
|
+
}
|
|
175
|
+
const requestContext = { schemaVersion: 1, runId, projectRoot: root, framework: options.config.framework, profile: profileName, mode: profile.mode ?? "unit", checks: group.map(({ contract: _contract, ...check }) => check), scenarios };
|
|
176
|
+
writeFileSync(request, JSON.stringify(requestContext));
|
|
177
|
+
const args = [...profile.args];
|
|
178
|
+
if (profile.runner === "playwright") args.push(...new Set(group.map(check => slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file))))), `--reporter=${fileURLToPath(new URL("./verification/playwright-reporter.cjs", import.meta.url))}`, `--output=${path.join(directory, "artifacts")}`);
|
|
179
|
+
let run, reportValue, runnerError;
|
|
180
|
+
try {
|
|
181
|
+
run = await execute(profile, args, root, { ADS_VERIFY_REQUEST: request, ADS_VERIFY_REPORT: report, ADS_VERIFY_MODE: requestContext.mode }, signal);
|
|
182
|
+
if (!existsSync(report) || realpathSync(report) !== report || lstatSync(report).size > 1024 * 1024) throw new Error("Runner produced no valid bounded verification report. A successful exit alone is not passing evidence.");
|
|
183
|
+
reportValue = JSON.parse(readFileSync(report, "utf8"));
|
|
184
|
+
if (reportValue.schemaVersion !== 1 || reportValue.runId !== runId || !Array.isArray(reportValue.cases) || reportValue.cases.length > 500) throw new Error("Runner report has the wrong run ID/schema or too many cases.");
|
|
185
|
+
} catch (error) { runnerError = error; diagnostics.push({ rule: "behavior-runner", message: error.message, output: error.output?.slice(-4000), profile: profileName }); }
|
|
186
|
+
const afterOptions = loadConfig(root), afterContext = inputContext(afterOptions);
|
|
187
|
+
for (const check of group) {
|
|
188
|
+
const file = slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file)));
|
|
189
|
+
const matches = reportValue?.cases.filter(item => item && item.id === check.case && item.file === file) ?? [];
|
|
190
|
+
let status = !run || run.code !== 0 ? "failed" : matches.length && matches.every(item => item.status === "passed") ? "passed" : matches.length && matches.every(item => item.status === "skipped") ? "skipped" : "failed";
|
|
191
|
+
if (runnerError?.code === "ENOENT") status = "unavailable";
|
|
192
|
+
const current = behaviorInputs(afterOptions, check.contract, check, afterContext).revision === check.inputRevision;
|
|
193
|
+
if (!current) status = "stale";
|
|
194
|
+
if (signal.aborted) status = "cancelled";
|
|
195
|
+
results.push({ unit: check.unit, check: check.id, file: check.file, case: check.case, profile: profileName, status, current, revision: check.inputRevision, runId, completedAt: new Date().toISOString(),
|
|
196
|
+
previewRevision: profile.mode === "browser" ? 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,
|
|
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
|
|
@@ -291,10 +316,14 @@ The checks enforce the architecture's ownership boundaries:
|
|
|
291
316
|
| Tokens and styles | Fixed token vocabulary, required definitions, declared value types and references, raw visual values, shared-token assignments, foundation import order, and component/pattern namespaces |
|
|
292
317
|
| 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 |
|
|
293
318
|
| Interaction policy | Primitive state variables stay inside components; non-state customization hooks require explicit registration |
|
|
294
|
-
| Renderer exceptions | Inline
|
|
319
|
+
| 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 |
|
|
295
320
|
| 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 |
|
|
296
321
|
| Assets | Source/public collisions and configured generated artifacts in source are rejected |
|
|
297
322
|
|
|
323
|
+
Pointer capture without locally visible capture-loss cleanup produces a
|
|
324
|
+
nonblocking advisory. Review delegated cleanup and browser behavior; neither a
|
|
325
|
+
warning nor a handler's presence establishes lifecycle correctness.
|
|
326
|
+
|
|
298
327
|
Document a new ownership exception with its guard/configuration change and
|
|
299
328
|
tests. Passing static checks establishes only the policies and source formats
|
|
300
329
|
they cover. Browser review and behavior tests still verify accessibility,
|