@reqvire-org/ads 0.3.6 → 0.3.8

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.
@@ -0,0 +1,214 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import postcss from "postcss";
4
+ import valueParser from "postcss-value-parser";
5
+ import { isInside } from "./config.mjs";
6
+ import { createAtomicTrace } from "./atomic-trace.mjs";
7
+
8
+ export const atomicStyleGuide = "ads://guides/atomic-styles";
9
+
10
+ const sides = ["top", "right", "bottom", "left"];
11
+ const logicalSides = ["block-start", "block-end", "inline-start", "inline-end"];
12
+ const intersects = (a, b) => a.some(value => b.includes(value));
13
+ const previewValue = value => value.length > 200 ? value.slice(0, 197) + "..." : value;
14
+ const shorthands = {
15
+ background: ["background-attachment", "background-clip", "background-color", "background-image", "background-origin", "background-position", "background-repeat", "background-size"],
16
+ "background-position": ["background-position-x", "background-position-y"],
17
+ "background-repeat": ["background-repeat-x", "background-repeat-y"],
18
+ font: ["font-family", "font-size", "font-stretch", "font-style", "font-variant", "font-weight", "line-height"],
19
+ animation: ["animation-name", "animation-duration", "animation-timing-function", "animation-delay", "animation-iteration-count", "animation-direction", "animation-fill-mode", "animation-play-state"],
20
+ transition: ["transition-property", "transition-duration", "transition-timing-function", "transition-delay", "transition-behavior"],
21
+ flex: ["flex-grow", "flex-shrink", "flex-basis"],
22
+ "flex-flow": ["flex-direction", "flex-wrap"],
23
+ gap: ["row-gap", "column-gap"],
24
+ outline: ["outline-color", "outline-style", "outline-width"],
25
+ columns: ["column-count", "column-width"],
26
+ "column-rule": ["column-rule-color", "column-rule-style", "column-rule-width"],
27
+ "list-style": ["list-style-image", "list-style-position", "list-style-type"],
28
+ "text-decoration": ["text-decoration-color", "text-decoration-style", "text-decoration-line", "text-decoration-thickness"],
29
+ };
30
+ const expand = property => shorthands[property]?.flatMap(expand) ?? [property];
31
+ const longhands = new Map(Object.keys(shorthands).map(property => [property, expand(property)]));
32
+
33
+ // CSS-language relationships, independent of components, tokens and frameworks.
34
+ function footprint(property) {
35
+ const box = /^(padding|margin|scroll-padding|scroll-margin|inset)(?:-(top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?$/.exec(property);
36
+ if (box) {
37
+ const side = box[2];
38
+ const logical = !!side && !sides.includes(side);
39
+ return { family: box[1], logical, slots: !side ? logicalSides : /^(block|inline)$/.test(side) ? [`${side}-start`, `${side}-end`] : [side], whole: !side };
40
+ }
41
+ if (sides.includes(property)) return { family: "inset", logical: false, slots: [property] };
42
+ const border = /^border(?:-(top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?(?:-(color|style|width))?$/.exec(property);
43
+ if (border) {
44
+ const side = border[1];
45
+ return { family: "border", logical: !!side && !sides.includes(side), slots: !side ? logicalSides : /^(block|inline)$/.test(side) ? [`${side}-start`, `${side}-end`] : [side], whole: !side, fields: border[2] ? [border[2]] : ["color", "style", "width"] };
46
+ }
47
+ const corner = /^border-(?:(top-left|top-right|bottom-left|bottom-right|start-start|start-end|end-start|end-end)-)?radius$/.exec(property);
48
+ if (corner) return { family: "radius", logical: /^(start|end)-/.test(corner[1] ?? ""), whole: !corner[1], slots: [corner[1]] };
49
+ const dimension = /^(min-|max-)?(width|height|inline-size|block-size)$/.exec(property);
50
+ if (dimension) return { family: `${dimension[1] ?? ""}size`, logical: dimension[2].endsWith("-size"), slots: [dimension[2]] };
51
+ const overflow = /^(overflow|overscroll-behavior)(?:-(x|y|block|inline))?$/.exec(property);
52
+ if (overflow) return { family: overflow[1], logical: ["block", "inline"].includes(overflow[2]), whole: !overflow[2], slots: [overflow[2]] };
53
+ return null;
54
+ }
55
+
56
+ function overlaps(a, b) {
57
+ if (a === b || a.startsWith("--") || b.startsWith("--")) return false;
58
+ if ([a, b].includes("all")) return ![a, b].some(value => ["direction", "unicode-bidi"].includes(value));
59
+ const first = footprint(a), second = footprint(b);
60
+ if (first && second && first.family === second.family) {
61
+ if (first.fields && !intersects(first.fields, second.fields)) return false;
62
+ return first.whole || second.whole || first.logical !== second.logical || intersects(first.slots, second.slots);
63
+ }
64
+ return intersects(longhands.get(a) ?? [a], longhands.get(b) ?? [b]);
65
+ }
66
+
67
+ const modes = ["horizontal-tb", "vertical-rl", "vertical-lr"].flatMap(mode => ["ltr", "rtl"].map(direction => ({ mode, direction })));
68
+ function physicalSlots(property, { mode, direction }) {
69
+ const value = footprint(property);
70
+ if (!value) return null;
71
+ if (value.whole) return sides;
72
+ const horizontal = mode === "horizontal-tb";
73
+ const axes = {
74
+ "block-start": horizontal ? "top" : mode === "vertical-rl" ? "right" : "left",
75
+ "block-end": horizontal ? "bottom" : mode === "vertical-rl" ? "left" : "right",
76
+ "inline-start": horizontal ? direction === "ltr" ? "left" : "right" : direction === "ltr" ? "top" : "bottom",
77
+ "inline-end": horizontal ? direction === "ltr" ? "right" : "left" : direction === "ltr" ? "bottom" : "top",
78
+ "inline-size": horizontal ? "width" : "height", "block-size": horizontal ? "height" : "width",
79
+ inline: horizontal ? "x" : "y", block: horizontal ? "y" : "x",
80
+ };
81
+ return value.slots.map(slot => value.family === "radius" && slot ? (/^(start|end)-/.test(slot) ? [axes[`block-${slot.split("-")[0]}`], axes[`inline-${slot.split("-")[1]}`]] : slot.split("-")).sort().join("-") : axes[slot] ?? slot);
82
+ }
83
+ function writingModeDependent(a, b) {
84
+ const first = footprint(a), second = footprint(b);
85
+ if (!first || !second || first.family !== second.family || first.whole || second.whole || first.logical === second.logical) return false;
86
+ const matches = modes.map(mode => intersects(physicalSlots(a, mode), physicalSlots(b, mode)));
87
+ return matches.some(Boolean) && !matches.every(Boolean);
88
+ }
89
+ function words(value) {
90
+ const nodes = valueParser(value).nodes;
91
+ return nodes.every(node => ["word", "space", "comment"].includes(node.type)) ? nodes.filter(node => node.type === "word").map(node => node.value.toLowerCase()) : null;
92
+ }
93
+ function boxValues(entry, mode) {
94
+ const value = footprint(entry.property), parts = words(entry.value);
95
+ if (!value || !["padding", "margin", "scroll-padding", "scroll-margin", "inset"].includes(value.family) || !parts?.length || parts.length > 4 || parts.some(part => ["inherit", "initial", "unset", "revert", "revert-layer"].includes(part))) return null;
96
+ const slots = physicalSlots(entry.property, mode);
97
+ if (!value.whole && parts.length > slots.length) return null;
98
+ const expanded = value.whole ? [parts[0], parts[1] ?? parts[0], parts[2] ?? parts[0], parts[3] ?? parts[1] ?? parts[0]] : slots.map((_slot, index) => parts[index] ?? parts[0]);
99
+ return new Map(slots.map((slot, index) => [slot, expanded[index]]));
100
+ }
101
+ function equivalent(a, b) {
102
+ // Only suppress values whose overlap can be established from literal source.
103
+ // var(), calc(), inheritance and token aliases still need rendered review.
104
+ const first = footprint(a.property), second = footprint(b.property);
105
+ if (first && second && first.family === second.family && ["padding", "margin", "scroll-padding", "scroll-margin", "inset"].includes(first.family)) {
106
+ return modes.every(mode => {
107
+ const left = boxValues(a, mode), right = boxValues(b, mode);
108
+ return left && right && [...left].every(([slot, value]) => !right.has(slot) || right.get(slot) === value);
109
+ });
110
+ }
111
+ const flex = a.property === "flex" ? a : b.property === "flex" ? b : null;
112
+ const field = flex === a ? b : a;
113
+ if (flex && ["flex-grow", "flex-shrink", "flex-basis"].includes(field.property)) {
114
+ const parts = words(flex.value);
115
+ return parts?.length === 3 && /^\d+(?:\.\d+)?$/.test(parts[0]) && /^\d+(?:\.\d+)?$/.test(parts[1]) && /^(?:auto|0|\d+(?:\.\d+)?(?:px|rem|em|%))$/.test(parts[2]) && parts[["flex-grow", "flex-shrink", "flex-basis"].indexOf(field.property)] === field.value.trim().toLowerCase();
116
+ }
117
+ return false;
118
+ }
119
+
120
+ function valueModeDependent(a, b) {
121
+ const equal = modes.map(mode => {
122
+ const left = boxValues(a, mode), right = boxValues(b, mode);
123
+ return left && right ? [...left].every(([slot, value]) => !right.has(slot) || right.get(slot) === value) : null;
124
+ });
125
+ return !equal.includes(null) && equal.some(Boolean) && !equal.every(Boolean);
126
+ }
127
+
128
+ function unwrap(node) {
129
+ while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
130
+ return node;
131
+ }
132
+
133
+ /** Review only: valid CSS can intentionally overlap. Never rewrite project CSS. */
134
+ export function createAtomicCompositionReview(program, options) {
135
+ const checker = program.getTypeChecker(), templates = new Map();
136
+ function imported(node, names, modules, seen = new Set()) {
137
+ node = unwrap(node);
138
+ if (!node || seen.has(node)) return false;
139
+ seen = new Set([...seen, node]);
140
+ const base = ts.isPropertyAccessExpression(node) ? node.expression : node;
141
+ const symbol = checker.getSymbolAtLocation(base);
142
+ for (const item of symbol?.declarations ?? []) {
143
+ if (ts.isImportSpecifier(item) && names.includes((item.propertyName ?? item.name).text) && modules.includes(item.parent.parent.parent.moduleSpecifier.text)) return true;
144
+ if (ts.isNamespaceImport(item) && ts.isPropertyAccessExpression(node) && names.includes(node.name.text) && modules.includes(item.parent.parent.moduleSpecifier.text)) return true;
145
+ if (ts.isVariableDeclaration(item) && imported(item.initializer, names, modules, seen)) return true;
146
+ }
147
+ return false;
148
+ }
149
+ const isCx = node => imported(node, ["cx"], ["@linaria/atomic", "@linaria/core"]);
150
+ function isAtomic(tag) {
151
+ if (ts.isCallExpression(tag)) tag = tag.expression;
152
+ if (imported(tag, ["css", "styled"], ["@linaria/atomic"])) return true;
153
+ return ts.isPropertyAccessExpression(tag) && imported(tag.expression, ["styled"], ["@linaria/atomic"]);
154
+ }
155
+ function entries(node) {
156
+ if (templates.has(node)) return templates.get(node);
157
+ const result = [];
158
+ templates.set(node, result);
159
+ const source = node.getSourceFile(), template = node.template;
160
+ const text = ts.isNoSubstitutionTemplateLiteral(template) ? template.text : template.head.text + template.templateSpans.map(span => `__ads_expression__${"\n".repeat(span.expression.getText(source).split("\n").length - 1)}${span.literal.text}`).join("");
161
+ const position = source.getLineAndCharacterOfPosition(template.getStart(source) + 1);
162
+ try {
163
+ postcss.parse(text).walkDecls(decl => {
164
+ const selectors = [], conditions = [];
165
+ for (let parent = decl.parent; parent?.type !== "root"; parent = parent?.parent) {
166
+ if (!parent) break;
167
+ if (parent.type === "rule") selectors.unshift(parent.selector.trim().replace(/\s+/g, " "));
168
+ if (parent.type === "atrule") conditions.unshift(`${parent.name} ${parent.params}`.trim().replace(/\s+/g, " "));
169
+ }
170
+ // Animation frames describe different instants, not combined classes.
171
+ if (conditions.some(condition => /^(?:-\w+-)?keyframes\b/.test(condition))) return;
172
+ result.push({ property: decl.prop.toLowerCase(), value: decl.value, important: !!decl.important, selectors: JSON.stringify(selectors), conditions: JSON.stringify(conditions),
173
+ file: path.relative(options.root, source.fileName).replaceAll("\\", "/"), line: position.line + decl.source.start.line, column: decl.source.start.column + (decl.source.start.line === 1 ? position.character : 0) });
174
+ });
175
+ } catch { /* The core CSS syntax diagnostic owns malformed templates. */ }
176
+ return result;
177
+ }
178
+ const trace = createAtomicTrace(program, options, { entries, isAtomic, isCx });
179
+ return source => {
180
+ const warnings = new Map(), limits = new Map();
181
+ const relative = entry => ({ ...entry, file: path.relative(options.root, entry.file).replaceAll("\\", "/") });
182
+ function compare(first, second, location, context = {}) {
183
+ for (const a of first) for (const b of second) {
184
+ if (a === b || a.selectors !== b.selectors || a.conditions !== b.conditions || a.important !== b.important || !overlaps(a.property, b.property) || equivalent(a, b)) continue;
185
+ const key = JSON.stringify([a, b].map(entry => [entry.file, entry.line, entry.column]).sort());
186
+ const composition = [...new Map([...(context.path ?? []), ...(a.via ?? []), ...(b.via ?? [])].map(entry => [JSON.stringify(entry), relative(entry)])).values()].slice(0, 16);
187
+ const previous = warnings.get(key);
188
+ if (previous && (previous.target || !context.target) && previous.composition.length >= composition.length) continue;
189
+ const position = source.getLineAndCharacterOfPosition(location.getStart(source));
190
+ const dependent = writingModeDependent(a.property, b.property) || valueModeDependent(a, b);
191
+ warnings.set(key, { rule: "atomic-composition-review", severity: "warning", line: position.line + 1, column: position.character + 1,
192
+ message: `Atomic declarations ${a.property}: ${previewValue(a.value)} and ${b.property}: ${previewValue(b.value)} may overlap without matching cx merge keys.${context.target ? ` Source tracing reaches the same <${context.target.name}> className.` : " This is a source composition risk, not a confirmed rendered conflict."}${dependent ? " The overlap depends on writing mode or direction." : ""} Their winner can depend on extraction order, specificity or writing mode. Use consistent property names/granularity across the base and variants, compose with Linaria cx, and verify computed styles in the built preview. Review all affected variants; valid tokens do not establish cascade correctness. See ${atomicStyleGuide}.`,
193
+ guidanceUri: atomicStyleGuide, writingModeDependent: dependent, composition, ...(context.target ? { target: relative(context.target) } : {}), ...(context.limits?.length ? { analysisLimits: [...new Set(context.limits)].slice(0, 5) } : {}),
194
+ related: [a, b].map(({ selectors: _selectors, conditions: _conditions, important: _important, via: _via, ...entry }) => ({...entry,value:previewValue(entry.value)})) });
195
+ }
196
+ }
197
+ function limit(node, reasons, composition) {
198
+ const key = reasons.join("\n");
199
+ if (limits.has(key) || limits.size >= 5) return;
200
+ const position = source.getLineAndCharacterOfPosition(node.getStart(source));
201
+ limits.set(key, { rule: "atomic-composition-limit", severity: "info", line: position.line + 1, column: position.character + 1,
202
+ message: `Atomic source review is incomplete: ${[...new Set(reasons)].slice(0, 3).join(" ")} Review the actual DOM classes and computed styles in a built scenario. An unresolved path is not evidence that composition is safe. See ${atomicStyleGuide}.`, guidanceUri: atomicStyleGuide, composition: composition.slice(0, 16).map(relative) });
203
+ }
204
+ function visit(node) {
205
+ if (ts.isTaggedTemplateExpression(node) && isAtomic(node.tag)) {
206
+ const declarations = entries(node);
207
+ for (let index = 0; index < declarations.length; index++) compare([declarations[index]], declarations.slice(index + 1), node);
208
+ }
209
+ ts.forEachChild(node, visit);
210
+ }
211
+ if (isInside(options.dsRoot, source.fileName)) { visit(source); trace(source, { compare, limit }); }
212
+ return [...warnings.values(), ...limits.values()];
213
+ };
214
+ }
@@ -0,0 +1,287 @@
1
+ import ts from "typescript";
2
+ import { isInside } from "./config.mjs";
3
+ import { uiOperation } from "./ui-framework.mjs";
4
+
5
+ const unknown = reason => ({ kind: "unknown", reason });
6
+ const literal = value => ({ kind: "literal", value });
7
+ const reference = (node, env = new Map(), via = []) => node ? { kind: "node", node, env, via } : literal(undefined);
8
+ const union = values => values.length === 1 ? values[0] : { kind: "union", values };
9
+ const isFunction = node => node && (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node));
10
+ const nameOf = node => node && (ts.isIdentifier(node) || ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) ? node.text : undefined;
11
+ function unwrap(node) {
12
+ while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
13
+ return node;
14
+ }
15
+
16
+ /** Bounded symbolic source traversal. No project code is imported or executed. */
17
+ export function createAtomicTrace(program, options, { entries, isAtomic, isCx }) {
18
+ const checker = program.getTypeChecker();
19
+ const symbol = node => checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
20
+ function declaration(node) {
21
+ let value = symbol(node);
22
+ if (value?.flags & ts.SymbolFlags.Alias) value = checker.getAliasedSymbol(value);
23
+ return value?.valueDeclaration;
24
+ }
25
+ const state = () => ({ budget: { steps: 0 }, depth: 0, calls: new Set() });
26
+ function next(context) { return { ...context, depth: context.depth + 1 }; }
27
+ function exhausted(context) { return ++context.budget.steps > 4000 || context.depth > 48; }
28
+ function at(node, kind, name) {
29
+ const source = node.getSourceFile(), position = source.getLineAndCharacterOfPosition(node.getStart(source));
30
+ return { file: source.fileName, line: position.line + 1, column: position.character + 1, kind, name: name.slice(0, 100) };
31
+ }
32
+ function withVia(value, via) {
33
+ if (value.kind === "union") return { ...value, values: value.values.map(item => withVia(item, via)) };
34
+ return value.kind === "node" ? { ...value, via: [...new Map([...via, ...value.via].map(item => [JSON.stringify(item), item])).values()].slice(0, 16) } : value;
35
+ }
36
+ function withDefault(value, fallback, context) {
37
+ if (!fallback) return value;
38
+ value = resolve(value, next(context));
39
+ if (value.kind === "literal") return value.value === undefined ? fallback : value;
40
+ if (value.kind === "unknown") return union([value, fallback]);
41
+ if (value.kind === "union") return union(value.values.map(item => withDefault(item, fallback, next(context))));
42
+ return value;
43
+ }
44
+ function bind(pattern, value, env, via, context) {
45
+ if (ts.isIdentifier(pattern)) { env.set(symbol(pattern), value); return; }
46
+ if (ts.isObjectBindingPattern(pattern)) {
47
+ const excluded = new Set();
48
+ for (const part of pattern.elements) {
49
+ const key = nameOf(part.propertyName ?? part.name);
50
+ if (part.dotDotDotToken) bind(part.name, { kind: "rest", value, excluded: new Set(excluded) }, env, via, next(context));
51
+ else {
52
+ if (key === undefined) continue;
53
+ excluded.add(key);
54
+ bind(part.name, withDefault(get(value, key, next(context)), part.initializer && reference(part.initializer, env, via), context), env, via, next(context));
55
+ }
56
+ }
57
+ } else if (ts.isArrayBindingPattern(pattern)) {
58
+ pattern.elements.forEach((part, index) => {
59
+ if (ts.isBindingElement(part) && !part.dotDotDotToken) bind(part.name, withDefault(get(value, String(index), next(context)), part.initializer && reference(part.initializer, env, via), context), env, via, next(context));
60
+ });
61
+ }
62
+ }
63
+ function boundElement(node, env, via, context) {
64
+ let owner = node;
65
+ while (ts.isBindingElement(owner) || ts.isObjectBindingPattern(owner) || ts.isArrayBindingPattern(owner)) owner = owner.parent;
66
+ if (!ts.isVariableDeclaration(owner) || !owner.initializer || !(owner.parent.flags & ts.NodeFlags.Const)) return unknown();
67
+ const local = new Map(env);
68
+ bind(owner.name, reference(owner.initializer, env, via), local, via, next(context));
69
+ return local.get(symbol(node.name)) ?? unknown();
70
+ }
71
+ function get(value, key, context) {
72
+ value = resolve(value, next(context));
73
+ if (value.kind === "union") return union(value.values.map(item => get(item, key, next(context))));
74
+ if (value.kind === "rest") return value.excluded.has(key) ? literal(undefined) : get(value.value, key, next(context));
75
+ const layers = value.kind === "object" ? value.layers : [];
76
+ if (value.kind === "node" && ts.isObjectLiteralExpression(value.node)) {
77
+ for (const property of value.node.properties) {
78
+ if (ts.isSpreadAssignment(property)) layers.push(reference(property.expression, value.env, value.via));
79
+ else {
80
+ const name = nameOf(property.name);
81
+ if (name !== undefined) layers.push(new Map([[name, reference(ts.isShorthandPropertyAssignment(property) ? property.name : ts.isMethodDeclaration(property) ? property : property.initializer, value.env, value.via)]]));
82
+ }
83
+ }
84
+ } else if (value.kind === "node" && ts.isArrayLiteralExpression(value.node)) {
85
+ return reference(value.node.elements[Number(key)], value.env, value.via);
86
+ } else if (value.kind !== "object") return unknown(value.reason);
87
+ for (let index = layers.length - 1; index >= 0; index--) {
88
+ const layer = layers[index];
89
+ if (layer instanceof Map) { if (layer.has(key)) return layer.get(key); }
90
+ else {
91
+ const selected = get(layer, key, next(context));
92
+ if (selected.kind === "literal" && selected.value === undefined) continue;
93
+ // An opaque later spread may replace an earlier explicit property.
94
+ if (selected.kind === "unknown" || selected.kind === "union") return union([selected, get({ kind: "object", layers: layers.slice(0, index) }, key, next(context))]);
95
+ return selected;
96
+ }
97
+ }
98
+ return literal(undefined);
99
+ }
100
+ function keys(value, context) {
101
+ value = resolve(value, next(context));
102
+ if (value.kind === "union") return [...new Set(value.values.flatMap(item => keys(item, next(context))))];
103
+ if (value.kind === "node" && ts.isObjectLiteralExpression(value.node)) return value.node.properties.flatMap(property => ts.isSpreadAssignment(property) ? keys(reference(property.expression, value.env, value.via), next(context)) : nameOf(property.name) ?? []);
104
+ if (value.kind === "object") return [...new Set(value.layers.flatMap(layer => layer instanceof Map ? [...layer.keys()] : keys(layer, next(context))))];
105
+ return [];
106
+ }
107
+ function returns(fn, args, context, kind = "helper") {
108
+ if (context.calls.has(fn.node)) return [unknown("Recursive local call could not be fully traced.")];
109
+ if (!fn.node.body || fn.node.asteriskToken || fn.node.modifiers?.some(item => item.kind === ts.SyntaxKind.AsyncKeyword)) return [unknown("Async, generator or declaration-only calls are outside atomic source tracing.")];
110
+ const env = new Map(fn.env), via = [...fn.via, at(fn.node, kind, fn.node.name?.getText() ?? "local function")];
111
+ const inner = { ...next(context), calls: new Set([...context.calls, fn.node]) };
112
+ for (const [index, parameter] of fn.node.parameters.entries()) {
113
+ if (parameter.dotDotDotToken) { bind(parameter.name, { kind: "array", values: args.slice(index) }, env, via, inner); break; }
114
+ bind(parameter.name, withDefault(resolve(args[index] ?? literal(undefined), inner), parameter.initializer && reference(parameter.initializer, env, via), inner), env, via, inner);
115
+ }
116
+ const outputs = [];
117
+ function statement(node) {
118
+ if (ts.isReturnStatement(node)) { outputs.push(reference(node.expression, env, via)); return true; }
119
+ if (ts.isBlock(node)) { for (const child of node.statements) if (statement(child)) return true; }
120
+ else if (ts.isIfStatement(node)) {
121
+ const condition = resolve(reference(node.expression, env, via), inner);
122
+ if (condition.kind === "literal") return condition.value ? statement(node.thenStatement) : node.elseStatement ? statement(node.elseStatement) : false;
123
+ const left = statement(node.thenStatement), right = node.elseStatement ? statement(node.elseStatement) : false;
124
+ return left && right;
125
+ } else if (ts.isThrowStatement(node)) return true;
126
+ else if (ts.isIterationStatement(node, false) || ts.isSwitchStatement(node) || ts.isTryStatement(node)) outputs.push(unknown("Loop, switch or try control flow could not be fully traced."));
127
+ return false;
128
+ }
129
+ if (ts.isBlock(fn.node.body)) statement(fn.node.body);
130
+ else outputs.push(reference(fn.node.body, env, via));
131
+ // Keep call stacks with lazy return references, including nested helpers.
132
+ return outputs.length ? outputs.map(value => ({ ...value, context: inner })) : [literal(undefined)];
133
+ }
134
+ function resolve(value, context) {
135
+ if (!value) return literal(undefined);
136
+ context = value.context ?? context;
137
+ if (exhausted(context)) return unknown("Atomic source tracing reached its depth or work limit.");
138
+ if (value.kind !== "node") return value;
139
+ const node = unwrap(value.node), { env, via } = value;
140
+ if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) return literal(ts.isNumericLiteral(node) ? Number(node.text) : node.text);
141
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return literal(node.kind === ts.SyntaxKind.TrueKeyword);
142
+ if (node.kind === ts.SyntaxKind.NullKeyword) return literal(null);
143
+ if (ts.isIdentifier(node)) {
144
+ if (env.has(symbol(node))) return resolve(env.get(symbol(node)), next(context));
145
+ const owner = declaration(node);
146
+ if (owner && isInside(options.dsRoot, owner.getSourceFile().fileName)) {
147
+ if (isFunction(owner)) return reference(owner, env, via);
148
+ if (ts.isVariableDeclaration(owner) && owner.initializer && owner.parent.flags & ts.NodeFlags.Const) return resolve(reference(owner.initializer, env, via), next(context));
149
+ if (ts.isBindingElement(owner)) return resolve(boundElement(owner, env, via, context), next(context));
150
+ }
151
+ return node.text === "undefined" && !owner ? literal(undefined) : unknown();
152
+ }
153
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
154
+ const key = ts.isPropertyAccessExpression(node) ? literal(node.name.text) : resolve(reference(node.argumentExpression, env, via), next(context));
155
+ const object = reference(node.expression, env, via);
156
+ if (key.kind === "literal") {
157
+ const selected = get(object, String(key.value), next(context));
158
+ const owner = declaration(node);
159
+ if (selected.kind === "unknown" && owner && isInside(options.dsRoot, owner.getSourceFile().fileName)) {
160
+ if (isFunction(owner)) return reference(owner, env, via);
161
+ if (ts.isVariableDeclaration(owner) && owner.initializer && owner.parent.flags & ts.NodeFlags.Const) return resolve(reference(owner.initializer, env, via), next(context));
162
+ }
163
+ return resolve(selected, next(context));
164
+ }
165
+ const names = keys(object, next(context));
166
+ return names.length ? union(names.map(name => get(object, name, next(context)))) : unknown();
167
+ }
168
+ if (ts.isConditionalExpression(node)) {
169
+ const condition = resolve(reference(node.condition, env, via), next(context));
170
+ return condition.kind === "literal" ? resolve(reference(condition.value ? node.whenTrue : node.whenFalse, env, via), next(context)) : union([reference(node.whenTrue, env, via), reference(node.whenFalse, env, via)]);
171
+ }
172
+ if (ts.isBinaryExpression(node)) {
173
+ const left = resolve(reference(node.left, env, via), next(context)), right = reference(node.right, env, via), operator = node.operatorToken.kind;
174
+ if (operator === ts.SyntaxKind.AmpersandAmpersandToken) return left.kind === "literal" ? left.value ? right : literal(undefined) : union([literal(undefined), right]);
175
+ if (operator === ts.SyntaxKind.BarBarToken) return left.kind === "literal" ? left.value ? left : right : union([left, right]);
176
+ if (operator === ts.SyntaxKind.QuestionQuestionToken) return left.kind === "literal" ? left.value == null ? right : left : union([left, right]);
177
+ const other = resolve(right, next(context));
178
+ if (left.kind === "literal" && other.kind === "literal" && [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(operator)) return literal(operator === ts.SyntaxKind.EqualsEqualsEqualsToken ? left.value === other.value : left.value !== other.value);
179
+ }
180
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
181
+ const operand = resolve(reference(node.operand, env, via), next(context));
182
+ return operand.kind === "literal" ? literal(!operand.value) : unknown();
183
+ }
184
+ if (ts.isCallExpression(node) && !isCx(node.expression)) {
185
+ if (["memo", "forwardRef"].includes(uiOperation(node.expression, checker))) return resolve(reference(node.arguments[0], env, via), next(context));
186
+ const fn = resolve(reference(node.expression, env, via), next(context));
187
+ if (fn.kind === "node" && isFunction(fn.node)) {
188
+ const outputs = returns(fn, node.arguments.map(argument => reference(argument, env, via)), next(context));
189
+ return withVia(resolve(union(outputs), next(context)), outputs.flatMap(item => item.via ?? []));
190
+ }
191
+ return unknown(`Call ${node.expression.getText().slice(0, 80)} could not be traced to a local function.`);
192
+ }
193
+ return { ...value, node };
194
+ }
195
+ function arrays(value, context) {
196
+ value = resolve(value, next(context));
197
+ if (value.kind === "array") return value.values;
198
+ if (value.kind === "node" && ts.isArrayLiteralExpression(value.node)) return value.node.elements.map(node => reference(node, value.env, value.via));
199
+ return null;
200
+ }
201
+
202
+ return function review(source, { compare, limit }) {
203
+ const maxBranches = 32;
204
+ const unique = branches => [...new Map(branches.map(branch => [JSON.stringify(branch.map(entry => [entry.file, entry.line, entry.column])), branch])).values()];
205
+ function styles(input, context) {
206
+ const value = resolve(input, next(context));
207
+ context = value.context ?? input.context ?? context;
208
+ if (value.kind === "unknown") return { branches: [[]], limits: value.reason ? [value.reason] : [] };
209
+ if (value.kind === "union") {
210
+ const results = value.values.slice(0, maxBranches).map(item => styles(item, next(context)));
211
+ const branches = unique(results.flatMap(item => item.branches));
212
+ return { branches: branches.slice(0, maxBranches), limits: [...results.flatMap(item => item.limits), ...(value.values.length > maxBranches || branches.length > maxBranches ? ["Atomic source tracing reached its alternative limit."] : [])] };
213
+ }
214
+ if (value.kind !== "node") return { branches: [[]], limits: [] };
215
+ const { node, env, via } = value;
216
+ if (ts.isTaggedTemplateExpression(node) && isAtomic(node.tag)) return { branches: [entries(node).map(entry => ({ ...entry, via }))], limits: [] };
217
+ if (ts.isCallExpression(node) && isCx(node.expression)) {
218
+ const inputs = node.arguments.flatMap(argument => ts.isSpreadElement(argument) ? arrays(reference(argument.expression, env, via), context) ?? [unknown("A dynamic class argument spread could not be traced.")] : [reference(argument, env, via)]);
219
+ let branches = [[]]; const limits = [];
220
+ for (const argument of inputs) {
221
+ const result = styles(argument, next(context));
222
+ limits.push(...result.limits);
223
+ const combined = new Map(); let truncated = false;
224
+ for (const first of branches) for (const second of result.branches) {
225
+ // cx replaces exact property/selector/condition keys; shorthand
226
+ // expansion remains the CSS overlap review's responsibility.
227
+ const merged = new Map([...first, ...second.map(entry => ({ ...entry, via: [...via, ...(entry.via ?? [])] }))].map(entry => [JSON.stringify([entry.property, entry.selectors, entry.conditions]), entry]));
228
+ const branch = [...merged.values()], key = JSON.stringify(branch.map(entry => [entry.file, entry.line, entry.column]));
229
+ if (!combined.has(key) && combined.size >= maxBranches) { truncated = true; continue; }
230
+ combined.set(key, branch);
231
+ }
232
+ if (truncated) limits.push("Atomic source tracing reached its alternative limit.");
233
+ branches = [...combined.values()];
234
+ }
235
+ return { branches, limits };
236
+ }
237
+ return { branches: [[]], limits: [] };
238
+ }
239
+ function inspect(value, node, context, target, path = []) {
240
+ const result = styles(value, context);
241
+ for (const branch of result.branches) for (let index = 0; index < branch.length; index++) compare([branch[index]], branch.slice(index + 1), node, { target, path, limits: result.limits });
242
+ if (result.limits.length && (result.branches.some(branch => branch.length) || ts.isCallExpression(node) && node.arguments.some(argument => styles(reference(argument), next(context)).branches.some(branch => branch.length)))) limit(node, result.limits, path);
243
+ return result;
244
+ }
245
+ function props(node, env, via) {
246
+ return { kind: "object", layers: node.attributes.properties.map(attribute => ts.isJsxSpreadAttribute(attribute) ? reference(attribute.expression, env, via) : new Map([[attribute.name.text, attribute.initializer ? ts.isJsxExpression(attribute.initializer) ? reference(attribute.initializer.expression, env, via) : reference(attribute.initializer, env, via) : literal(true)]])) };
247
+ }
248
+ function render(input, location, context, path = [], pendingStyles = false) {
249
+ const value = resolve(input, next(context));
250
+ context = value.context ?? input.context ?? context;
251
+ if (value.kind === "union") { for (const item of value.values.slice(0, maxBranches)) render(item, location, next(context), path, pendingStyles); return; }
252
+ if (value.kind !== "node") { if (value.reason && pendingStyles) limit(location, [value.reason], path); return; }
253
+ const { node, env, via } = value;
254
+ if (ts.isJsxExpression(node)) { render(reference(node.expression, env, via), location, next(context), path, pendingStyles); return; }
255
+ if (ts.isJsxFragment(node)) { for (const child of node.children) render(reference(child, env, via), location, next(context), path); return; }
256
+ if (ts.isArrayLiteralExpression(node)) { for (const child of node.elements) render(reference(child, env, via), location, next(context), path); return; }
257
+ if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) return;
258
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
259
+ const attributes = props(opening, env, via), classValue = get(attributes, "className", next(context));
260
+ const tag = opening.tagName.getText();
261
+ if (/^[a-z]/.test(tag) && !tag.includes(".")) {
262
+ inspect(classValue, location, context, at(opening, "element", tag), [...path, ...via]);
263
+ if (ts.isJsxElement(node)) for (const child of node.children) render(reference(child, env, via), location, next(context), path);
264
+ return;
265
+ }
266
+ function componentTarget(component) {
267
+ if (component.kind === "union") { for (const alternative of component.values.slice(0, maxBranches)) componentTarget(resolve(alternative, next(context))); return; }
268
+ if (component.kind === "literal" && typeof component.value === "string") {
269
+ inspect(classValue, location, context, at(opening, "element", component.value), [...path, ...via]);
270
+ } else if (component.kind === "node" && isFunction(component.node)) {
271
+ const passed = pendingStyles || styles(classValue, context).branches.some(branch => branch.length);
272
+ for (const output of returns(component, [attributes], next(context), "component")) render(output, location, output.context ?? next(context), [...path, at(opening, "component", tag)], passed);
273
+ } else {
274
+ const passed = styles(classValue, context);
275
+ if (passed.branches.some(branch => branch.length)) limit(location, [`Component ${tag.slice(0, 80)} could not be traced to its DOM className target.`, ...passed.limits], path);
276
+ }
277
+ }
278
+ componentTarget(resolve(reference(opening.tagName, env, via), next(context)));
279
+ }
280
+ function visit(node) {
281
+ if (ts.isCallExpression(node)) inspect(reference(node), node, state());
282
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) render(reference(node), node, state());
283
+ ts.forEachChild(node, visit);
284
+ }
285
+ visit(source);
286
+ };
287
+ }
package/dist/check.mjs CHANGED
@@ -19,6 +19,7 @@ import { inspectBehaviors, inspectControllers } from "./behaviors.mjs";
19
19
  import { numericCssUnit, tokenLengthLiteral } from "./css-values.mjs";
20
20
  import { foundationDeclaration, foundationSelector } from "./foundation.mjs";
21
21
  import { scopedTokens, scopedTokenOwner } from "./scoped-tokens.mjs";
22
+ import { createAtomicCompositionReview } from "./atomic-composition.mjs";
22
23
 
23
24
  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.";
24
25
  const inlineStyleGuidance = `Own visual styles in component/pattern Linaria. ${scenarioLayoutGuidance} Reserve inlineStyleFiles for documented measured geometry, not demo layout.`;
@@ -50,6 +51,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
50
51
  const compiler = projectCompilerOptions(options);
51
52
  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 });
52
53
  const checker = program.getTypeChecker();
54
+ const atomicCompositionReview = createAtomicCompositionReview(program, options);
53
55
  function report(file, rule, message, line = 1, column = 1) {
54
56
  diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule, message, line, column });
55
57
  }
@@ -149,6 +151,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
149
151
  const source = program.getSourceFile(file);
150
152
  if (!source) continue;
151
153
  advisories.push(...pointerCaptureAdvisories(source).map(entry => ({ file: path.relative(root, file).replaceAll("\\", "/"), ...entry })));
154
+ advisories.push(...atomicCompositionReview(source).map(entry => ({ file: path.relative(root, file).replaceAll("\\", "/"), ...entry })));
152
155
  for (const diagnostic of program.getSyntacticDiagnostics(source)) {
153
156
  const position = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
154
157
  report(file, "source-syntax", ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), position.line + 1, position.character + 1);
@@ -259,5 +262,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
259
262
  }
260
263
  visit(source);
261
264
  }
265
+ // Bounded clients should see actionable warnings before source-analysis limits.
266
+ advisories.sort((first, second) => Number(first.severity === "info") - Number(second.severity === "info"));
262
267
  return { ok: diagnostics.length === 0, diagnostics, ...(advisories.length ? { advisories } : {}) };
263
268
  }
package/dist/cli.mjs CHANGED
@@ -113,7 +113,7 @@ async function main(args) {
113
113
  if (!json && result.diagnostics?.length) {
114
114
  for (const diagnostic of result.diagnostics) console.error(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.rule}] ${diagnostic.message}`);
115
115
  }
116
- if (!json) for (const advisory of result.advisories ?? []) console.error(`${advisory.file}:${advisory.line}:${advisory.column} [advisory:${advisory.rule}] ${advisory.message}`);
116
+ if (!json) for (const advisory of result.advisories ?? []) console.error(`${advisory.file}:${advisory.line}:${advisory.column} [${advisory.severity ?? "advisory"}:${advisory.rule}] ${advisory.message}`);
117
117
  if (result.ok === false || result.diagnostics?.length) process.exitCode = 1;
118
118
  }
119
119
 
@@ -85,6 +85,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
85
85
  return [
86
86
  { uri: GUIDE_URI, name: "Start here: design-system workflow", description: "Read first for UI/ADS tasks, especially when only resource access is exposed. Explains the exact workspace, tool sequence, skill selection, previews, project commands and fallback.", mimeType: "text/markdown" },
87
87
  { uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
88
+ { uri: "ads://guides/atomic-styles", name: "Atomic style composition: conflicts, prevention and fixes", mimeType: "text/markdown" },
88
89
  { uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
89
90
  { uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
90
91
  { uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
@@ -92,13 +93,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
92
93
  { uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
93
94
  { uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
94
95
  { uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
96
+ ...documents.filter(document => promoted.has(document.uri)).sort((a, b) => Number(b.uri === context.publicEntryUri) - Number(a.uri === context.publicEntryUri)).map(({ file: _file, ...document }) => document),
95
97
  { uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
96
98
  { uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
97
99
  ...(options.config.mcp.previewManifest ? [{ uri: PREVIEW_APP_URI, name: "Shared-resource preview App", mimeType: APP_MIME }, { uri: PREVIEW_MANIFEST_URI, name: "Preview dependency graph", mimeType: "application/json" }] : []),
98
100
  ...(options.config.mcp.previewManifest ? resourceAssets() : []),
99
101
  ...allApps().map((app) => ({ uri: appUri(app.id), name: app.title, description: app.description, mimeType: APP_MIME })),
100
102
  ...skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description, mimeType: "text/markdown" })),
101
- ...documents.filter((document) => promoted.has(document.uri)).map(({ file: _file, ...document }) => document),
102
103
  ...documents.filter((document) => !promoted.has(document.uri)).map(({ file: _file, ...document }) => document),
103
104
  ...[...skills.files.values()].filter((file) => !skillEntries.has(file.uri)).map(({ uri, name, mimeType }) => ({ uri, name, mimeType })),
104
105
  ];
@@ -121,6 +122,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
121
122
  async function read(uri, signal) {
122
123
  if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
123
124
  if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
125
+ if (uri === "ads://guides/atomic-styles") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/atomic-styles.md", import.meta.url), "utf8") }] };
124
126
  if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
125
127
  let value;
126
128
  if (uri === `${PROJECT_URI}/status`) value = status();
@@ -203,7 +205,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
203
205
  const matches = inventory.tokens.filter((token) => token.name.toLowerCase().includes(search.toLowerCase()));
204
206
  return json({ total: matches.length, tokens: matches.slice(0, limit), categories: tokenScenarios(), diagnosticCount: inventory.diagnostics.length, resourceUri: `${PROJECT_URI}/tokens` });
205
207
  } },
206
- { name: "ads_check", description: "Run read-only core ADS architecture checks; returns blocking diagnostics separately from nonblocking review advisories. Project Oxlint, typecheck, build and browser tests remain separate.", schema: z.object({ limit: z.number().int().min(1).max(50).default(20) }).strict(), call: ({ limit }) => {
208
+ { name: "ads_check", description: "Run read-only core ADS architecture checks; returns blocking diagnostics separately from nonblocking advisories, with warnings before informational limits. Atomic warnings include helper/forwarded-class paths and ads://guides/atomic-styles guidance; unresolved paths do not establish safe composition. Project Oxlint, typecheck, build and browser tests remain separate.", schema: z.object({ limit: z.number().int().min(1).max(50).default(20) }).strict(), call: ({ limit }) => {
207
209
  const result = checkProject(root);
208
210
  return json({ ok: result.ok, total: result.diagnostics.length, diagnostics: result.diagnostics.slice(0, limit), advisoryCount: result.advisories?.length ?? 0, advisories: (result.advisories ?? []).slice(0, limit), resourceUri: `${PROJECT_URI}/checks` });
209
211
  } },