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