@reqvire-org/ads 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/atomic-composition.mjs +183 -0
- package/dist/bootstrap-ds/ADS.md +9 -3
- package/dist/bootstrap-ds/README.md +10 -0
- package/dist/boundaries.mjs +3 -0
- package/dist/check.mjs +16 -0
- package/dist/cli.mjs +1 -1
- package/dist/config.mjs +6 -2
- package/dist/mcp/build-status.mjs +7 -4
- package/dist/mcp/context.mjs +2 -2
- package/dist/mcp/edit-preparation.mjs +111 -0
- package/dist/mcp/mutations.mjs +54 -38
- package/dist/mcp/preview-watch.mjs +10 -4
- package/dist/mcp/server.mjs +36 -24
- package/dist/mcp/text-diff.mjs +19 -0
- package/dist/preview-resources.mjs +45 -8
- package/dist/preview-scope.mjs +30 -0
- package/dist/public-api.mjs +37 -1
- package/dist/scenarios-compiler.mjs +24 -17
- package/dist/scenarios.mjs +58 -6
- package/dist/scoped-tokens.mjs +57 -0
- package/dist/skills/ads-break-ui/SKILL.md +10 -2
- package/dist/skills/ads-break-ui/references/atomic-styles.md +121 -0
- package/dist/skills/ads-break-ui/references/guarded-editing.md +44 -0
- package/dist/skills/ads-break-ui/references/verification.md +6 -0
- package/dist/skills/ads-designer/SKILL.md +10 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +9 -3
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
- package/dist/skills/ads-designer/references/atomic-styles.md +121 -0
- package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
- package/dist/skills/ads-import/SKILL.md +10 -2
- package/dist/skills/ads-import/references/atomic-styles.md +121 -0
- package/dist/skills/ads-import/references/guarded-editing.md +44 -0
- package/dist/skills/ads-import/references/tokens.md +4 -2
- package/dist/skills/ads-migrate/SKILL.md +10 -2
- package/dist/skills/ads-migrate/references/atomic-styles.md +121 -0
- package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
- package/dist/skills/ads-motion/SKILL.md +10 -2
- package/dist/skills/ads-motion/references/atomic-styles.md +121 -0
- package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
- package/dist/token-fixtures.mjs +30 -0
- package/dist/tokens.mjs +8 -2
- package/dist/units.mjs +15 -0
- package/dist/verification.mjs +1 -1
- package/docs/ADS.md +59 -2
- package/docs/ARCHITECTURE.md +27 -0
- package/docs/BEHAVIORS.md +20 -0
- package/docs/CHANGELOG.md +32 -0
- package/docs/GUIDE.md +8 -3
- package/docs/MCP.md +70 -5
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -91,7 +91,11 @@ for config files and Claude Desktop setup.
|
|
|
91
91
|
To let the agent edit the ADS through enforced checks, append `--mutations` to
|
|
92
92
|
the MCP command. It accepts a Git patch for one ADS unit, including its owned scenarios and
|
|
93
93
|
fixtures, and runs the guards before applying it. Foundation styles, original
|
|
94
|
-
assets, guides and project configuration/guard extensions have editable units too.
|
|
94
|
+
assets, guides and project configuration/guard extensions have editable units too.
|
|
95
|
+
Coupled changes use bounded owner transactions with checks on the complete final
|
|
96
|
+
candidate. Local owner drafts and token-sample preparation reduce patch setup;
|
|
97
|
+
they keep source writes behind the same guards. See
|
|
98
|
+
[guarded editing](docs/MCP.md#guarded-editing).
|
|
95
99
|
|
|
96
100
|
The server exposes public components/patterns, token values,
|
|
97
101
|
source guides, controllers/hooks, architecture checks, behavioral verification,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import postcss from "postcss";
|
|
4
|
+
import { isInside } from "./config.mjs";
|
|
5
|
+
|
|
6
|
+
export const atomicStyleGuide = "ads://guides/atomic-styles";
|
|
7
|
+
|
|
8
|
+
const sides = ["top", "right", "bottom", "left"];
|
|
9
|
+
const logicalSides = ["block-start", "block-end", "inline-start", "inline-end"];
|
|
10
|
+
const intersects = (a, b) => a.some(value => b.includes(value));
|
|
11
|
+
const previewValue = value => value.length > 200 ? value.slice(0, 197) + "..." : value;
|
|
12
|
+
const shorthands = {
|
|
13
|
+
background: ["background-attachment", "background-clip", "background-color", "background-image", "background-origin", "background-position", "background-repeat", "background-size"],
|
|
14
|
+
"background-position": ["background-position-x", "background-position-y"],
|
|
15
|
+
"background-repeat": ["background-repeat-x", "background-repeat-y"],
|
|
16
|
+
font: ["font-family", "font-size", "font-stretch", "font-style", "font-variant", "font-weight", "line-height"],
|
|
17
|
+
animation: ["animation-name", "animation-duration", "animation-timing-function", "animation-delay", "animation-iteration-count", "animation-direction", "animation-fill-mode", "animation-play-state"],
|
|
18
|
+
transition: ["transition-property", "transition-duration", "transition-timing-function", "transition-delay", "transition-behavior"],
|
|
19
|
+
flex: ["flex-grow", "flex-shrink", "flex-basis"],
|
|
20
|
+
"flex-flow": ["flex-direction", "flex-wrap"],
|
|
21
|
+
gap: ["row-gap", "column-gap"],
|
|
22
|
+
outline: ["outline-color", "outline-style", "outline-width"],
|
|
23
|
+
columns: ["column-count", "column-width"],
|
|
24
|
+
"column-rule": ["column-rule-color", "column-rule-style", "column-rule-width"],
|
|
25
|
+
"list-style": ["list-style-image", "list-style-position", "list-style-type"],
|
|
26
|
+
"text-decoration": ["text-decoration-color", "text-decoration-style", "text-decoration-line", "text-decoration-thickness"],
|
|
27
|
+
};
|
|
28
|
+
const expand = property => shorthands[property]?.flatMap(expand) ?? [property];
|
|
29
|
+
const longhands = new Map(Object.keys(shorthands).map(property => [property, expand(property)]));
|
|
30
|
+
|
|
31
|
+
// CSS-language relationships, independent of components, tokens and frameworks.
|
|
32
|
+
function footprint(property) {
|
|
33
|
+
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);
|
|
34
|
+
if (box) {
|
|
35
|
+
const side = box[2];
|
|
36
|
+
const logical = !!side && !sides.includes(side);
|
|
37
|
+
return { family: box[1], logical, slots: !side ? logicalSides : /^(block|inline)$/.test(side) ? [`${side}-start`, `${side}-end`] : [side], whole: !side };
|
|
38
|
+
}
|
|
39
|
+
if (sides.includes(property)) return { family: "inset", logical: false, slots: [property] };
|
|
40
|
+
const border = /^border(?:-(top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?(?:-(color|style|width))?$/.exec(property);
|
|
41
|
+
if (border) {
|
|
42
|
+
const side = border[1];
|
|
43
|
+
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"] };
|
|
44
|
+
}
|
|
45
|
+
const corner = /^border-(?:(top-left|top-right|bottom-left|bottom-right|start-start|start-end|end-start|end-end)-)?radius$/.exec(property);
|
|
46
|
+
if (corner) return { family: "radius", logical: /^(start|end)-/.test(corner[1] ?? ""), whole: !corner[1], slots: [corner[1]] };
|
|
47
|
+
const dimension = /^(min-|max-)?(width|height|inline-size|block-size)$/.exec(property);
|
|
48
|
+
if (dimension) return { family: `${dimension[1] ?? ""}size`, logical: dimension[2].endsWith("-size"), slots: [dimension[2]] };
|
|
49
|
+
const overflow = /^(overflow|overscroll-behavior)(?:-(x|y|block|inline))?$/.exec(property);
|
|
50
|
+
if (overflow) return { family: overflow[1], logical: ["block", "inline"].includes(overflow[2]), whole: !overflow[2], slots: [overflow[2]] };
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function overlaps(a, b) {
|
|
55
|
+
if (a === b || a.startsWith("--") || b.startsWith("--")) return false;
|
|
56
|
+
if ([a, b].includes("all")) return ![a, b].some(value => ["direction", "unicode-bidi"].includes(value));
|
|
57
|
+
const first = footprint(a), second = footprint(b);
|
|
58
|
+
if (first && second && first.family === second.family) {
|
|
59
|
+
if (first.fields && !intersects(first.fields, second.fields)) return false;
|
|
60
|
+
return first.whole || second.whole || first.logical !== second.logical || intersects(first.slots, second.slots);
|
|
61
|
+
}
|
|
62
|
+
return intersects(longhands.get(a) ?? [a], longhands.get(b) ?? [b]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unwrap(node) {
|
|
66
|
+
while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Review only: valid CSS can intentionally overlap. Never rewrite project CSS. */
|
|
71
|
+
export function createAtomicCompositionReview(program, options) {
|
|
72
|
+
const checker = program.getTypeChecker(), templates = new Map();
|
|
73
|
+
function declaration(node) {
|
|
74
|
+
let symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
|
|
75
|
+
if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
|
|
76
|
+
return symbol?.valueDeclaration;
|
|
77
|
+
}
|
|
78
|
+
function imported(node, names, modules, seen = new Set()) {
|
|
79
|
+
node = unwrap(node);
|
|
80
|
+
if (!node || seen.has(node)) return false;
|
|
81
|
+
seen = new Set([...seen, node]);
|
|
82
|
+
const base = ts.isPropertyAccessExpression(node) ? node.expression : node;
|
|
83
|
+
const symbol = checker.getSymbolAtLocation(base);
|
|
84
|
+
for (const item of symbol?.declarations ?? []) {
|
|
85
|
+
if (ts.isImportSpecifier(item) && names.includes((item.propertyName ?? item.name).text) && modules.includes(item.parent.parent.parent.moduleSpecifier.text)) return true;
|
|
86
|
+
if (ts.isNamespaceImport(item) && ts.isPropertyAccessExpression(node) && names.includes(node.name.text) && modules.includes(item.parent.parent.moduleSpecifier.text)) return true;
|
|
87
|
+
if (ts.isVariableDeclaration(item) && imported(item.initializer, names, modules, seen)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const isCx = node => imported(node, ["cx"], ["@linaria/atomic", "@linaria/core"]);
|
|
92
|
+
function isAtomic(tag) {
|
|
93
|
+
if (ts.isCallExpression(tag)) tag = tag.expression;
|
|
94
|
+
if (imported(tag, ["css", "styled"], ["@linaria/atomic"])) return true;
|
|
95
|
+
return ts.isPropertyAccessExpression(tag) && imported(tag.expression, ["styled"], ["@linaria/atomic"]);
|
|
96
|
+
}
|
|
97
|
+
function entries(node) {
|
|
98
|
+
if (templates.has(node)) return templates.get(node);
|
|
99
|
+
const result = [];
|
|
100
|
+
templates.set(node, result);
|
|
101
|
+
const source = node.getSourceFile(), template = node.template;
|
|
102
|
+
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("");
|
|
103
|
+
const position = source.getLineAndCharacterOfPosition(template.getStart(source) + 1);
|
|
104
|
+
try {
|
|
105
|
+
postcss.parse(text).walkDecls(decl => {
|
|
106
|
+
const selectors = [], conditions = [];
|
|
107
|
+
for (let parent = decl.parent; parent?.type !== "root"; parent = parent?.parent) {
|
|
108
|
+
if (!parent) break;
|
|
109
|
+
if (parent.type === "rule") selectors.unshift(parent.selector.trim().replace(/\s+/g, " "));
|
|
110
|
+
if (parent.type === "atrule") conditions.unshift(`${parent.name} ${parent.params}`.trim().replace(/\s+/g, " "));
|
|
111
|
+
}
|
|
112
|
+
// Animation frames describe different instants, not combined classes.
|
|
113
|
+
if (conditions.some(condition => /^(?:-\w+-)?keyframes\b/.test(condition))) return;
|
|
114
|
+
result.push({ property: decl.prop.toLowerCase(), value: decl.value, important: !!decl.important, selectors: JSON.stringify(selectors), conditions: JSON.stringify(conditions),
|
|
115
|
+
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) });
|
|
116
|
+
});
|
|
117
|
+
} catch { /* The core CSS syntax diagnostic owns malformed templates. */ }
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function object(node, seen = new Set()) {
|
|
121
|
+
node = unwrap(node);
|
|
122
|
+
if (!node || seen.has(node)) return null;
|
|
123
|
+
if (ts.isObjectLiteralExpression(node) || ts.isArrayLiteralExpression(node)) return node;
|
|
124
|
+
const owner = declaration(node);
|
|
125
|
+
return owner?.initializer ? object(owner.initializer, new Set([...seen, node])) : null;
|
|
126
|
+
}
|
|
127
|
+
function styles(node, seen = new Set()) {
|
|
128
|
+
node = unwrap(node);
|
|
129
|
+
if (!node || seen.has(node)) return [];
|
|
130
|
+
seen = new Set([...seen, node]);
|
|
131
|
+
if (ts.isTaggedTemplateExpression(node) && isAtomic(node.tag)) return entries(node);
|
|
132
|
+
if (ts.isConditionalExpression(node)) return [...styles(node.whenTrue, seen), ...styles(node.whenFalse, seen)];
|
|
133
|
+
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind)) return [...(node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ? [] : styles(node.left, seen)), ...styles(node.right, seen)];
|
|
134
|
+
if (ts.isCallExpression(node) && isCx(node.expression)) return node.arguments.flatMap(argument => styles(argument, seen));
|
|
135
|
+
if (ts.isSpreadElement(node)) {
|
|
136
|
+
const value = object(node.expression);
|
|
137
|
+
return value && ts.isArrayLiteralExpression(value) ? value.elements.flatMap(element => styles(element, seen)) : [];
|
|
138
|
+
}
|
|
139
|
+
const owner = declaration(node);
|
|
140
|
+
if (owner?.initializer) return styles(owner.initializer, seen);
|
|
141
|
+
if (ts.isElementAccessExpression(node) || ts.isPropertyAccessExpression(node)) {
|
|
142
|
+
const value = object(node.expression);
|
|
143
|
+
if (!value || !ts.isObjectLiteralExpression(value)) return [];
|
|
144
|
+
const key = ts.isPropertyAccessExpression(node) ? node.name.text : node.argumentExpression && ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
145
|
+
return value.properties.flatMap(property => {
|
|
146
|
+
const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
|
|
147
|
+
return key !== null && name !== key ? [] : ts.isPropertyAssignment(property) ? styles(property.initializer, seen) : ts.isShorthandPropertyAssignment(property) ? styles(property.name, seen) : [];
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
return source => {
|
|
153
|
+
const warnings = [], seen = new Set();
|
|
154
|
+
function compare(first, second, location) {
|
|
155
|
+
for (const a of first) for (const b of second) {
|
|
156
|
+
if (a === b || a.selectors !== b.selectors || a.conditions !== b.conditions || a.important !== b.important || a.value === "0" && b.value === "0" || !overlaps(a.property, b.property)) continue;
|
|
157
|
+
const key = JSON.stringify([a, b].map(entry => [entry.file, entry.line, entry.column]).sort());
|
|
158
|
+
if (seen.has(key)) continue;
|
|
159
|
+
seen.add(key);
|
|
160
|
+
const position = source.getLineAndCharacterOfPosition(location.getStart(source));
|
|
161
|
+
warnings.push({ rule: "atomic-composition-review", severity: "warning", line: position.line + 1, column: position.character + 1,
|
|
162
|
+
message: `Atomic declarations ${a.property}: ${previewValue(a.value)} and ${b.property}: ${previewValue(b.value)} may overlap without matching cx merge keys. 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}.`,
|
|
163
|
+
guidanceUri: atomicStyleGuide, related: [a, b].map(({ selectors: _selectors, conditions: _conditions, important: _important, ...entry }) => ({...entry,value:previewValue(entry.value)})) });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function visit(node) {
|
|
167
|
+
if (ts.isTaggedTemplateExpression(node) && isAtomic(node.tag)) {
|
|
168
|
+
const declarations = entries(node);
|
|
169
|
+
for (let index = 0; index < declarations.length; index++) compare([declarations[index]], declarations.slice(index + 1), node);
|
|
170
|
+
}
|
|
171
|
+
if (ts.isCallExpression(node) && isCx(node.expression)) {
|
|
172
|
+
const groups = node.arguments.flatMap(argument => {
|
|
173
|
+
const value = ts.isSpreadElement(argument) ? object(argument.expression) : null;
|
|
174
|
+
return value && ts.isArrayLiteralExpression(value) ? value.elements.map(element => styles(element)) : [styles(argument)];
|
|
175
|
+
});
|
|
176
|
+
for (let index = 0; index < groups.length; index++) for (let next = index + 1; next < groups.length; next++) compare(groups[index], groups[next], node);
|
|
177
|
+
}
|
|
178
|
+
ts.forEachChild(node, visit);
|
|
179
|
+
}
|
|
180
|
+
if (isInside(options.dsRoot, source.fileName)) visit(source);
|
|
181
|
+
return warnings;
|
|
182
|
+
};
|
|
183
|
+
}
|
package/dist/bootstrap-ds/ADS.md
CHANGED
|
@@ -36,8 +36,14 @@ Projects sharing this ADS use the same token names, meanings, and value types;
|
|
|
36
36
|
their concrete values and theme overrides can differ. Reuse existing local values
|
|
37
37
|
when importing components/patterns, add missing shared definitions, and import
|
|
38
38
|
source token values only when adopting that theme is requested.
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
Shared token names and prefixes follow the ADS contract. A component/pattern may
|
|
40
|
+
register one unique local namespace in colocated `tokens.json` and define it in
|
|
41
|
+
`tokens.css`, imported by its runtime. Flat owners use `<Owner>.tokens.json` and
|
|
42
|
+
`<Owner>.tokens.css`. These definition-only sheets contain root/theme custom
|
|
43
|
+
properties, not UI selectors or ordinary styles. Other owners consume shared
|
|
44
|
+
tokens; they cannot override or consume a peer's local namespace. A colocated
|
|
45
|
+
`kind:"tokens"` scenario needs real samples for every local token. Shared
|
|
46
|
+
assignments remain in the shared token files. Components own
|
|
41
47
|
`--ds-*`, patterns `--ux-*`, and the showcase `--showcase-*`; token files cannot
|
|
42
48
|
assign those local variables or override component state.
|
|
43
49
|
|
|
@@ -183,7 +189,7 @@ mounting, correct props, every branch/state or visual parity. The older
|
|
|
183
189
|
|
|
184
190
|
`token-scenario-reference` rejects undeclared token names in metadata and reachable
|
|
185
191
|
inputs. `token-scenario-render` rejects claimed tokens with no reachable input
|
|
186
|
-
reference; `token-scenario-coverage` requires every discovered
|
|
192
|
+
reference; `token-scenario-coverage` requires every discovered shared or owner-local token
|
|
187
193
|
to have coverage. A new or removed token therefore needs a scenario/fixture
|
|
188
194
|
update. All three are errors in `ads check`, run by `lint:ds` and the build.
|
|
189
195
|
Coverage is static reachability, not proof that every branch actually renders
|
|
@@ -62,6 +62,10 @@ the public `TokenPreview` pattern owns their presentation and applies the real C
|
|
|
62
62
|
variables. Add/remove samples alongside token definitions. `ads check` rejects
|
|
63
63
|
missing coverage and invented names; the App verifies the declared CSS bindings
|
|
64
64
|
in the browser. Extend or replace the project-owned pattern as your tokens evolve.
|
|
65
|
+
`ads_prepare_token_samples` can prepare a fixture patch from explicit project
|
|
66
|
+
property/probe bindings. It preserves existing samples and reports unmapped tokens;
|
|
67
|
+
it does not guess how a new value should look. Component/pattern constants can use
|
|
68
|
+
the colocated namespace contract in [ADS.md](ADS.md), with their own token scenario.
|
|
65
69
|
|
|
66
70
|
Create a component under `design-system/components/`, export it through `@ds`,
|
|
67
71
|
and colocate `Scenario.tsx` plus `fixture.ts` (or `<Owner>.scenario.tsx` and
|
|
@@ -79,6 +83,8 @@ stale retained output. Switching or rebuilding a scenario unmounts its previous
|
|
|
79
83
|
state. `showcase/mcp/main.tsx` exports `mountScenario(Scenario, container)` and
|
|
80
84
|
returns an unmount callback; it is not the shell. Keep the saved `framework`,
|
|
81
85
|
compiler and mount adapter consistent.
|
|
86
|
+
Managed sessions own their graph output; standalone prebuilds cannot replace it.
|
|
87
|
+
Verified generated graph snapshots preserve warm-start reuse across sessions.
|
|
82
88
|
|
|
83
89
|
The application and previews use the same foundation CSS and Linaria pipeline.
|
|
84
90
|
The source barrel loads the foundation once. The private `build:lib` script uses
|
|
@@ -98,3 +104,7 @@ from a user-selected external repository, preserving revisions and licenses.
|
|
|
98
104
|
inspection builds it automatically, then loads selected entries. Browser review
|
|
99
105
|
still checks layout, accessibility, keyboard/focus, states, themes and motion. A clean
|
|
100
106
|
build or delivered HTML alone does not establish those behaviors.
|
|
107
|
+
Opt-in MCP editing uses owner Git patches. `ads_prepare_patch` creates a temporary
|
|
108
|
+
owner draft/diff; `ads_apply_transaction` validates bounded coupled owner patches
|
|
109
|
+
against one final candidate. Preparation changes no live source. Keep all guards
|
|
110
|
+
and review the rendered result after applying.
|
package/dist/boundaries.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import ts from "typescript";
|
|
|
3
3
|
import { importedUiName } from "./ui-framework.mjs";
|
|
4
4
|
import { isInside, isStateVariable, isTestFile, isDevelopmentFile } from "./config.mjs";
|
|
5
5
|
import { isSharedToken } from "./token-policy.mjs";
|
|
6
|
+
import { scopedTokenOwner } from "./scoped-tokens.mjs";
|
|
6
7
|
|
|
7
8
|
export function projectCompilerOptions({ root }) {
|
|
8
9
|
const file = ts.findConfigFile(root, ts.sys.fileExists, "tsconfig.json");
|
|
@@ -59,6 +60,8 @@ export function checkComponentVariable(name, filename, options) {
|
|
|
59
60
|
const components = !development && isInside(path.join(dsRoot, "components"), filename);
|
|
60
61
|
const patterns = !development && isInside(path.join(dsRoot, "patterns"), filename);
|
|
61
62
|
const showcase = development;
|
|
63
|
+
const owned = scopedTokenOwner(options, name);
|
|
64
|
+
if (owned) return path.resolve(options.root, owned.file) === filename ? null : { rule: "token-ownership", message: `Define ${name} only in ${owned.file}, owned by ${owned.unit}. Consume it with var(); runtime geometry remains a separate styleWrites boundary.` };
|
|
62
65
|
if (isSharedToken(name)) return tokens ? null : { rule: "token-ownership", message: `Define ${name} in ADS tokens, not component, pattern, showcase, or app styles.` };
|
|
63
66
|
if (name.startsWith("--ux-")) return patterns || showcase ? null : { rule: "variable-ownership", message: "--ux-* variables belong to ADS patterns and showcase composition." };
|
|
64
67
|
if (name.startsWith("--showcase-")) return showcase ? null : { rule: "variable-ownership", message: "--showcase-* variables belong to the ADS showcase." };
|
package/dist/check.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { inspectStyleBoundaries, pointerCaptureAdvisories } from "./style-bounda
|
|
|
18
18
|
import { inspectBehaviors, inspectControllers } from "./behaviors.mjs";
|
|
19
19
|
import { numericCssUnit, tokenLengthLiteral } from "./css-values.mjs";
|
|
20
20
|
import { foundationDeclaration, foundationSelector } from "./foundation.mjs";
|
|
21
|
+
import { scopedTokens, scopedTokenOwner } from "./scoped-tokens.mjs";
|
|
22
|
+
import { createAtomicCompositionReview } from "./atomic-composition.mjs";
|
|
21
23
|
|
|
22
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.";
|
|
23
25
|
const inlineStyleGuidance = `Own visual styles in component/pattern Linaria. ${scenarioLayoutGuidance} Reserve inlineStyleFiles for documented measured geometry, not demo layout.`;
|
|
@@ -40,6 +42,8 @@ export function checkProject(directory, { trackingRoot } = {}) {
|
|
|
40
42
|
diagnostics.push(...tokenInventory.diagnostics);
|
|
41
43
|
diagnostics.push(...inspectAssets(options));
|
|
42
44
|
const resolveImport = importResolver(options);
|
|
45
|
+
const scoped = scopedTokens(options);
|
|
46
|
+
const scopedFiles = new Set(scoped.definitions.map(entry => path.resolve(root, entry.file)));
|
|
43
47
|
const rootHtml = path.join(root, "index.html");
|
|
44
48
|
const files = [...new Set([...sourceFiles(sourceRoot), ...sourceFiles(dsRoot), ...(existsSync(rootHtml) ? [rootHtml] : [])])];
|
|
45
49
|
const boundaryFiles = [...config.inlineStyleFiles, ...config.boundaries.styleWrites.map(entry => entry.file)].map(file => path.resolve(root, file)).filter(file => existsSync(file) && statSync(file).isFile());
|
|
@@ -47,6 +51,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
|
|
|
47
51
|
const compiler = projectCompilerOptions(options);
|
|
48
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 });
|
|
49
53
|
const checker = program.getTypeChecker();
|
|
54
|
+
const atomicCompositionReview = createAtomicCompositionReview(program, options);
|
|
50
55
|
function report(file, rule, message, line = 1, column = 1) {
|
|
51
56
|
diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule, message, line, column });
|
|
52
57
|
}
|
|
@@ -110,6 +115,11 @@ export function checkProject(directory, { trackingRoot } = {}) {
|
|
|
110
115
|
const variable = checkComponentVariable(decl.prop, file, options);
|
|
111
116
|
if (variable) report(file, variable.rule, variable.message, offset.line + decl.source.start.line - 1, decl.source.start.column);
|
|
112
117
|
for (const ref of references(decl.value)) {
|
|
118
|
+
const owned = scopedTokenOwner(options, ref.name);
|
|
119
|
+
if (owned) {
|
|
120
|
+
if (scoped.inventory.owner(scoped.inventory.relative(path.relative(root, file))) !== owned.unit || !declaredTokens.has(ref.name)) report(file, "token-reference", `${ref.name} belongs to ${owned.unit}. Use shared tokens across owners; owner-local tokens must be defined and consumed by their owner.`, offset.line + decl.source.start.line - 1, decl.source.start.column);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
113
123
|
const local = /^(?:--ds-|--ux-|--showcase-)/.test(ref.name);
|
|
114
124
|
if (foundation && local) report(file, "foundation-role", "Foundation defaults consume shared tokens, not component, pattern or scenario-local variables.", offset.line + decl.source.start.line - 1, decl.source.start.column);
|
|
115
125
|
if (!local && !isSharedToken(ref.name)) report(file, "token-name", `${ref.name} does not follow ADS's fixed token naming contract.`, offset.line + decl.source.start.line - 1, decl.source.start.column);
|
|
@@ -130,6 +140,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
|
|
|
130
140
|
});
|
|
131
141
|
}
|
|
132
142
|
for (const file of files) {
|
|
143
|
+
if (scopedFiles.has(file)) continue; // Closed definition-only contract checked by inspectTokens.
|
|
133
144
|
if (file.endsWith(".css") && isInside(path.join(dsRoot, config.tokenDir), file) && !isDevelopmentFile(file, options)) { checkCss(file, readFileSync(file, "utf8"), undefined, true); continue; }
|
|
134
145
|
if (file.endsWith(".html")) { inspectHtml(readFileSync(file, "utf8"), (rule, message, line) => report(file, rule, message, line)); continue; }
|
|
135
146
|
if (file.endsWith(".css")) {
|
|
@@ -140,6 +151,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
|
|
|
140
151
|
const source = program.getSourceFile(file);
|
|
141
152
|
if (!source) continue;
|
|
142
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 })));
|
|
143
155
|
for (const diagnostic of program.getSyntacticDiagnostics(source)) {
|
|
144
156
|
const position = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
|
|
145
157
|
report(file, "source-syntax", ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), position.line + 1, position.character + 1);
|
|
@@ -223,6 +235,10 @@ export function checkProject(directory, { trackingRoot } = {}) {
|
|
|
223
235
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
|
|
224
236
|
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) specifier = node.arguments[0].text;
|
|
225
237
|
if (specifier && importViolation(specifier, file, options, resolveImport)) nodeReport(node, "public-import", `Import through ${config.publicAlias}, not ADS implementation paths.`);
|
|
238
|
+
if (specifier) for (const target of resolveImport(specifier, file)) {
|
|
239
|
+
const tokenOwner = scoped.definitions.find(entry => path.resolve(root, entry.file) === target);
|
|
240
|
+
if (tokenOwner && scoped.inventory.owner(scoped.inventory.relative(path.relative(root, file))) !== tokenOwner.unit) nodeReport(node, "token-ownership", `Only ${tokenOwner.unit} imports its token sheet. Import the public component/pattern to consume its design.`);
|
|
241
|
+
}
|
|
226
242
|
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.");
|
|
227
243
|
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.");
|
|
228
244
|
if (specifier && showcaseImportViolation(specifier, file, options, resolveImport)) nodeReport(node, "showcase-import", "Showcase code and styles are development fixtures; runtime code must not import them.");
|
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
|
|
package/dist/config.mjs
CHANGED
|
@@ -152,10 +152,14 @@ export function isBehaviorFile(file) {
|
|
|
152
152
|
return /(?:^|[\\/])behavior\.json$|\.behavior\.json$/.test(file);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export function isTokenManifestFile(file) {
|
|
156
|
+
return /(?:^|[\\/])tokens\.json$|\.tokens\.json$/.test(file);
|
|
157
|
+
}
|
|
158
|
+
|
|
155
159
|
export function isDevelopmentFile(file, { dsRoot }) {
|
|
156
160
|
if (!isInside(dsRoot, file)) return isTestFile(file);
|
|
157
161
|
const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
|
|
158
|
-
return isTestFile(file) || isScenarioFile(file) || isFixtureFile(file) || isBehaviorFile(file)
|
|
162
|
+
return isTestFile(file) || isScenarioFile(file) || isFixtureFile(file) || isBehaviorFile(file) || isTokenManifestFile(file)
|
|
159
163
|
|| /^(?:showcase|composition)\//.test(relative)
|
|
160
164
|
|| /(?:^|\/)(?:scenario|fixtures)(?:\/|$)/.test(relative)
|
|
161
165
|
|| /\.scenario(?:-adapter)?\.[cm]?[jt]sx?$/.test(relative);
|
|
@@ -170,7 +174,7 @@ export function sourceFiles(root) {
|
|
|
170
174
|
if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
171
175
|
const file = path.join(root, entry.name);
|
|
172
176
|
if (entry.isDirectory()) files.push(...sourceFiles(file));
|
|
173
|
-
else if (entry.isFile() && (/\.(css|html|[cm]?[jt]sx?)$/.test(entry.name) || isBehaviorFile(entry.name))) files.push(file);
|
|
177
|
+
else if (entry.isFile() && (/\.(css|html|[cm]?[jt]sx?)$/.test(entry.name) || isBehaviorFile(entry.name) || isTokenManifestFile(entry.name))) files.push(file);
|
|
174
178
|
}
|
|
175
179
|
return files.sort();
|
|
176
180
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { isInside, projectPath } from "../config.mjs";
|
|
6
6
|
import { readPreviewManifest } from "../preview-resources.mjs";
|
|
7
|
+
import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
|
|
7
8
|
|
|
8
9
|
export const BUILD_URI = "ads://project/build";
|
|
9
10
|
export const buildStatusFile = (root) => path.join(root, ".ds/build-status.json");
|
|
@@ -123,7 +124,8 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
/** Read durable build events; a running process alone never means a fresh build. */
|
|
126
|
-
export function inspectBuildStatus(root, config, { session, processState, file = buildStatusFile(root) } = {}) {
|
|
127
|
+
export function inspectBuildStatus(root, config, { session, previewSession, processState, file = buildStatusFile(root) } = {}) {
|
|
128
|
+
const options = withPreviewSession({ root, config: { mcp: config } }, previewSession);
|
|
127
129
|
let report;
|
|
128
130
|
let reportError;
|
|
129
131
|
try {
|
|
@@ -137,7 +139,7 @@ export function inspectBuildStatus(root, config, { session, processState, file =
|
|
|
137
139
|
let graphError;
|
|
138
140
|
if (config.previewManifest && report?.previews.some(entry => entry.state === "ready")) {
|
|
139
141
|
try {
|
|
140
|
-
if (!readPreviewManifest(
|
|
142
|
+
if (!readPreviewManifest(options, { verify: true })) throw new Error("Preview resource manifest is missing.");
|
|
141
143
|
} catch (error) { graphError = `Invalid preview resource graph: ${error.message}`; }
|
|
142
144
|
}
|
|
143
145
|
const previews = config.apps.map((app) => {
|
|
@@ -145,12 +147,13 @@ export function inspectBuildStatus(root, config, { session, processState, file =
|
|
|
145
147
|
let state = value?.state ?? "unreported";
|
|
146
148
|
let diagnostics = value?.diagnosticSet !== undefined ? report.diagnosticSets[value.diagnosticSet] : value?.diagnostics ?? [];
|
|
147
149
|
if (report?.diagnostics?.length) { state = "error"; diagnostics = [...report.diagnostics, ...diagnostics].slice(0, 20); }
|
|
148
|
-
const
|
|
150
|
+
const artifact = path.resolve(root, previewArtifactPath(options, app.file));
|
|
151
|
+
const available = existsSync(artifact);
|
|
149
152
|
if (reportError) { state = "error"; diagnostics = [{ message: reportError }]; }
|
|
150
153
|
if (graphError && app.id.includes("/")) { state = "error"; diagnostics = [{ message: graphError }]; }
|
|
151
154
|
if (state === "ready") {
|
|
152
155
|
try {
|
|
153
|
-
if (value.lastSuccessfulRevision !== value.revision || artifactDigest(
|
|
156
|
+
if (value.lastSuccessfulRevision !== value.revision || artifactDigest(artifact) !== value.artifactDigest) throw new Error("Preview output does not match its successful build. Rebuild before reviewing it.");
|
|
154
157
|
} catch (error) { state = "error"; diagnostics = [{ message: error.message }]; }
|
|
155
158
|
}
|
|
156
159
|
if (processState?.state === "failed") { state = "error"; diagnostics = [{ message: processState.error ?? "Preview watcher exited." }, ...diagnostics].slice(0, 20); }
|
package/dist/mcp/context.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { projectGuideFiles } from "../project-documents.mjs";
|
|
|
5
5
|
|
|
6
6
|
export const GUIDE_URI = "ads://project/guide";
|
|
7
7
|
// Put the task triggers and entry path first: some hosts shorten server guidance.
|
|
8
|
-
export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner
|
|
8
|
+
export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
|
|
9
9
|
|
|
10
10
|
function manager(root, pkg) {
|
|
11
11
|
if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
|
|
@@ -56,7 +56,7 @@ Use this server for UI components/patterns, tokens/themes/branding, layout, inte
|
|
|
56
56
|
3. Select one workflow with ads_skills, then ads_read_skill. ${route.skills.map((skill) => `${skill.task}: ${skill.name}`).join("; ")}. Native skill activation is host-controlled. Relative skill references resolve from its SKILL.md URI. Read support files only when relevant.
|
|
57
57
|
4. ${route.preview} Token samples: ads_scenarios(kind: tokens), then ads_show_scenario or ads_show_tokens(category: id). Token source inspection: ads_tokens with a focused query. Registered previews: ${previews.map((preview) => `${preview.id}: ${preview.tool ?? "ads_show_scenario"}`).join(", ") || "none"}. Showcase URL: ${context.showcase.url ?? "not configured; use the project's showcase command"}.
|
|
58
58
|
Inspection connects a public export to its owning source, available guides, token values and discovered preview IDs. Bounded tools keep discovery focused; explicit resource reads supply detail. Source metadata does not prove layout, interaction or motion. A scenario renders the real public implementation and its fixture context in either review surface.
|
|
59
|
-
5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements
|
|
59
|
+
5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements; a real selected-owner re-export can remove its redundant export {};. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit; use ads_apply_transaction for coupled owners instead of bypassing rejection with direct writes. ads_prepare_patch creates a local owner draft/diff; ads_prepare_token_samples reconciles explicit project bindings without guessing visual meaning. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
|
|
60
60
|
6. ${route.validation} Registered commands: ${context.commands.map((command) => command.args?.join(" ") ?? `choose the project package manager for ${command.script}`).join("; ") || "none discovered; inspect the project's build setup"}. Run them from ${context.projectRoot}.
|
|
61
61
|
7. After edits, inspect ads_status.build or ads://project/build explicitly. Running/unreported/old output is not a current successful preview. Fix diagnostics in owned source, wait for rebuilding and invoke the preview tool again. Notifications may not start an agent turn.
|
|
62
62
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isInside } from "../config.mjs";
|
|
6
|
+
import { checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
|
|
7
|
+
import { textDiff } from "./text-diff.mjs";
|
|
8
|
+
|
|
9
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
10
|
+
const binary = bytes => bytes?.includes(0) || bytes && !Buffer.from(bytes.toString("utf8")).equals(bytes);
|
|
11
|
+
function generated(options, relative) {
|
|
12
|
+
const target = path.join(options.root, ".ds", relative);
|
|
13
|
+
mkdirSync(path.dirname(target), {recursive:true});
|
|
14
|
+
if (realpathSync(path.dirname(target)) !== path.dirname(target)) throw new Error("Patch preparation output cannot resolve through symlinks.");
|
|
15
|
+
return target;
|
|
16
|
+
}
|
|
17
|
+
export function storePreparedPatch(options, patch) {
|
|
18
|
+
if (Buffer.byteLength(patch) > 16 * 1024 * 1024) throw new Error("Prepared Git patch exceeds 16 MiB.");
|
|
19
|
+
if (!patch) return {patch:null,patchFile:null};
|
|
20
|
+
const file = `.ds/patches/${randomUUID()}.patch`;
|
|
21
|
+
writeFileSync(generated(options,file.slice(4)),patch);
|
|
22
|
+
return {patchFile:file,bytes:Buffer.byteLength(patch)};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A temporary copy of just one owner and its declared companions, not a project mirror. */
|
|
26
|
+
export function prepareUnitPatch(options, {unit, action = "create", draft}) {
|
|
27
|
+
validateUnitId(unit);
|
|
28
|
+
const inventory = inspectUnits(options);
|
|
29
|
+
if (action === "create") {
|
|
30
|
+
const owner = inventory.units.find(entry => entry.id === unit);
|
|
31
|
+
const files = [...new Set([...(owner?.files ?? []), ...(owner?.companionFiles ?? []).map(entry => entry.path),
|
|
32
|
+
...(!owner ? ["ds.config.json"] : []),
|
|
33
|
+
...(/^(?:components|patterns|controllers)\//.test(unit) ? [path.relative(options.root,path.join(options.dsRoot,options.config.publicEntry)).replaceAll("\\","/")] : [])])];
|
|
34
|
+
if (!owner && !/^(?:components|patterns|controllers|composition)\/.+/.test(unit)) throw new Error("Discover an existing unit or choose a new unoccupied component/pattern/controller/composition owner.");
|
|
35
|
+
if (/^(?:components|patterns|controllers|composition)\//.test(unit)) for (let directory=unit;directory!==".";directory=path.posix.dirname(directory)) {
|
|
36
|
+
const marker=path.join(options.dsRoot,directory,".gitkeep");
|
|
37
|
+
if (existsSync(marker) && realpathSync(marker)===marker && lstatSync(marker).isFile() && lstatSync(marker).size===0) {
|
|
38
|
+
const relative=path.relative(options.root,marker).replaceAll("\\","/");
|
|
39
|
+
if (!files.includes(relative)) files.push(relative);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
draft = randomUUID();
|
|
43
|
+
const base = generated(options,`patch-drafts/${draft}/manifest.json`), folder = path.join(path.dirname(base),"work");
|
|
44
|
+
const entries = [];
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const absolute = path.resolve(options.root,file);
|
|
48
|
+
if (!isInside(options.root,absolute) || realpathSync(absolute) !== absolute || !lstatSync(absolute).isFile()) throw new Error("Draft inputs must be ordinary project source files.");
|
|
49
|
+
if (lstatSync(absolute).size > 16 * 1024 * 1024) throw new Error("Draft input exceeds 16 MiB.");
|
|
50
|
+
const content = readFileSync(absolute);
|
|
51
|
+
bytes += content.length;
|
|
52
|
+
if (entries.length >= 256 || bytes > 32 * 1024 * 1024) throw new Error("Owner draft exceeds 256 files/32 MiB. Prepare a targeted Git patch instead.");
|
|
53
|
+
for (const name of ["work","base"]) {
|
|
54
|
+
const target = path.join(path.dirname(base),name,file);
|
|
55
|
+
mkdirSync(path.dirname(target),{recursive:true}); writeFileSync(target,content);
|
|
56
|
+
}
|
|
57
|
+
entries.push({file,digest:hash(content)});
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(folder,{recursive:true});
|
|
60
|
+
writeFileSync(base,JSON.stringify({schemaVersion:1,unit,entries}));
|
|
61
|
+
return {draft,unit,directory:folder,files, next:"Edit only this draft, then call ads_prepare_patch with action: diff and this draft ID. Live source is unchanged; the resulting Git patch still requires ads_apply_patch and all its gates."};
|
|
62
|
+
}
|
|
63
|
+
if (!/^[a-f0-9-]{36}$/.test(draft ?? "")) throw new Error("Use the draft ID returned by ads_prepare_patch.");
|
|
64
|
+
const base = path.join(options.root,".ds/patch-drafts",draft);
|
|
65
|
+
if (realpathSync(base) !== base) throw new Error("Draft paths cannot resolve through symlinks.");
|
|
66
|
+
const metadata = JSON.parse(readFileSync(path.join(base,"manifest.json"),"utf8"));
|
|
67
|
+
if (metadata.schemaVersion !== 1 || metadata.unit !== unit) throw new Error("Draft and selected unit do not match.");
|
|
68
|
+
if (action === "discard") {rmSync(base,{recursive:true});return {discarded:true,draft,unit};}
|
|
69
|
+
if (action !== "diff") throw new Error("Choose create, diff or discard.");
|
|
70
|
+
const files = new Set(metadata.entries.map(entry => entry.file));
|
|
71
|
+
function walk(directory) {
|
|
72
|
+
if (realpathSync(directory) !== directory) throw new Error("Draft directories cannot resolve through symlinks.");
|
|
73
|
+
for (const entry of readdirSync(directory,{withFileTypes:true})) {
|
|
74
|
+
const file = path.join(directory,entry.name);
|
|
75
|
+
if (entry.isDirectory()) walk(file);
|
|
76
|
+
else if (!entry.isFile() || lstatSync(file).nlink !== 1) throw new Error("Drafts contain ordinary unlinked source files only.");
|
|
77
|
+
else files.add(path.relative(path.join(base,"work"),file).replaceAll("\\","/"));
|
|
78
|
+
if (files.size > 256) throw new Error("Draft exceeds 256 files.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
walk(path.join(base,"work"));
|
|
82
|
+
const changes = [];
|
|
83
|
+
let patch = "", bytes = 0;
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!file || path.isAbsolute(file) || file.includes("\\") || file.split("/").some(part => !part || part === "." || part === ".." || part.startsWith(".") && part !== ".gitkeep" && !/^\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?$/.test(part))) throw new Error("Drafts use ordinary project-relative source paths.");
|
|
86
|
+
const original = path.join(base,"base",file), next = path.join(base,"work",file), live = path.join(options.root,file);
|
|
87
|
+
const read = file => {
|
|
88
|
+
if (!existsSync(file)) return null;
|
|
89
|
+
const stat = lstatSync(file);
|
|
90
|
+
if (realpathSync(file) !== file || !stat.isFile() || stat.nlink !== 1 || stat.size > 16 * 1024 * 1024) throw new Error("Draft inputs must be ordinary unlinked files no larger than 16 MiB.");
|
|
91
|
+
return readFileSync(file);
|
|
92
|
+
};
|
|
93
|
+
const expected = metadata.entries.find(entry => entry.file === file)?.digest ?? null;
|
|
94
|
+
const left = read(original), right = read(next), source = read(live);
|
|
95
|
+
if ((left && hash(left)) !== expected || (source && hash(source)) !== expected) throw new Error(`Source/baseline changed for ${file}; create a fresh owner draft.`);
|
|
96
|
+
if (left !== null && right !== null && left.equals(right) || left === null && right === null) continue;
|
|
97
|
+
bytes += (left?.length ?? 0) + (right?.length ?? 0);
|
|
98
|
+
if (bytes > 32 * 1024 * 1024) throw new Error("Draft changes exceed 32 MiB.");
|
|
99
|
+
const change = {path:file,originalContent:left === null ? null : binary(left) ? left : left.toString(),content:right === null ? null : binary(right) ? right : right.toString()};
|
|
100
|
+
changes.push(change);
|
|
101
|
+
if (!binary(left) && !binary(right)) patch += textDiff(file,change.originalContent,change.content);
|
|
102
|
+
else {
|
|
103
|
+
let diff;
|
|
104
|
+
try { diff = execFileSync("git",["diff","--no-index","--binary","--no-ext-diff","--no-textconv","--",left === null ? "/dev/null" : original,right === null ? "/dev/null" : next],{encoding:"utf8",maxBuffer:16*1024*1024}); }
|
|
105
|
+
catch (error) { if (error.status !== 1) throw error; diff=error.stdout; }
|
|
106
|
+
patch += diff.replace(/^diff --git .+$/m,`diff --git a/${file} b/${file}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (changes.length) checkUnitFiles(options,unit,changes,inventory,true);
|
|
110
|
+
return {unit,draft,changes:changes.map(entry=>entry.path),...storePreparedPatch(options,patch),next:"Apply with ads_apply_patch using this unit and patchFile. Preparation is not validation or publication."};
|
|
111
|
+
}
|