@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.
Files changed (55) hide show
  1. package/README.md +12 -4
  2. package/dist/behaviors.mjs +157 -0
  3. package/dist/bootstrap-ds/ADS.md +7 -0
  4. package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  5. package/dist/boundaries.mjs +21 -23
  6. package/dist/check.mjs +25 -13
  7. package/dist/cli.mjs +18 -0
  8. package/dist/config.mjs +19 -3
  9. package/dist/css-values.mjs +26 -0
  10. package/dist/index.mjs +2 -0
  11. package/dist/init.mjs +1 -1
  12. package/dist/mcp/build-status.mjs +10 -3
  13. package/dist/mcp/catalog.mjs +11 -8
  14. package/dist/mcp/context.mjs +2 -2
  15. package/dist/mcp/mutations.mjs +11 -1
  16. package/dist/mcp/preview-owner-guard.mjs +29 -0
  17. package/dist/mcp/preview-watch.mjs +28 -4
  18. package/dist/mcp/preview-widget.bundle.js +2 -2
  19. package/dist/mcp/prompts.mjs +3 -3
  20. package/dist/mcp/server.mjs +61 -24
  21. package/dist/mcp/showcase-host.mjs +9 -5
  22. package/dist/oxlint.mjs +1 -1
  23. package/dist/public-api.mjs +39 -0
  24. package/dist/scenarios-compiler.mjs +17 -2
  25. package/dist/scenarios.mjs +27 -39
  26. package/dist/skills/README.md +3 -3
  27. package/dist/skills/ads-break-ui/SKILL.md +48 -4
  28. package/dist/skills/ads-designer/SKILL.md +49 -5
  29. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +7 -0
  30. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  31. package/dist/skills/ads-designer/references/cli.md +1 -1
  32. package/dist/skills/ads-designer/references/design-craft.md +1 -1
  33. package/dist/skills/ads-import/SKILL.md +48 -4
  34. package/dist/skills/ads-import/references/cli.md +1 -1
  35. package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +54 -10
  36. package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
  37. package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
  38. package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +27 -0
  39. package/dist/skills/ads-motion/SKILL.md +48 -4
  40. package/dist/style-boundaries.mjs +59 -0
  41. package/dist/tokens.mjs +3 -2
  42. package/dist/units.mjs +56 -9
  43. package/dist/verification/browser.mjs +27 -0
  44. package/dist/verification/playwright-reporter.cjs +20 -0
  45. package/dist/verification.mjs +214 -0
  46. package/docs/ADS.md +32 -3
  47. package/docs/ARCHITECTURE.md +45 -4
  48. package/docs/BEHAVIORS.md +230 -0
  49. package/docs/CHANGELOG.md +48 -0
  50. package/docs/GUIDE.md +25 -1
  51. package/docs/MCP.md +76 -7
  52. package/package.json +4 -2
  53. package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
  54. /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
  55. /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
package/README.md CHANGED
@@ -58,6 +58,8 @@ Linaria; integrate its extraction with your build as described in the
58
58
  | `ads init --framework react` | Set up ADS in the current project; also accepts `preact`; saved choices can omit the flag |
59
59
  | `ads check` | Check tokens, styles, imports, and ADS ownership |
60
60
  | `ads tokens` | Inspect token values and themes |
61
+ | `ads behaviors` | Inspect registered behavior guarantees and current evidence |
62
+ | `ads verify --unit <id>` | Run an owner's registered behavior checks |
61
63
  | `ads assets` | Generate standard web icons from the project's SVG |
62
64
  | `ads lint-config` | Generate lint config; normally called by the build |
63
65
  | `ads mcp` | Start the stdio MCP server and its local showcase |
@@ -92,9 +94,15 @@ fixtures, and runs the guards before applying it. Foundation styles, original
92
94
  assets, guides and project configuration/guard extensions have editable units too. See [guarded editing](docs/MCP.md#guarded-editing).
93
95
 
94
96
  The server exposes public components/patterns, token values,
95
- source guides, architecture checks, workflow prompts, and packaged ADS skills.
97
+ source guides, controllers/hooks, architecture checks, behavioral verification,
98
+ workflow prompts, and packaged ADS skills.
96
99
  It supports older MCP clients and the newer Skills protocol.
97
100
 
101
+ Private controllers stay beside their component; shared controllers have their own
102
+ edit units. Optional behavior contracts connect guarantees to maintained checks.
103
+ Demonstrations and passing execution evidence remain distinct. See
104
+ [controllers and verification](docs/BEHAVIORS.md).
105
+
98
106
  Experimental MCP Apps show an interactive token inspector and project-built
99
107
  token, component, pattern and composition scenarios. They use the same public implementations and fixtures
100
108
  as the showcase. Text and resource access remain available without Apps support.
@@ -202,13 +210,13 @@ app's bundler. With no icon source, generation is skipped. See the
202
210
  Install skills with `npx skills`:
203
211
 
204
212
  ```sh
205
- npx skills add reqvire-org/ads --skill ads-designer --skill ads-import --skill ads-assimilate --skill ads-break-ui --skill ads-motion
213
+ npx skills add reqvire-org/ads --skill ads-designer --skill ads-import --skill ads-migrate --skill ads-break-ui --skill ads-motion
206
214
  ```
207
215
 
208
216
  For local development, install from this checkout:
209
217
 
210
218
  ```sh
211
- npx skills add ./skills --skill ads-designer --skill ads-import --skill ads-assimilate --skill ads-break-ui --skill ads-motion
219
+ npx skills add ./skills --skill ads-designer --skill ads-import --skill ads-migrate --skill ads-break-ui --skill ads-motion
212
220
  ```
213
221
 
214
222
  Acknowledgments: `ads-designer`'s craft/mobile guidance, `ads-break-ui`, and
@@ -220,7 +228,7 @@ notice beside its files.
220
228
 
221
229
  - [ads-designer](skills/ads-designer/SKILL.md): start an ADS or build UI with your local ADS.
222
230
  - [ads-import](skills/ads-import/SKILL.md): import external components, patterns, styles, token values, or brand artwork.
223
- - [ads-assimilate](skills/ads-assimilate/SKILL.md): implement a native component inspired by an external library.
231
+ - [ads-migrate](skills/ads-migrate/SKILL.md): implement a native component inspired by an external library.
224
232
  - [ads-break-ui](skills/ads-break-ui/SKILL.md): stress-test real components and patterns with difficult content.
225
233
  - [ads-motion](skills/ads-motion/SKILL.md): implement, review, audit, or profile component animation.
226
234
 
@@ -0,0 +1,157 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { isBuiltin } from "node:module";
4
+ import ts from "typescript";
5
+ import { z } from "zod";
6
+ import { isBehaviorFile, isDevelopmentFile, isInside, sourceFiles } from "./config.mjs";
7
+ import { importResolver, projectCompilerOptions } from "./boundaries.mjs";
8
+ import { inspectUnits } from "./units.mjs";
9
+ import { publicApi } from "./public-api.mjs";
10
+ import { inspectScenarios } from "./scenarios.mjs";
11
+ import { uiOperation } from "./ui-framework.mjs";
12
+
13
+ const id = z.string().regex(/^[a-zA-Z0-9][\w.-]{0,99}$/);
14
+ const reference = z.string().min(1).max(500);
15
+ const subject = z.union([
16
+ z.object({ api: reference }).strict(),
17
+ z.object({ source: reference, symbol: reference.optional() }).strict(),
18
+ ]);
19
+ export const behaviorSchema = z.object({
20
+ schemaVersion: z.literal(1),
21
+ subjects: z.record(id, subject).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 100),
22
+ guarantees: z.array(z.object({ id, description: z.string().min(1).max(2000), subjects: z.array(id).min(1).max(100), checks: z.array(id).min(1).max(100) }).strict()).min(1).max(100),
23
+ checks: z.record(id, z.object({ file: reference, case: id, scenario: reference.optional(), profile: id }).strict()).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 100),
24
+ }).strict();
25
+
26
+ /** Static contracts never execute project code or certify natural-language claims. */
27
+ export function inspectBehaviors(options, suppliedProgram, suppliedRegistry) {
28
+ const { root, dsRoot, config } = options;
29
+ const inventory = inspectUnits(options);
30
+ const files = sourceFiles(dsRoot).filter(isBehaviorFile);
31
+ if (!files.length) return { contracts: [], diagnostics: [] };
32
+ const program = suppliedProgram ?? ts.createProgram(sourceFiles(dsRoot).filter(file => /\.[cm]?[jt]sx?$/.test(file)), { ...projectCompilerOptions(options), allowJs: true });
33
+ const checker = program.getTypeChecker();
34
+ const apis = new Map(publicApi(program, options).map(item => [item.name, item]));
35
+ const scenarios = (suppliedRegistry ?? inspectScenarios(options, program)).scenarios;
36
+ const diagnostics = [], contracts = [], owners = new Set();
37
+ const relative = file => path.relative(root, file).replaceAll("\\", "/");
38
+ for (const file of files) {
39
+ const report = message => diagnostics.push({ file: relative(file), rule: "behavior-contract", line: 1, column: 1, message });
40
+ let value;
41
+ try {
42
+ if (statSync(file).size > 128 * 1024 || realpathSync(file) !== file) throw new Error("Behavior contracts must be regular files of at most 128 KiB.");
43
+ const parsed = behaviorSchema.safeParse(JSON.parse(readFileSync(file, "utf8")));
44
+ if (!parsed.success) throw new Error(parsed.error.issues.slice(0, 5).map(issue => `${issue.path.join(".")}: ${issue.message}`).join("; "));
45
+ value = parsed.data;
46
+ } catch (error) { report(error.message); continue; }
47
+ const unit = inventory.owner(inventory.relative(relative(file)));
48
+ if (!["components", "patterns", "controllers"].includes(unit?.split("/")[0])) { report("Behavior contracts belong to a component, pattern or controller unit."); continue; }
49
+ if (owners.has(unit)) { report(`Only one behavior contract may describe ${unit}.`); continue; }
50
+ owners.add(unit);
51
+ function owned(target, field) {
52
+ if (path.isAbsolute(target) || target.includes("\\") || target.includes("\0")) { report(`${field} must be an owner-local relative path.`); return null; }
53
+ const absolute = path.resolve(path.dirname(file), target);
54
+ if (!isInside(dsRoot, absolute) || inventory.owner(inventory.relative(relative(absolute))) !== unit
55
+ || !existsSync(absolute) || !statSync(absolute).isFile() || realpathSync(absolute) !== absolute) { report(`${field} must resolve to a real file in ${unit}: ${target}`); return null; }
56
+ return absolute;
57
+ }
58
+ for (const [name, entry] of Object.entries(value.subjects)) {
59
+ if (entry.api) {
60
+ const api = apis.get(entry.api);
61
+ if (!api || inventory.owner(inventory.relative(relative(api.file))) !== unit) report(`Subject ${name} must resolve to a public API owned by ${unit}: ${entry.api}`);
62
+ } else {
63
+ const source = owned(entry.source, `Subject ${name}`);
64
+ if (source && isDevelopmentFile(source, options)) report(`Subject ${name} must identify runtime implementation, not a fixture, scenario or check.`);
65
+ if (source && entry.symbol) {
66
+ const ast = program.getSourceFile(source);
67
+ if (!ast || !checker.getSymbolsInScope(ast, ts.SymbolFlags.Value).some(symbol => symbol.name === entry.symbol && symbol.declarations?.some(declaration => declaration.getSourceFile() === ast))) report(`Subject ${name}: symbol ${entry.symbol} is not declared in ${entry.source}.`);
68
+ }
69
+ }
70
+ }
71
+ const guaranteeIds = new Set();
72
+ for (const guarantee of value.guarantees) {
73
+ if (guaranteeIds.has(guarantee.id)) report(`Duplicate guarantee ID ${guarantee.id}.`);
74
+ guaranteeIds.add(guarantee.id);
75
+ for (const name of guarantee.subjects) if (!Object.hasOwn(value.subjects, name)) report(`Guarantee ${guarantee.id} references unknown subject ${name}.`);
76
+ for (const check of guarantee.checks) if (!Object.hasOwn(value.checks, check)) report(`Guarantee ${guarantee.id} references unknown check ${check}.`);
77
+ }
78
+ const cases = new Set();
79
+ for (const [name, check] of Object.entries(value.checks)) {
80
+ const target = owned(check.file, `Check ${name}`);
81
+ if (target && !isDevelopmentFile(target, options)) report(`Check ${name} must use a development-only test/spec file.`);
82
+ if (!Object.hasOwn(config.mcp.behaviorProfiles, check.profile)) report(`Check ${name} references unregistered profile ${check.profile}. Register its literal runner command in ds.config.json; checks cannot install packages or assume host globals.`);
83
+ if (check.scenario && !scenarios.some(scenario => scenario.id === check.scenario)) report(`Check ${name} references unknown scenario ${check.scenario}.`);
84
+ const identity = JSON.stringify([check.file, check.case, check.profile]);
85
+ if (cases.has(identity)) report(`Duplicate execution identity for check ${name}. Use one check ID and reference it from several guarantees.`);
86
+ cases.add(identity);
87
+ }
88
+ contracts.push({ ...value, unit, file: relative(file), demonstrations: Object.fromEntries(Object.entries(value.subjects).filter(([, item]) => item.api).map(([name, item]) => [name, scenarios.filter(scenario => scenario.exercises?.includes(item.api)).map(scenario => scenario.id)])) });
89
+ }
90
+ return { contracts, diagnostics };
91
+ }
92
+
93
+ /** Headless ownership applies to shared controllers and explicitly named private subjects. */
94
+ export function inspectControllers(options, program, report, contracts = []) {
95
+ const { root, dsRoot, config } = options;
96
+ const resolve = importResolver(options);
97
+ const pkg = existsSync(path.join(root, "package.json")) ? JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) : {};
98
+ const declared = new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies, ...pkg.optionalDependencies }));
99
+ const privateFiles = new Map();
100
+ for (const contract of contracts) for (const subject of Object.values(contract.subjects).filter(item => item.source)) {
101
+ const file = path.resolve(root, path.dirname(contract.file), subject.source);
102
+ if (!privateFiles.has(file)) privateFiles.set(file, new Set());
103
+ privateFiles.get(file).add(subject.symbol ?? null);
104
+ }
105
+ const checker = program.getTypeChecker();
106
+ for (const source of program.getSourceFiles()) {
107
+ const file = source.fileName;
108
+ if (isDevelopmentFile(file, options) || !isInside(path.join(dsRoot, "controllers"), file) && !privateFiles.has(file)) continue;
109
+ const wholeFile = isInside(path.join(dsRoot, "controllers"), file) || privateFiles.get(file)?.has(null);
110
+ const subjects = wholeFile ? [] : checker.getSymbolsInScope(source, ts.SymbolFlags.Value).filter(symbol => privateFiles.get(file)?.has(symbol.name)).flatMap(symbol => symbol.declarations ?? []);
111
+ const headless = node => wholeFile || subjects.some(subject => subject.getSourceFile() === source && node.pos >= subject.pos && node.end <= subject.end);
112
+ function emit(node, rule, message) { const p = source.getLineAndCharacterOfPosition(node.getStart()); report(file, rule, message, p.line + 1, p.character + 1); }
113
+ function visit(node) {
114
+ let specifier;
115
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
116
+ 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;
117
+ if (specifier) {
118
+ const typeOnly = ts.isImportDeclaration(node) && (node.importClause?.isTypeOnly || node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) && !node.importClause.name && node.importClause.namedBindings.elements.every(item => item.isTypeOnly)) || ts.isExportDeclaration(node) && node.isTypeOnly;
119
+ if (!typeOnly) {
120
+ if (wholeFile && /^(?:@linaria\/|.*\.css(?:[?#].*)?$)/.test(specifier)) emit(node, "controller-skin", "Controllers own behavior and measurements. Put CSS/Linaria skins in the consuming component or pattern.");
121
+ const framework = /^(?:react|react-dom)(?:\/|$)/.test(specifier) ? "react" : /^preact(?:\/|$)/.test(specifier) ? "preact" : null;
122
+ if (framework && config.framework !== framework) emit(node, "controller-framework", `Controller imports ${framework}, but ds.config.json selects ${config.framework ?? "no framework"}. Use the configured framework or plain TypeScript/browser APIs; MCP hosts do not supply another runtime.`);
123
+ const targets = resolve(specifier, file);
124
+ const local = specifier.startsWith(".") || specifier === config.publicAlias || targets.some(target => isInside(root, target) && !target.includes(`${path.sep}node_modules${path.sep}`) && existsSync(target));
125
+ if (local) {
126
+ if (targets.some(target => !existsSync(target))) emit(node, "controller-dependency", `Unresolved controller dependency ${specifier}. Declare real project dependencies and local modules; do not assume MCP host globals.`);
127
+ if (isInside(path.join(dsRoot, "controllers"), file)) {
128
+ const targetsInUi = targets.some(target => ["components", "patterns"].some(kind => isInside(path.join(dsRoot, kind), target)));
129
+ if (targetsInUi) emit(node, "controller-dependency", "Shared controllers cannot import component/pattern runtime implementations. Pass behavior adapters or callbacks from the consumer; type-only imports are allowed.");
130
+ if (specifier === config.publicAlias) {
131
+ for (const item of node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) ? node.importClause.namedBindings.elements : []) {
132
+ let symbol = checker.getSymbolAtLocation(item.name);
133
+ if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
134
+ if (symbol?.declarations?.some(declaration => ["components", "patterns"].some(kind => isInside(path.join(dsRoot, kind), declaration.getSourceFile().fileName)))) emit(item, "controller-dependency", "Shared controllers consume behavior helpers, not styled component/pattern implementations.");
135
+ }
136
+ if (!node.importClause?.namedBindings || ts.isNamespaceImport(node.importClause.namedBindings)) emit(node, "controller-dependency", "Use explicit behavior imports in shared controllers so dependency ownership can be checked.");
137
+ }
138
+ }
139
+ } else if (!isBuiltin(specifier)) {
140
+ const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
141
+ if (!declared.has(name)) emit(node, "controller-dependency", `Undeclared controller dependency ${name}. Register it in the project package; ADS does not install dependencies or receive them from an MCP host.`);
142
+ }
143
+ }
144
+ }
145
+ if (headless(node) && ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName) && /^[a-z]/.test(node.tagName.text)
146
+ || ts.isCallExpression(node) && uiOperation(node.expression, checker) === "createElement" && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0]))) emit(node, "controller-skin", "Visual DOM markup belongs in components/patterns. Controllers may use framework hooks and context providers.");
147
+ if (!wholeFile && headless(node) && (ts.isTaggedTemplateExpression(node) || ts.isCallExpression(node))) {
148
+ let base = ts.isTaggedTemplateExpression(node) ? node.tag : node.expression;
149
+ while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) base = base.expression;
150
+ if (checker.getSymbolAtLocation(base)?.declarations?.some(declaration => ts.isImportSpecifier(declaration) && declaration.parent.parent.parent.moduleSpecifier.text.startsWith("@linaria/")
151
+ || ts.isNamespaceImport(declaration) && declaration.parent.parent.moduleSpecifier.text.startsWith("@linaria/"))) emit(node, "controller-skin", "Keep Linaria styling outside the declared private controller subject, in its visual consumer.");
152
+ }
153
+ ts.forEachChild(node, visit);
154
+ }
155
+ visit(source);
156
+ }
157
+ }
@@ -241,3 +241,10 @@ Run `npm run build` after adding or changing components. Add meaningful tests
241
241
  for stateful behavior, accessibility, gestures, or async lifecycle where needed.
242
242
  Run `npm run dev` to inspect the real showcase. A screenshot alone does not
243
243
  verify interaction or animation. Generated output and dependencies are not source.
244
+
245
+ Shared behavior may live in `controllers/<Name>/`; private controllers stay with
246
+ their component/pattern. Use the configured framework and declared dependencies.
247
+ Behavior contracts/checks are development-only, and scenario `exercises` links
248
+ public hooks to demonstrations separately from rendered `covers`. Read the
249
+ installed ADS package's `docs/BEHAVIORS.md` or MCP `ads://guides/behaviors` before
250
+ registering guarantees, profiles and verification.
@@ -90,31 +90,29 @@ function isWrite(node) {
90
90
  || ts.isDeleteExpression(parent);
91
91
  }
92
92
 
93
- // Inspects style access itself, so aliases, computed keys, cssText and bulk writes
94
- // cannot silently turn a narrow property exception into a whole-file exception.
93
+ /** Exact writes and known reads share one interpretation with exception audits.
94
+ * undefined = unrelated, false = read, null = unknown/bulk, string = exact write. */
95
+ export function styleAccessProperty(node) {
96
+ if (!isMember(node) || memberName(node) !== "style") return undefined;
97
+ const access = unwrapped(node), parent = access.parent;
98
+ if (!isMember(parent) || parent.expression !== access) return null;
99
+ const property = cssName(memberName(parent)), target = unwrapped(parent);
100
+ if (ts.isCallExpression(target.parent) && target.parent.expression === target) {
101
+ if (["set-property", "remove-property"].includes(property)) {
102
+ const name = target.parent.arguments[0];
103
+ return name && ts.isStringLiteralLike(name) ? name.text : null;
104
+ }
105
+ return ["get-property-value", "get-property-priority", "item"].includes(property) ? false : null;
106
+ }
107
+ if (isWrite(target)) return property ?? null;
108
+ return property && !["set-property", "remove-property"].includes(property) ? false : null;
109
+ }
110
+
111
+ // Aliases, computed keys, cssText and bulk writes cannot widen an exception.
95
112
  export function styleWriteViolation(node, filename, { root, config }) {
96
113
  const allowed = config.boundaries.styleWrites.find((boundary) => path.resolve(root, boundary.file) === filename)?.properties ?? [];
97
- if (isMember(node) && memberName(node) === "style") {
98
- const access = unwrapped(node);
99
- const parent = access.parent;
100
- let property;
101
- if (isMember(parent) && parent.expression === access) {
102
- property = cssName(memberName(parent));
103
- const target = unwrapped(parent);
104
- if (ts.isCallExpression(target.parent) && target.parent.expression === target) {
105
- if (["set-property", "remove-property"].includes(property)) {
106
- const name = target.parent.arguments[0];
107
- property = name && ts.isStringLiteralLike(name) ? name.text : null;
108
- } else if (["get-property-value", "get-property-priority", "item"].includes(property)) return null;
109
- else property = null;
110
- } else if (!isWrite(target)) {
111
- // Plain property reads are allowed. Passing a method reference or a
112
- // computed member elsewhere is not a known property read.
113
- if (property && !["set-property", "remove-property"].includes(property)) return null;
114
- property = null;
115
- }
116
- }
117
- if (property && property !== "css-text" && allowed.includes(property)) return null;
114
+ const property = styleAccessProperty(node);
115
+ if (property !== undefined && property !== false && !(property && property !== "css-text" && allowed.includes(property))) {
118
116
  return "Imperative styles require a file boundary and an explicit property. Bulk writes, computed property names, and style-object escapes are forbidden.";
119
117
  }
120
118
  if (ts.isCallExpression(node) && isMember(node.expression) && ["setAttribute", "setAttributeNS"].includes(memberName(node.expression))) {
package/dist/check.mjs CHANGED
@@ -14,8 +14,14 @@ import { inspectShowcasePages } from "./showcase.mjs";
14
14
  import { inspectShowcaseCoverage } from "./showcase-coverage.mjs";
15
15
  import { inspectScenarioCoverage } from "./scenarios.mjs";
16
16
  import { uiOperation, importedUiApi } from "./ui-framework.mjs";
17
+ import { inspectStyleBoundaries, pointerCaptureAdvisories } from "./style-boundaries.mjs";
18
+ import { inspectBehaviors, inspectControllers } from "./behaviors.mjs";
19
+ import { numericCssUnit, tokenLengthLiteral } from "./css-values.mjs";
17
20
  import { foundationDeclaration, foundationSelector } from "./foundation.mjs";
18
21
 
22
+ const scenarioLayoutGuidance = "Put demo-only sizing and framing in a public review/layout pattern. Keep required behavior in the runtime owner; add component props for deliberate product API needs.";
23
+ const inlineStyleGuidance = `Own visual styles in component/pattern Linaria. ${scenarioLayoutGuidance} Reserve inlineStyleFiles for documented measured geometry, not demo layout.`;
24
+
19
25
  const showcaseControls = new Set(["a", "button", "input", "select", "textarea", "option", "optgroup", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "dialog", "details", "summary", "progress", "meter"]);
20
26
  const svgTags = new Set(["svg", "g", "path", "circle", "rect", "ellipse", "line", "polyline", "polygon", "text", "tspan", "use", "defs", "clipPath", "mask", "filter", "linearGradient", "radialGradient", "stop", "marker", "pattern"]);
21
27
  const svgPresentation = new Set(["strokeWidth", "strokeLinecap", "strokeLinejoin", "strokeDasharray", "strokeDashoffset", "strokeOpacity", "strokeMiterlimit", "fillRule", "fillOpacity", "clipRule", "clipPath", "colorInterpolation", "colorInterpolationFilters", "floodColor", "floodOpacity", "stopColor", "stopOpacity", "fontFamily", "fontSize", "fontStyle", "fontWeight", "letterSpacing", "textAnchor", "dominantBaseline", "markerStart", "markerMid", "markerEnd", "vectorEffect", "shapeRendering", "textRendering", "paintOrder", "alignmentBaseline", "baselineShift", "transformOrigin"]);
@@ -24,7 +30,7 @@ const widgetRoles = new Set(["button", "link", "checkbox", "radio", "switch", "c
24
30
  export function checkProject(directory, { trackingRoot } = {}) {
25
31
  const options = { ...loadConfig(directory), ...(trackingRoot ? { trackingRoot: path.resolve(trackingRoot) } : {}) };
26
32
  const { root, config, dsRoot, sourceRoot } = options;
27
- const diagnostics = [];
33
+ const diagnostics = [], advisories = [];
28
34
  for (const [field, file] of [["dsDir", dsRoot], ["publicEntry", path.join(dsRoot, config.publicEntry)], ["stylesEntry", path.join(dsRoot, config.stylesEntry)]]) {
29
35
  if (!existsSync(file) || (field === "dsDir" ? !statSync(file).isDirectory() : !statSync(file).isFile())) diagnostics.push({ rule: "project-structure", file: path.relative(root, file), line: 1, column: 1, message: `Missing or invalid ${field}. Bootstrap an empty ADS or configure its actual path.` });
30
36
  }
@@ -36,16 +42,21 @@ export function checkProject(directory, { trackingRoot } = {}) {
36
42
  const resolveImport = importResolver(options);
37
43
  const rootHtml = path.join(root, "index.html");
38
44
  const files = [...new Set([...sourceFiles(sourceRoot), ...sourceFiles(dsRoot), ...(existsSync(rootHtml) ? [rootHtml] : [])])];
39
- const scriptFiles = files.filter((file) => /\.[cm]?[jt]sx?$/.test(file));
45
+ const boundaryFiles = [...config.inlineStyleFiles, ...config.boundaries.styleWrites.map(entry => entry.file)].map(file => path.resolve(root, file)).filter(file => existsSync(file) && statSync(file).isFile());
46
+ const scriptFiles = [...new Set([...files, ...boundaryFiles])].filter((file) => /\.[cm]?[jt]sx?$/.test(file));
40
47
  const compiler = projectCompilerOptions(options);
41
48
  const program = ts.createProgram(scriptFiles, { ...compiler, paths: { ...compiler.paths, [config.publicAlias]: [path.join(dsRoot, config.publicEntry)] }, allowJs: true, noResolve: true, noLib: true, types: [], target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.Preserve });
42
49
  const checker = program.getTypeChecker();
43
50
  function report(file, rule, message, line = 1, column = 1) {
44
51
  diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule, message, line, column });
45
52
  }
53
+ inspectStyleBoundaries(options, program, report);
46
54
  inspectShowcasePages(options, program, resolveImport, report);
47
55
  inspectShowcaseCoverage(options, program, report);
48
- inspectScenarioCoverage(options, program, report);
56
+ inspectScenarioCoverage(options, program, report, (file, rule, message) => advisories.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule, message, line: 1, column: 1 }));
57
+ const behaviors = inspectBehaviors(options, program);
58
+ diagnostics.push(...behaviors.diagnostics);
59
+ inspectControllers(options, program, report, behaviors.contracts);
49
60
  // The foundation remains a fixed import-only entry, not an extra styling layer.
50
61
  const foundationEntry = path.join(dsRoot, config.stylesEntry);
51
62
  try {
@@ -60,7 +71,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
60
71
  try { css = postcss.parse(text, { from: file }); }
61
72
  catch (error) { report(file, "css-syntax", error.reason ?? error.message, offset.line + (error.line ?? 1) - 1); return; }
62
73
  if (!foundation && (isDevelopmentFile(file, options) || !isInside(path.join(dsRoot, "components"), file) && !isInside(path.join(dsRoot, "patterns"), file) && file !== foundationEntry)) report(file, "style-ownership", "Visual CSS belongs to the local ADS's runtime components or patterns.");
63
- if (isDevelopmentFile(file, options) && css.nodes.some((node) => node.type !== "comment" && !(node.type === "atrule" && node.name === "import"))) report(file, "showcase-style", "Scenarios and fixtures define no styles. Put the frame/layout in a public ADS pattern and product UI in ADS components or patterns.", offset.line);
74
+ if (isDevelopmentFile(file, options) && css.nodes.some((node) => node.type !== "comment" && !(node.type === "atrule" && node.name === "import"))) report(file, "showcase-style", `Scenarios and fixtures define no styles. ${scenarioLayoutGuidance}`, offset.line);
64
75
  css.walkAtRules("import", (rule) => {
65
76
  if (foundation) report(file, "foundation-import", "Import foundation sheets through the configured styles entry. Token sheets cannot import an unchecked styling layer.", rule.source.start.line);
66
77
  const first = valueParser(rule.params).nodes.find((node) => node.type !== "space" && node.type !== "comment");
@@ -108,7 +119,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
108
119
  const violations = new Set();
109
120
  valueParser(decl.value).walk((node) => {
110
121
  if (node.type === "function" && node.value === "url") return false;
111
- if (node.type === "word" && /^(?:#[\da-f]{3,8}|[-+]?(?:\d*\.)?\d+(?:px|rem|em|m?s))$/i.test(node.value)) violations.add("Use a token for colors, dimensions, and durations.");
122
+ if (node.type === "word" && (/^#[\da-f]{3,8}$/i.test(node.value) || tokenLengthLiteral(node.value) || ["s", "ms"].includes(numericCssUnit(node.value)))) violations.add("Use a token for colors, absolute/font-relative lengths (including ch), and durations. Unitless geometry and structural angle/viewport/container relationships remain valid.");
112
123
  if (node.type === "function" && /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|cubic-bezier)$/.test(node.value)) violations.add("Define color formulas and easing in tokens.");
113
124
  if (node.type === "function" && /^(?:blur|brightness|contrast|drop-shadow|grayscale|hue-rotate|invert|saturate|sepia)$/.test(node.value)) violations.add("Define visual filter effects in tokens.");
114
125
  if (node.type === "word" && /^ease(?:-in|-out|-in-out)?$/.test(node.value)) violations.add("Use a motion token for easing.");
@@ -128,6 +139,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
128
139
  }
129
140
  const source = program.getSourceFile(file);
130
141
  if (!source) continue;
142
+ advisories.push(...pointerCaptureAdvisories(source).map(entry => ({ file: path.relative(root, file).replaceAll("\\", "/"), ...entry })));
131
143
  for (const diagnostic of program.getSyntacticDiagnostics(source)) {
132
144
  const position = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
133
145
  report(file, "source-syntax", ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), position.line + 1, position.character + 1);
@@ -186,14 +198,14 @@ export function checkProject(directory, { trackingRoot } = {}) {
186
198
  for (const property of properties) {
187
199
  if (!ts.isPropertyAssignment(property) || !(ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name))) continue;
188
200
  if (tag && ts.isStringLiteralLike(tag) && svgTags.has(tag.text) && importedUiApi(node.expression, checker)?.module === "preact") checkSvgAttribute(property, property.name.text);
189
- if (property.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(property, "no-inline-style", "UI factories follow the same style ownership as JSX. Use semantic ADS props or a narrow renderer boundary.");
201
+ if (property.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(property, "no-inline-style", showcase ? `Scenarios define no inline styles. ${scenarioLayoutGuidance}` : inlineStyleGuidance);
190
202
  if (property.name.text === "style") inspectProperty(property.initializer);
191
203
  if (property.name.text === "className") inspectClass(property.initializer);
192
- if (showcase && property.name.text === "className") nodeReport(property, "showcase-class", "Scenarios use public ADS props, not custom class names.");
204
+ if (showcase && property.name.text === "className") nodeReport(property, "showcase-class", `Scenarios define no custom class names. ${scenarioLayoutGuidance}`);
193
205
  }
194
206
  if (showcase && tag && ts.isStringLiteralLike(tag)) {
195
207
  const role = properties.find(property => ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) && property.name.text === "role")?.initializer;
196
- if (showcaseControls.has(tag.text) || role && ts.isStringLiteralLike(role) && widgetRoles.has(role.text)) nodeReport(node, "showcase-control", "Scenario controls must render public ADS components.");
208
+ if (showcaseControls.has(tag.text) || role && ts.isStringLiteralLike(role) && widgetRoles.has(role.text)) nodeReport(node, "showcase-control", `Scenario controls must render public ADS components for actual interaction. ${scenarioLayoutGuidance}`);
197
209
  }
198
210
  }
199
211
  if (isInside(dsRoot, file) && (ts.isIdentifier(node) && ["fetch", "XMLHttpRequest", "WebSocket", "EventSource"].includes(node.text) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node))) {
@@ -204,9 +216,9 @@ export function checkProject(directory, { trackingRoot } = {}) {
204
216
  if (showcase && (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName) && /^[a-z]/.test(node.tagName.text)) {
205
217
  const role = node.attributes.properties.find((attr) => ts.isJsxAttribute(attr) && attr.name.text === "role")?.initializer;
206
218
  const roleValue = role && ts.isStringLiteralLike(role) ? role.text : role && ts.isJsxExpression(role) && role.expression && ts.isStringLiteralLike(role.expression) ? role.expression.text : null;
207
- if (showcaseControls.has(node.tagName.text) || widgetRoles.has(roleValue)) nodeReport(node, "showcase-control", "Showcase and mock controls must use public ADS components. Implement missing reusable controls in the ADS, then import them here.");
219
+ if (showcaseControls.has(node.tagName.text) || widgetRoles.has(roleValue)) nodeReport(node, "showcase-control", `Scenario controls must render public ADS components for actual interaction. ${scenarioLayoutGuidance}`);
208
220
  }
209
- if (showcase && ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "className") nodeReport(node, "showcase-class", "Showcase and mocks use known public ADS entities and props, not custom class names. Put styling in the owning ADS component or frame pattern.");
221
+ if (showcase && ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "className") nodeReport(node, "showcase-class", `Scenarios define no custom class names. ${scenarioLayoutGuidance}`);
210
222
  let specifier;
211
223
  if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
212
224
  if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) specifier = node.arguments[0].text;
@@ -214,13 +226,13 @@ export function checkProject(directory, { trackingRoot } = {}) {
214
226
  if (specifier && resolveImport(specifier, file).some((target) => (specifier.startsWith(".") || existsSync(target)) && target.endsWith(".css") && isInside(root, target) && !isInside(dsRoot, target) && target !== path.join(sourceRoot, "app-mount.css") && !isInside(path.join(root, "node_modules"), target))) nodeReport(node, "style-ownership", "Imported project CSS belongs to ADS components, patterns, or the foundation. Only structural app-mount.css is allowed outside the ADS.");
215
227
  if (specifier && appImportViolation(specifier, file, options, resolveImport)) nodeReport(node, "no-app-import", "ADS implementations and showcase fixtures must not import app code. Use public ADS APIs with local fixture data; keep application integration harnesses in application tests.");
216
228
  if (specifier && showcaseImportViolation(specifier, file, options, resolveImport)) nodeReport(node, "showcase-import", "Showcase code and styles are development fixtures; runtime code must not import them.");
217
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(node, "no-inline-style", "Move visual policy into the local ADS, or document a narrow measured-geometry exception in inlineStyleFiles.");
229
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(node, "no-inline-style", showcase ? `Scenarios define no inline styles. ${scenarioLayoutGuidance}` : inlineStyleGuidance);
218
230
  if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "style" && node.initializer) {
219
231
  inspectProperty(node.initializer);
220
232
  }
221
233
  const mutation = styleWriteViolation(node, file, options);
222
234
  if (mutation) nodeReport(node, "style-mutation", mutation);
223
- if (!inlineAllowed && isUiCssProperties(node, checker)) nodeReport(node, "css-properties", "Do not expose CSSProperties-based visual plumbing. Use semantic props, or document a narrow JSX renderer boundary.");
235
+ if (!inlineAllowed && isUiCssProperties(node, checker)) nodeReport(node, "css-properties", "Keep CSSProperties out of the public API. Use deliberate component contracts or a documented measured-geometry renderer boundary; demo layout belongs in a public review pattern.");
224
236
  if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "className" && node.initializer) {
225
237
  inspectClass(node.initializer);
226
238
  }
@@ -234,5 +246,5 @@ export function checkProject(directory, { trackingRoot } = {}) {
234
246
  }
235
247
  visit(source);
236
248
  }
237
- return { ok: diagnostics.length === 0, diagnostics };
249
+ return { ok: diagnostics.length === 0, diagnostics, ...(advisories.length ? { advisories } : {}) };
238
250
  }
package/dist/cli.mjs CHANGED
@@ -11,6 +11,8 @@ Usage: ads init [options] Run inside your project
11
11
 
12
12
  init Set up ADS in the current project (requires package.json)
13
13
  check [directory] Check tokens, ownership, imports, and showcase coverage
14
+ behaviors [dir] Inspect behavior contracts and current verification evidence
15
+ verify [directory] Run registered behavior checks (--unit and --check select)
14
16
  tokens [directory] Inspect local token values and theme definitions
15
17
  assets [directory] Generate standard web icons in .ds/assets from assets.iconSource
16
18
  lint-config [dir] Generate .ds/oxlintrc.json with core and project plugins
@@ -25,6 +27,8 @@ Usage: ads init [options] Run inside your project
25
27
  --port number MCP/showcase only: loopback website port (default: automatic)
26
28
  --no-showcase MCP only: disable the local website
27
29
  --mutations MCP only: enable guarded ADS source editing
30
+ --unit id Verify only: select one discovered behavior contract owner
31
+ --check id Verify only: select a registered check (repeatable)
28
32
 
29
33
  External components and examples are imported using the ADS skills.
30
34
  No component library or example catalogue is bundled.
@@ -38,6 +42,8 @@ async function main(args) {
38
42
  let packageManager;
39
43
  let framework;
40
44
  let port;
45
+ let unit;
46
+ const selectedChecks = [];
41
47
  let showcase = true;
42
48
  let mutations = false;
43
49
  const positional = [];
@@ -46,6 +52,12 @@ async function main(args) {
46
52
  if (argument === "--json") json = true;
47
53
  else if (argument === "--no-showcase") showcase = false;
48
54
  else if (argument === "--mutations") mutations = true;
55
+ else if (argument === "--unit" || argument === "--check") {
56
+ const value = args[++index];
57
+ if (!value || value.startsWith("-")) throw new Error(`${argument} needs a discovered unit/check ID.`);
58
+ if (argument === "--unit") { if (unit) throw new Error("Specify one --unit."); unit = value; }
59
+ else selectedChecks.push(value);
60
+ }
49
61
  else if (argument === "--port") {
50
62
  const value = args[++index];
51
63
  if (port !== undefined || !/^\d+$/.test(value ?? "") || Number(value) > 65535) throw new Error("--port requires one port between 0 and 65535.");
@@ -72,6 +84,7 @@ async function main(args) {
72
84
  if (packageManager !== undefined && command !== "init") throw new Error("--package-manager is supported only by init.");
73
85
  if (framework !== undefined && command !== "init") throw new Error("--framework is supported only by init.");
74
86
  if (command === "init" && directory) throw new Error("Run ads init inside the project, without a directory argument.");
87
+ if ((unit || selectedChecks.length) && command !== "verify") throw new Error("--unit and --check are supported only by verify.");
75
88
  const target = path.resolve(directory ?? process.cwd());
76
89
  if (command === "mcp" || command === "showcase") {
77
90
  if (json) throw new Error("--json is not supported by mcp/showcase; mcp reserves stdout for JSON-RPC.");
@@ -82,6 +95,8 @@ async function main(args) {
82
95
  let result;
83
96
  if (command === "init") result = initProject(target, { toolPackage, packageManager, framework });
84
97
  else if (command === "check") result = checkProject(target);
98
+ else if (command === "behaviors") { const {behaviorStatus}=await import("./verification.mjs"); const {loadConfig}=await import("./config.mjs"); result=behaviorStatus(loadConfig(target)); }
99
+ else if (command === "verify") { const {createVerificationController}=await import("./verification.mjs"); const verification=createVerificationController(target); try {result=await verification.run({...(unit?{unit}:{}),...(selectedChecks.length?{checks:selectedChecks}:{})});} finally {await verification.close();} }
85
100
  else if (command === "tokens") result = inspectTokens(target);
86
101
  else if (command === "assets") result = await generateBrandAssets(target);
87
102
  else if (command === "lint-config") result = generateLintConfig(target);
@@ -90,12 +105,15 @@ async function main(args) {
90
105
  else if (command === "init") console.log(`Initialized ${target}\nFramework: ${result.framework}\nAdded ${result.toolDependency.name} (${result.toolDependency.spec}) as a development dependency.\nRun ${result.packageManager} install and ${result.packageManager} run build; the build refreshes lint configuration, runs ADS checks, and generates configured brand assets automatically.`);
91
106
  else if (command === "assets") console.log(result.skipped ? "No assets.iconSource configured; brand generation skipped." : `Generated ${result.files.length} brand assets in ${result.directory}.`);
92
107
  else if (command === "lint-config") console.log(`Generated ${result.file}\nProjects initialized with ads init refresh this automatically during lint:ds/build.`);
108
+ else if (command === "behaviors") console.log(JSON.stringify(result, null, 2));
109
+ else if (command === "verify") { for (const item of result.results) console.log(`${item.unit}#${item.check}: ${item.status} (${item.profile})`); }
93
110
  else if (command === "tokens") {
94
111
  for (const token of result.tokens) console.log(`${token.name}${token.type ? ` (${token.type})` : ""}: ${token.definitions.map((entry) => `${entry.scope}: ${entry.value}`).join("; ")}`);
95
112
  } else if (result.ok) console.log(`ADS check passed: ${target}`);
96
113
  if (!json && result.diagnostics?.length) {
97
114
  for (const diagnostic of result.diagnostics) console.error(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.rule}] ${diagnostic.message}`);
98
115
  }
116
+ if (!json) for (const advisory of result.advisories ?? []) console.error(`${advisory.file}:${advisory.line}:${advisory.column} [advisory:${advisory.rule}] ${advisory.message}`);
99
117
  if (result.ok === false || result.diagnostics?.length) process.exitCode = 1;
100
118
  }
101
119
 
package/dist/config.mjs CHANGED
@@ -17,7 +17,7 @@ export const defaultConfig = {
17
17
  boundaries: { styleWrites: [], componentVariables: [] },
18
18
  assets: { publicDirs: ["public/assets"], generatedPaths: ["dist", "dist-showcase", ".vite", ".ds"], generatedFiles: [], iconSource: null },
19
19
  oxlint: { plugins: [], rules: {}, overrides: [] },
20
- mcp: { showcaseUrl: null, showcaseTitle: null, apps: [], previewWatch: null, previewManifest: null, mutationChecks: null },
20
+ mcp: { showcaseUrl: null, showcaseTitle: null, apps: [], previewWatch: null, previewManifest: null, mutationChecks: null, behaviorProfiles: {} },
21
21
  };
22
22
 
23
23
  export function isInside(parent, file) {
@@ -100,6 +100,18 @@ export function loadConfig(directory = process.cwd()) {
100
100
  }
101
101
  if (config.mcp.showcaseTitle !== null && (typeof config.mcp.showcaseTitle !== "string" || !config.mcp.showcaseTitle.trim() || config.mcp.showcaseTitle.length > 120)) throw new Error("mcp.showcaseTitle must be a non-empty title of at most 120 characters or null.");
102
102
  if (!Array.isArray(config.mcp.apps)) throw new Error("mcp.apps must be an array of built project previews.");
103
+ if (!config.mcp.behaviorProfiles || typeof config.mcp.behaviorProfiles !== "object" || Array.isArray(config.mcp.behaviorProfiles) || Object.keys(config.mcp.behaviorProfiles).length > 20) throw new Error("mcp.behaviorProfiles must be an object of up to 20 registered execution profiles.");
104
+ for (const [name, profile] of Object.entries(config.mcp.behaviorProfiles)) {
105
+ if (!/^[a-zA-Z0-9][\w.-]{0,79}$/.test(name) || !profile || typeof profile !== "object" || Array.isArray(profile)
106
+ || Object.keys(profile).some(key => !["command", "args", "timeoutMs", "runner", "framework", "mode"].includes(key))
107
+ || typeof profile.command !== "string" || !profile.command.trim() || profile.command.includes("\0")
108
+ || !Array.isArray(profile.args) || profile.args.some(arg => typeof arg !== "string" || arg.includes("\0"))
109
+ || !["protocol", "playwright"].includes(profile.runner ?? "protocol")
110
+ || profile.framework !== undefined && profile.framework !== config.framework
111
+ || profile.mode !== undefined && !["browser", "strict", "unit"].includes(profile.mode)
112
+ || profile.timeoutMs !== undefined && (!Number.isInteger(profile.timeoutMs) || profile.timeoutMs < 100 || profile.timeoutMs > 300000)) throw new Error(`Invalid behavior profile ${name}. Use literal executable/args, a protocol or Playwright runner, a matching framework and a bounded timeout. Profiles do not install dependencies or receive APIs from an MCP host.`);
113
+ if (profile.mode === "strict" && config.framework !== "react") throw new Error(`Behavior profile ${name}: React StrictMode requires framework react; use the configured framework's lifecycle tests instead.`);
114
+ }
103
115
  if (config.mcp.mutationChecks !== null && (!Array.isArray(config.mcp.mutationChecks) || config.mcp.mutationChecks.length > 12 || new Set(config.mcp.mutationChecks).size !== config.mcp.mutationChecks.length || config.mcp.mutationChecks.some(name => typeof name !== "string" || !/^[a-zA-Z0-9][\w:.-]*$/.test(name)))) throw new Error("mcp.mutationChecks must be null or up to 12 unique package script names.");
104
116
  if (config.mcp.previewManifest !== null) {
105
117
  const manifest = projectPath(root, config.mcp.previewManifest, "mcp.previewManifest");
@@ -136,10 +148,14 @@ export function isFixtureFile(file) {
136
148
  }
137
149
 
138
150
  /** File role, rather than folder location, keeps colocated preview code private. */
151
+ export function isBehaviorFile(file) {
152
+ return /(?:^|[\\/])behavior\.json$|\.behavior\.json$/.test(file);
153
+ }
154
+
139
155
  export function isDevelopmentFile(file, { dsRoot }) {
140
156
  if (!isInside(dsRoot, file)) return isTestFile(file);
141
157
  const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
142
- return isTestFile(file) || isScenarioFile(file) || isFixtureFile(file)
158
+ return isTestFile(file) || isScenarioFile(file) || isFixtureFile(file) || isBehaviorFile(file)
143
159
  || /^(?:showcase|composition)\//.test(relative)
144
160
  || /(?:^|\/)(?:scenario|fixtures)(?:\/|$)/.test(relative)
145
161
  || /\.scenario(?:-adapter)?\.[cm]?[jt]sx?$/.test(relative);
@@ -154,7 +170,7 @@ export function sourceFiles(root) {
154
170
  if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
155
171
  const file = path.join(root, entry.name);
156
172
  if (entry.isDirectory()) files.push(...sourceFiles(file));
157
- else if (entry.isFile() && /\.(css|html|[cm]?[jt]sx?)$/.test(entry.name)) files.push(file);
173
+ else if (entry.isFile() && (/\.(css|html|[cm]?[jt]sx?)$/.test(entry.name) || isBehaviorFile(entry.name))) files.push(file);
158
174
  }
159
175
  return files.sort();
160
176
  }
@@ -0,0 +1,26 @@
1
+ import valueParser from "postcss-value-parser";
2
+
3
+ // CSS-language categories, independent of any project's token names or values.
4
+ // https://www.w3.org/TR/css-values-4/#lengths
5
+ const absoluteLengths = new Set(["px", "cm", "mm", "q", "in", "pt", "pc"]);
6
+ const fontLengths = new Set(["em", "rem", "ex", "rex", "cap", "rcap", "ch", "rch", "ic", "ric", "lh", "rlh"]);
7
+ const responsiveLength = /^(?:[sld]?v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max))$/;
8
+
9
+ export function numericCssUnit(value) {
10
+ const parsed = valueParser.unit(value);
11
+ return parsed ? parsed.unit.toLowerCase() : null;
12
+ }
13
+
14
+ /** Skin lengths use tokens regardless of font metrics, physical units or notation.
15
+ * Responsive viewport/container relationships retain their structural treatment. */
16
+ export function tokenLengthLiteral(value) {
17
+ const unit = numericCssUnit(value);
18
+ return absoluteLengths.has(unit) || fontLengths.has(unit);
19
+ }
20
+
21
+ /** The same CSS categories apply to length-valued foundation tokens. */
22
+ export function dimensionValue(value) {
23
+ const unit = numericCssUnit(value);
24
+ return unit !== null && (absoluteLengths.has(unit) || fontLengths.has(unit)
25
+ || responsiveLength.test(unit) || unit === "%" || unit === "" && Number(value) === 0);
26
+ }
package/dist/index.mjs CHANGED
@@ -7,3 +7,5 @@ export { generateBrandAssets } from "./brand-assets.mjs";
7
7
  // same validated configuration and property boundary as the core checker.
8
8
  export { loadConfig } from "./config.mjs";
9
9
  export { importResolver, styleWriteViolation } from "./boundaries.mjs";
10
+ export { inspectBehaviors } from "./behaviors.mjs";
11
+ export { behaviorStatus, createVerificationController } from "./verification.mjs";
package/dist/init.mjs CHANGED
@@ -183,7 +183,7 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
183
183
  }
184
184
  updates.set(entryFile, current + "\n" + reviewExport);
185
185
  }
186
- for (const section of ["components", "patterns", "composition"]) addMissing(path.join(dsRoot, section, ".gitkeep"), "");
186
+ for (const section of ["components", "patterns", "controllers", "composition"]) addMissing(path.join(dsRoot, section, ".gitkeep"), "");
187
187
  addMissing(path.join(dsRoot, "assets/brand/README.md"), readFileSync(path.join(starter, "design-system/assets/brand/README.md")));
188
188
  addMissing(path.join(dsRoot, "assets/fonts/.gitkeep"), "");
189
189
  const configFile = path.join(root, "ds.config.json");