@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/dist/mcp/mutations.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { projectContext } from "./context.mjs";
|
|
|
13
13
|
import { applyFilePatch, parseUnifiedPatch } from "./patch.mjs";
|
|
14
14
|
import { checkUnitCandidate, checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
|
|
15
15
|
|
|
16
|
-
export const mutationLimits = { files: 32, patchBytes: 256 * 1024, patchFileBytes: 16 * 1024 * 1024, fileBytes: 16 * 1024 * 1024, readChars: 12000, snapshotFiles: 20000, snapshotBytes: 128 * 1024 * 1024 };
|
|
16
|
+
export const mutationLimits = { units: 8, files: 32, patchBytes: 256 * 1024, patchFileBytes: 16 * 1024 * 1024, fileBytes: 16 * 1024 * 1024, readChars: 12000, snapshotFiles: 20000, snapshotBytes: 128 * 1024 * 1024 };
|
|
17
17
|
const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
18
18
|
const ignored = new Set([".git", ".ds", "node_modules", ".vite", "coverage", ".playwright-cli", "test-results", "output"]);
|
|
19
19
|
const textFile = file => /\.(?:[cm]?[jt]sx?|css|html|jsonc?|md|txt|svg)$/.test(file);
|
|
@@ -29,7 +29,7 @@ function address(options, file) {
|
|
|
29
29
|
if (!isInside(options.root, target) || target === options.root) throw fail("mutation-path", "Mutation paths must stay inside the project.", file);
|
|
30
30
|
for (let current = options.root; ; ) {
|
|
31
31
|
const relative = path.relative(current, target).split(path.sep);
|
|
32
|
-
if (relative.some(part => ignored.has(part) || part.startsWith(".") && !setupDotfile(part))) throw fail("mutation-path", "Hidden, dependency and generated paths are not editable.", file);
|
|
32
|
+
if (relative.some(part => ignored.has(part) || part.startsWith(".") && !setupDotfile(part) && !(part === ".gitkeep" && path.basename(target) === part && isInside(options.dsRoot, target)))) throw fail("mutation-path", "Hidden, dependency and generated paths are not editable.", file);
|
|
33
33
|
current = path.join(current, relative[0]);
|
|
34
34
|
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
35
35
|
if (stat?.isSymbolicLink() || stat && current !== target && !stat.isDirectory() || stat && current === target && (!stat.isFile() || stat.nlink !== 1)) throw fail("mutation-path", "Mutation paths must contain ordinary directories and unlinked regular files.", file);
|
|
@@ -154,7 +154,7 @@ export function createMutationController(directory) {
|
|
|
154
154
|
let commands = [], diagnostics = [];
|
|
155
155
|
try { commands = checks(options).map(check => [check.command, ...check.args].join(" ")); }
|
|
156
156
|
catch (error) { diagnostics = [error.diagnostic ?? { rule: "mutation-check", message: error.message }]; }
|
|
157
|
-
return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, lastResult };
|
|
157
|
+
return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", transactions:{tool:"ads_apply_transaction",maxUnits:mutationLimits.units,finalCandidateChecks:true}, preparation:{owner:"ads_prepare_patch",tokenSamples:"ads_prepare_token_samples"}, dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, lastResult };
|
|
158
158
|
},
|
|
159
159
|
cancel() { active?.abort(); },
|
|
160
160
|
units() { return inspectUnits(loadConfig(root)).units; },
|
|
@@ -170,7 +170,7 @@ export function createMutationController(directory) {
|
|
|
170
170
|
return { ...source, unit, content, offset, nextOffset: end < source.content.length ? end : null, totalChars: source.content.length,
|
|
171
171
|
startLine: source.content.slice(0, offset).split("\n").length };
|
|
172
172
|
},
|
|
173
|
-
async apply({ unit, patch, patchFile, dryRun = false }, signal) {
|
|
173
|
+
async apply({ unit, patch, patchFile, patches, dryRun = false }, signal) {
|
|
174
174
|
if (busy) return { ok: false, applied: false, diagnostics: [{ rule: "mutation-busy", message: "Another mutation is validating; retry after it finishes." }] };
|
|
175
175
|
busy = true;
|
|
176
176
|
active = new AbortController();
|
|
@@ -179,26 +179,22 @@ export function createMutationController(directory) {
|
|
|
179
179
|
const verified = [];
|
|
180
180
|
try {
|
|
181
181
|
const options = loadConfig(root);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
if (entry.binary) change.binaryPatch = entry.patch;
|
|
195
|
-
change.expectedDigest = before.digest;
|
|
196
|
-
change.originalContent = before.content;
|
|
197
|
-
if (!entry.binary) validateContent(change, unit);
|
|
198
|
-
changes.push(change);
|
|
199
|
-
return { ...before, mode: before.exists ? lstatSync(target).mode : undefined };
|
|
182
|
+
if (patches !== undefined && (unit !== undefined || patch !== undefined || patchFile !== undefined || !Array.isArray(patches) || patches.length < 2 || patches.length > mutationLimits.units)) throw fail("mutation-batch", "A transaction requires 2–8 distinct owner patches, without a top-level unit/patch.");
|
|
183
|
+
const groups = patches ?? [{unit,patch,patchFile}];
|
|
184
|
+
if (new Set(groups.map(group => group.unit)).size !== groups.length) throw fail("mutation-batch", "Use one patch per distinct unit in a transaction.");
|
|
185
|
+
let patchBytes = 0;
|
|
186
|
+
const plans = groups.map(group => {
|
|
187
|
+
validateUnitId(group.unit);
|
|
188
|
+
if ((typeof group.patch === "string") === (typeof group.patchFile === "string")) throw fail("mutation-patch", "Provide exactly one of patch or patchFile for each owner.");
|
|
189
|
+
const value = group.patchFile === undefined ? group.patch : readPatchFile(options,group.patchFile);
|
|
190
|
+
const maxPatch = group.patchFile === undefined ? mutationLimits.patchBytes : mutationLimits.patchFileBytes;
|
|
191
|
+
patchBytes += Buffer.byteLength(value ?? "");
|
|
192
|
+
if (typeof value !== "string" || value.includes("\0") || Buffer.from(value).toString("utf8") !== value || Buffer.byteLength(value) > maxPatch || patchBytes > mutationLimits.patchFileBytes) throw fail("mutation-patch", "Patch exceeds its UTF-8 Git diff size limit.");
|
|
193
|
+
return {unit:group.unit,entries:parseUnifiedPatch(value,mutationLimits.files)};
|
|
200
194
|
});
|
|
201
|
-
const
|
|
195
|
+
const units = groups.map(group => group.unit), configuration = units.includes("configuration"), assets = units.some(unit => unit.startsWith("assets"));
|
|
196
|
+
for (const plan of plans) for (const entry of plan.entries) address(options,entry.path);
|
|
197
|
+
const changes = [], originals = [], byPath = new Map();
|
|
202
198
|
const cache = path.join(root, ".ds");
|
|
203
199
|
mkdirSync(cache, { recursive: true });
|
|
204
200
|
if (realpathSync(cache) !== path.join(realpathSync(root), ".ds")) throw fail("mutation-path", "The .ds directory cannot resolve through a symlink.");
|
|
@@ -206,7 +202,6 @@ export function createMutationController(directory) {
|
|
|
206
202
|
const candidate = path.join(temporary, "project");
|
|
207
203
|
mkdirSync(candidate);
|
|
208
204
|
const before = snapshot(options, candidate);
|
|
209
|
-
for (const original of originals) if (read(options, original.path).digest !== original.digest) throw fail("mutation-conflict", "Source changed while preparing the validation copy. Read the affected range and regenerate the patch.", original.path);
|
|
210
205
|
const dependencies = path.join(root, "node_modules");
|
|
211
206
|
if (!existsSync(dependencies)) throw fail("mutation-check", "Install project dependencies before using guarded mutations.");
|
|
212
207
|
symlinkSync(realpathSync(dependencies), path.join(candidate, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
@@ -222,24 +217,45 @@ export function createMutationController(directory) {
|
|
|
222
217
|
return true;
|
|
223
218
|
} });
|
|
224
219
|
}
|
|
220
|
+
const entries = plans.flatMap(plan => plan.entries);
|
|
225
221
|
if (entries.some(entry => entry.binary)) {
|
|
226
222
|
const formats = new Set(entries.filter(entry=>entry.binary).map(entry=>entry.objectFormat));
|
|
227
223
|
if (formats.size !== 1) throw fail("mutation-patch", "Binary blocks in one Git diff must use the same object format.");
|
|
228
224
|
await checkCommand("git", ["init", "--quiet", `--object-format=${[...formats][0]}`, candidate], root, signal);
|
|
229
225
|
}
|
|
230
|
-
for (const
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
change.content
|
|
236
|
-
validateContent(change,
|
|
226
|
+
for (const plan of plans) {
|
|
227
|
+
const current = loadConfig(candidate), selected = plan.entries.map(entry => {
|
|
228
|
+
address(options,entry.path);
|
|
229
|
+
const before = read(current,entry.path);
|
|
230
|
+
if (!entry.binary && before.binary) throw fail("mutation-patch", "Binary assets require a git diff --binary patch.", entry.path);
|
|
231
|
+
const change = {path:entry.path, originalContent:before.content, content:entry.binary ? entry.operation === "delete" ? null : before.content ?? Buffer.alloc(0) : applyFilePatch(entry,before.content), ...(entry.binary ? {binaryPatch:entry.patch} : {})};
|
|
232
|
+
if (!entry.binary) validateContent(change,plan.unit);
|
|
233
|
+
if (!byPath.has(entry.path)) {
|
|
234
|
+
const original = read(options,entry.path);
|
|
235
|
+
// The candidate copy and live inputs must agree before any changes.
|
|
236
|
+
if (original.digest !== before.digest) throw fail("mutation-conflict", "Source changed while preparing validation; regenerate the patch.", entry.path);
|
|
237
|
+
originals.push({...original,mode:original.exists ? lstatSync(address(options,entry.path)).mode : undefined});
|
|
238
|
+
byPath.set(entry.path,changes.length);
|
|
239
|
+
changes.push({...change,expectedDigest:original.digest});
|
|
240
|
+
if (changes.length > mutationLimits.files) throw fail("mutation-batch", "A transaction may change at most 32 distinct files.");
|
|
241
|
+
}
|
|
242
|
+
return change;
|
|
243
|
+
});
|
|
244
|
+
const inventory = checkUnitFiles(current,plan.unit,selected,inspectUnits(current),true);
|
|
245
|
+
for (const change of selected) {
|
|
246
|
+
const file = path.join(candidate,change.path);
|
|
247
|
+
if (change.binaryPatch) {
|
|
248
|
+
for (const match of change.binaryPatch.matchAll(/^(?:literal|delta) (\d+)$/gm)) if (Number(match[1]) > mutationLimits.fileBytes) throw fail("mutation-content", "Binary patch blocks exceed 16 MiB.", change.path);
|
|
249
|
+
await checkCommand("git",["apply","--binary","--whitespace=nowarn","-"],candidate,signal,change.binaryPatch);
|
|
250
|
+
change.content = existsSync(file) ? readFileSync(file) : null;
|
|
251
|
+
validateContent(change,plan.unit);
|
|
252
|
+
} else if (change.content === null) rmSync(file);
|
|
253
|
+
else {mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,change.content);}
|
|
254
|
+
changes[byPath.get(change.path)].content = change.content;
|
|
237
255
|
}
|
|
238
|
-
|
|
239
|
-
else { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, change.content); }
|
|
256
|
+
checkUnitCandidate(loadConfig(candidate),plan.unit,selected,inventory);
|
|
240
257
|
}
|
|
241
258
|
const candidateOptions = loadConfig(candidate);
|
|
242
|
-
checkUnitCandidate(candidateOptions, unit, changes, inventory);
|
|
243
259
|
const extraChecks = checks(candidateOptions);
|
|
244
260
|
const expected = snapshot(candidateOptions);
|
|
245
261
|
const result = checkProject(candidate, { trackingRoot: root });
|
|
@@ -251,12 +267,12 @@ export function createMutationController(directory) {
|
|
|
251
267
|
const bin = JSON.parse(readFileSync(oxlintPackage, "utf8")).bin;
|
|
252
268
|
const executable = path.resolve(path.dirname(oxlintPackage), typeof bin === "string" ? bin : bin.oxlint);
|
|
253
269
|
const lintPaths = [candidateOptions.config.sourceDir, candidateOptions.config.dsDir].filter(file => existsSync(path.join(candidate, file)));
|
|
254
|
-
if (
|
|
270
|
+
if (configuration) for (const file of inspectUnits(candidateOptions).units.find(item => item.id === "configuration").files) {
|
|
255
271
|
if (/\.[cm]?[jt]sx?$/.test(file) && !lintPaths.some(directory => isInside(path.resolve(candidate, directory), path.resolve(candidate, file)))) lintPaths.push(file);
|
|
256
272
|
}
|
|
257
273
|
await checkCommand(process.execPath, [executable, "--deny-warnings", "--config", lintConfig, ...lintPaths], candidate, signal);
|
|
258
274
|
verified.push({ name: "registered Oxlint rules", passed: true });
|
|
259
|
-
if (
|
|
275
|
+
if (assets || configuration) {
|
|
260
276
|
const brand = await generateBrandAssets(candidate);
|
|
261
277
|
verified.push({ name: "brand assets", passed: true, skipped: !!brand.skipped });
|
|
262
278
|
}
|
|
@@ -300,8 +316,8 @@ export function createMutationController(directory) {
|
|
|
300
316
|
}
|
|
301
317
|
}
|
|
302
318
|
const warnings = [];
|
|
303
|
-
if (!dryRun && (
|
|
304
|
-
return lastResult = { ok: true, unit, applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
|
|
319
|
+
if (!dryRun && (assets || configuration)) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
|
|
320
|
+
return lastResult = { ok: true, ...(patches ? {units,transaction:true} : {unit}), applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
|
|
305
321
|
preview: "Source validation passed. Inspect the managed preview build and rendered scenario after applying; validation is not visual verification." };
|
|
306
322
|
} catch (error) {
|
|
307
323
|
return lastResult = { ok: false, applied: false, diagnostics: [error.diagnostic ?? { rule: "mutation-error", message: error.message }], checks: verified };
|
|
@@ -4,6 +4,7 @@ import { statSync, mkdirSync, writeFileSync, renameSync, rmSync, realpathSync }
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { buildFailure, inspectBuildStatus } from "./build-status.mjs";
|
|
7
|
+
import { previewSessionDirectories, withPreviewSession } from "../preview-scope.mjs";
|
|
7
8
|
|
|
8
9
|
function stamp(file) {
|
|
9
10
|
try { const stat = statSync(file); return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; }
|
|
@@ -160,23 +161,27 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
const status = () => ({ state, ...(stalled ? { stalled: true } : {}), ...(child?.pid ? { pid: child.pid } : {}), ...(ownerGuard?.pid ? { guardPid: ownerGuard.pid } : {}), ...(error ? { error } : {}) });
|
|
163
|
-
const
|
|
164
|
+
const resourceSession = () => command && config.previewManifest ? session : undefined;
|
|
165
|
+
const buildStatus = () => inspectBuildStatus(root, config, command ? { session, previewSession: resourceSession(), file: reportFile, processState: status() } : {});
|
|
166
|
+
function clearOutput() {
|
|
167
|
+
for (const directory of previewSessionDirectories(withPreviewSession({ root, config: { mcp: config } }, resourceSession()))) rmSync(directory, { recursive: true, force: true });
|
|
168
|
+
}
|
|
164
169
|
|
|
165
170
|
async function stopChild() {
|
|
166
171
|
rmSync(requestFile, { force: true });
|
|
167
172
|
if (stopping) return stopping;
|
|
168
|
-
if (!child || childClosed) { if (command) state = "stopped"; rmSync(reportFile, { force: true }); return; }
|
|
173
|
+
if (!child || childClosed) { if (command) state = "stopped"; rmSync(reportFile, { force: true }); clearOutput(); return; }
|
|
169
174
|
if (state === "stopped") return;
|
|
170
175
|
state = "stopping";
|
|
171
176
|
stopping = (async () => {
|
|
172
177
|
kill("SIGTERM");
|
|
173
178
|
clearTimeout(killTimer);
|
|
174
179
|
const timer = setTimeout(() => kill("SIGKILL"), 1500);
|
|
175
|
-
try { await closed; } finally { clearTimeout(timer); rmSync(reportFile, { force: true }); }
|
|
180
|
+
try { await closed; } finally { clearTimeout(timer); rmSync(reportFile, { force: true }); clearOutput(); }
|
|
176
181
|
})();
|
|
177
182
|
return stopping;
|
|
178
183
|
}
|
|
179
|
-
return { start, ensure, async stop() { disposed = true; await restarting; return stopChild(); }, kill: () => kill("SIGKILL"), status, buildStatus, setApps(apps) {
|
|
184
|
+
return { start, ensure, async stop() { disposed = true; await restarting; return stopChild(); }, kill: () => kill("SIGKILL"), status, resourceSession, buildStatus, setApps(apps) {
|
|
180
185
|
for (const app of apps) {
|
|
181
186
|
const file = path.resolve(root, app.file);
|
|
182
187
|
if (!baselines.has(file)) baselines.set(file, stamp(file));
|
|
@@ -195,6 +200,7 @@ export function createReconfigurablePreviewWatch(root, config, options) {
|
|
|
195
200
|
start() { if (!stopped && !replacing) current.start(); },
|
|
196
201
|
async ensure(file, signal) { await pending; if (stopped) throw new Error("Preview watcher is stopped."); return current.ensure(file, signal); },
|
|
197
202
|
status() { return replacing ? { state: "reconfiguring" } : current.status(); },
|
|
203
|
+
resourceSession() { return current.resourceSession(); },
|
|
198
204
|
buildStatus() { return current.buildStatus(); },
|
|
199
205
|
setApps(next) { apps = next; current.setApps(next); },
|
|
200
206
|
kill() { current.kill(); },
|
package/dist/mcp/server.mjs
CHANGED
|
@@ -13,11 +13,14 @@ import { createSkillStore, page, SKILLS_EXTENSION } from "./skills.mjs";
|
|
|
13
13
|
import { inspectCatalog, projectDocuments, PROJECT_URI, readProjectDocument } from "./catalog.mjs";
|
|
14
14
|
import { APP_MIME, appMetadata, appUri, projectApp, TOKENS_APP_URI, tokensApp, tokenWidgetData, SCENARIO_APP_URI, scenarioApp, previewApp, PREVIEW_APP_URI } from "./apps.mjs";
|
|
15
15
|
import { PREVIEW_MANIFEST_URI, readPreviewManifest, readPreviewAsset } from "../preview-resources.mjs";
|
|
16
|
+
import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
|
|
16
17
|
import { getWorkflowPrompt, promptDefinitions } from "./prompts.mjs";
|
|
17
18
|
import { createReconfigurablePreviewWatch } from "./preview-watch.mjs";
|
|
18
19
|
import { BUILD_URI, buildFailure, inspectBuildStatus, summarizeBuild } from "./build-status.mjs";
|
|
19
20
|
import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow } from "./context.mjs";
|
|
20
21
|
import { createMutationController, mutationLimits } from "./mutations.mjs";
|
|
22
|
+
import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
|
|
23
|
+
import { reconcileTokenSamples } from "../token-fixtures.mjs";
|
|
21
24
|
|
|
22
25
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
23
26
|
class InvalidParamsError extends ProtocolError {
|
|
@@ -28,6 +31,7 @@ const empty = z.object({}).strict();
|
|
|
28
31
|
const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
|
|
29
32
|
const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
30
33
|
const schemas = { params: z.object({ cursor: z.string().optional() }).passthrough(), result: z.object({ resultType: z.literal("complete") }).passthrough() };
|
|
34
|
+
const ownerPatch = z.object({ unit: z.string().min(1).max(256), patch: z.string().min(1).max(mutationLimits.patchBytes).optional(), patchFile: z.string().min(1).max(500).optional() }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile.");
|
|
31
35
|
|
|
32
36
|
export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollInterval = 1000, previewWatch, showcaseUrl, notifications, inspection, mutations = false } = {}) {
|
|
33
37
|
const root = path.resolve(directory);
|
|
@@ -54,13 +58,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
54
58
|
const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
|
|
55
59
|
const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
|
|
56
60
|
const scenarioWidget = () => options.config.mcp.previewManifest ? PREVIEW_APP_URI : SCENARIO_APP_URI;
|
|
61
|
+
const previewOptions = () => withPreviewSession(options, previewWatch?.resourceSession?.());
|
|
57
62
|
const resourceAssets = () => {
|
|
58
|
-
try { return Object.entries(readPreviewManifest(
|
|
63
|
+
try { return Object.entries(readPreviewManifest(previewOptions())?.assets ?? {}).map(([uri, record]) => ({ uri, name: `Preview asset ${uri.slice("ads://assets/".length)}`, mimeType: record.mimeType, size: record.size })); }
|
|
59
64
|
catch { return []; } // Discovery and diagnostics remain usable after a failed graph.
|
|
60
65
|
};
|
|
61
66
|
function graphSelection(id) {
|
|
62
67
|
if (!options.config.mcp.previewManifest) return {};
|
|
63
|
-
const manifest = readPreviewManifest(
|
|
68
|
+
const manifest = readPreviewManifest(previewOptions());
|
|
64
69
|
if (!manifest?.scenarios.some(entry => entry.id === id)) throw new Error("The preview resource graph is not ready for this scenario. Rebuild previews.");
|
|
65
70
|
return { manifestUri: PREVIEW_MANIFEST_URI, manifestRevision: manifest.revision };
|
|
66
71
|
}
|
|
@@ -80,6 +85,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
80
85
|
return [
|
|
81
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" },
|
|
82
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" },
|
|
83
89
|
{ uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
|
|
84
90
|
{ uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
|
|
85
91
|
{ uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
|
|
@@ -87,13 +93,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
87
93
|
{ uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
|
|
88
94
|
{ uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
|
|
89
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),
|
|
90
97
|
{ uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
|
|
91
98
|
{ uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
|
|
92
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" }] : []),
|
|
93
100
|
...(options.config.mcp.previewManifest ? resourceAssets() : []),
|
|
94
101
|
...allApps().map((app) => ({ uri: appUri(app.id), name: app.title, description: app.description, mimeType: APP_MIME })),
|
|
95
102
|
...skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description, mimeType: "text/markdown" })),
|
|
96
|
-
...documents.filter((document) => promoted.has(document.uri)).map(({ file: _file, ...document }) => document),
|
|
97
103
|
...documents.filter((document) => !promoted.has(document.uri)).map(({ file: _file, ...document }) => document),
|
|
98
104
|
...[...skills.files.values()].filter((file) => !skillEntries.has(file.uri)).map(({ uri, name, mimeType }) => ({ uri, name, mimeType })),
|
|
99
105
|
];
|
|
@@ -116,6 +122,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
116
122
|
async function read(uri, signal) {
|
|
117
123
|
if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
|
|
118
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") }] };
|
|
119
126
|
if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
|
|
120
127
|
let value;
|
|
121
128
|
if (uri === `${PROJECT_URI}/status`) value = status();
|
|
@@ -126,7 +133,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
126
133
|
else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
|
|
127
134
|
else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
|
|
128
135
|
else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
|
|
129
|
-
value = readPreviewManifest(
|
|
136
|
+
value = readPreviewManifest(previewOptions());
|
|
130
137
|
if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
|
|
131
138
|
}
|
|
132
139
|
if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
|
|
@@ -134,7 +141,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
134
141
|
if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
|
|
135
142
|
if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
|
|
136
143
|
if (uri.startsWith("ads://assets/") && options.config.mcp.previewManifest) {
|
|
137
|
-
try { return { contents: [readPreviewAsset(
|
|
144
|
+
try { return { contents: [readPreviewAsset(previewOptions(), uri)] }; }
|
|
138
145
|
catch (error) { if (error.code === "ADS_PREVIEW_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
|
|
139
146
|
}
|
|
140
147
|
const app = allApps().find((entry) => appUri(entry.id) === uri);
|
|
@@ -157,6 +164,16 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
157
164
|
return build;
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
async function applyEdits(args,signal) {
|
|
168
|
+
const result = await edits.apply(args,signal);
|
|
169
|
+
if (result.applied) {
|
|
170
|
+
try {
|
|
171
|
+
if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration" || result.units?.includes("configuration")) await previewWatch?.reconfigure?.(loadConfig(root).config.mcp,true);
|
|
172
|
+
await refresh(true);
|
|
173
|
+
} catch (error) {result.warnings=[...(result.warnings ?? []),{rule:"mutation-notification",message:`Changes were applied, but preview refresh failed: ${error.message.slice(0,2000)}. Recheck ads_status and reopen the scenario.`}];}
|
|
174
|
+
}
|
|
175
|
+
return {...json(result),...(!result.ok ? {isError:true} : {})};
|
|
176
|
+
}
|
|
160
177
|
function tools() {
|
|
161
178
|
return [
|
|
162
179
|
{ name: "ads_status", description: "Start here for UI/ADS work: components, patterns, tokens/themes/branding, layout, behavior, motion and showcase review. Returns the exact project root/public API, workflow, project commands, editing mode and build diagnostics. When mutations.enabled, use guarded ads_apply_patch for ADS edits; otherwise use local editing tools.", schema: empty, call: () => json(status()) },
|
|
@@ -170,7 +187,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
170
187
|
const units = edits.units();
|
|
171
188
|
return json({ total: matches.length, scenarios: matches.slice(offset, offset + limit).map(item => ({ ...item, unit: units.find(unit => unit.files.includes(item.file))?.id ?? null })), ...(offset + limit < matches.length ? { nextOffset: offset + limit } : {}), diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios` });
|
|
172
189
|
} },
|
|
173
|
-
{ name: "ads_show_scenario", description: "Render one real token, component, pattern or composition scenario with its colocated fixtures and state adapter. First get an exact id from ads_scenarios or ads_catalog.previews. The managed compiler builds it on demand; errors return diagnostics instead of falsely current output. Requires MCP Apps plus host resource forwarding; fallback links to the same scenario in the project showcase.", schema: z.object({ id: z.string().min(1).max(256) }).strict(), _meta: { ui: { resourceUri: scenarioWidget() } }, call: async ({ id }, signal) => {
|
|
190
|
+
{ name: "ads_show_scenario", title: "Scenario preview", description: "Render one real token, component, pattern or composition scenario with its colocated fixtures and state adapter. First get an exact id from ads_scenarios or ads_catalog.previews. The managed compiler builds it on demand; errors return diagnostics instead of falsely current output. Requires MCP Apps plus host resource forwarding; fallback links to the same scenario in the project showcase.", schema: z.object({ id: z.string().min(1).max(256) }).strict(), _meta: { ui: { resourceUri: scenarioWidget() } }, call: async ({ id }, signal) => {
|
|
174
191
|
const entry = getRegistry().scenarios.find(item => item.id === id);
|
|
175
192
|
if (!entry) throw new Error("Unknown scenario id. Read ads_scenarios; IDs must match discovered source entries.");
|
|
176
193
|
if (getRegistry().diagnostics.length) throw new Error("Scenario registry has errors. Read ads://project/scenarios and fix its diagnostics.");
|
|
@@ -188,7 +205,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
188
205
|
const matches = inventory.tokens.filter((token) => token.name.toLowerCase().includes(search.toLowerCase()));
|
|
189
206
|
return json({ total: matches.length, tokens: matches.slice(0, limit), categories: tokenScenarios(), diagnosticCount: inventory.diagnostics.length, resourceUri: `${PROJECT_URI}/tokens` });
|
|
190
207
|
} },
|
|
191
|
-
{ 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 review advisories. Atomic composition warnings identify overlapping properties and link ads://guides/atomic-styles for causes and fixes. 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 }) => {
|
|
192
209
|
const result = checkProject(root);
|
|
193
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` });
|
|
194
211
|
} },
|
|
@@ -203,7 +220,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
203
220
|
if (!("text" in content)) return json({ ...manifest, mimeType: content.mimeType, binary: true, note: "Read binary bytes via resources/read; this tool does not embed them." });
|
|
204
221
|
return json({ ...manifest, text: content.text.slice(offset, offset + limit), offset, ...(offset + limit < content.text.length ? { nextOffset: offset + limit } : {}) });
|
|
205
222
|
} },
|
|
206
|
-
{ name: "ads_show_tokens", description: "Render an exact discovered token scenario with its project-owned templates and fixtures. Pass its id as category, discovered through ads_tokens.categories or ads_scenarios(kind: tokens). Omit category for the first project token scenario, or a generic definition inspector for older compilers.", schema: z.object({ category: z.string().max(256).optional() }).strict(), _meta: { ui: { resourceUri: options.config.mcp.previewManifest ? PREVIEW_APP_URI : TOKENS_APP_URI } }, call: async ({ category }, signal) => {
|
|
223
|
+
{ name: "ads_show_tokens", title: "Token previews", description: "Render an exact discovered token scenario with its project-owned templates and fixtures. Pass its id as category, discovered through ads_tokens.categories or ads_scenarios(kind: tokens). Omit category for the first project token scenario, or a generic definition inspector for older compilers.", schema: z.object({ category: z.string().max(256).optional() }).strict(), _meta: { ui: { resourceUri: options.config.mcp.previewManifest ? PREVIEW_APP_URI : TOKENS_APP_URI } }, call: async ({ category }, signal) => {
|
|
207
224
|
if (!category && options.config.mcp.previewManifest) category = tokenScenarios()[0]?.id;
|
|
208
225
|
if (!category && options.config.mcp.previewManifest) throw new Error("No token scenario is registered. Colocate a token scenario and fixture before opening the preview; ads_tokens still inspects definitions.");
|
|
209
226
|
if (category) {
|
|
@@ -243,22 +260,17 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
243
260
|
return json({ total: matches.length, units: matches.slice(0, limit).map(item => ({ id: item.id, kind: item.kind, fileCount: item.files.length, ...(unit ? { files: item.files.slice(0, 200), filesTruncated: item.files.length > 200, ...(item.companionFiles ? { companionFiles: item.companionFiles } : {}) } : {}) })) });
|
|
244
261
|
} },
|
|
245
262
|
{ name: "ads_read_file", description: "Read a bounded range of ADS or registered configuration/extension source. Returns unit, full-file digest, startLine and nextOffset; default/max is 12000 UTF-16 characters. Binary assets return byte count/digest and no encoded content. Paths come from ads_units or discovery. Missing new paths return digest null. Read only relevant context before preparing a Git patch.", schema: z.object({ path: z.string().min(1).max(500), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(mutationLimits.readChars).default(mutationLimits.readChars) }).strict(), call: ({ path: file, ...range }) => json(edits.read(file, range)) },
|
|
246
|
-
...(mutations ? [{ name: "
|
|
263
|
+
...(mutations ? [{ name: "ads_prepare_patch", description: "Prepare a Git patch without a separate project mirror. Create copies only one owner plus its declared companions into a local draft; edit that directory with local file tools. Diff checks baseline/source freshness and owner paths, then writes a patchFile under .ds/patches. Discard removes the draft. Live source is never changed; ads_apply_patch still validates all gates. Remote clients can continue sending inline Git diffs instead.", schema:z.object({unit:z.string().min(1).max(256),action:z.enum(["create","diff","discard"]).default("create"),draft:z.string().max(100).optional()}).strict(), call:args=>json(prepareUnitPatch(options,args)) },
|
|
264
|
+
{name:"ads_prepare_token_samples",description:"Reconcile a token scenario's consumed literal fixture samples. Removes undeclared tokens, preserves existing project bindings, and accepts explicit real-token/property/probe bindings. Reports unresolved token names; never guesses CSS meaning from token names. Returns an owner patchFile without changing source. Apply it through ads_apply_patch and review the real preview.",schema:z.object({id:z.string().min(1).max(256),bindings:z.array(z.object({token:z.string().max(256),property:z.string().max(100),probe:z.string().max(1000)}).passthrough()).max(1000).optional()}).strict(),call:args=>{
|
|
265
|
+
const {patch,...result}=reconcileTokenSamples(options,args);
|
|
266
|
+
return json({...result,removed:result.removed.slice(0,50),unresolved:result.unresolved.slice(0,50),unresolvedCount:result.unresolved.length,...storePreparedPatch(options,patch)});
|
|
267
|
+
}},
|
|
268
|
+
{ name: "ads_apply_patch", description: "Apply one Git diff to one ADS unit from ads_units. Pass exactly one of patch (inline) or patchFile (a regular .patch/.diff under .ds/patches/, for large/binary diffs without model-context bytes). Text uses --- a/path, +++ b/path and exact @@ hunks; /dev/null creates/deletes. Git binary patches require git diff --binary --full-index and installed Git. A unit owns its implementation, previews and fixtures; imported peers are separate. Foundation, documentation, assets and configuration are editable too. Configuration changes may register/edit project guards and builders; the proposed schema, mandatory core/Oxlint and candidate project checks must pass. Only selected-unit public re-exports may change; preserve unrelated statements. A real selected-unit re-export may remove a redundant export {}; placeholder. A tokens patch may include its companionFiles foundation entry only to add/remove canonical imports required by token files created/deleted in this patch, preserving other imports/comments and canonical order. A runtime owner patch may also include its ds.config.json companion solely for that owner's inlineStyleFiles/styleWrites entries alongside its source. Unused permissions fail validation. Set dryRun true to validate without writing. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance.",
|
|
247
269
|
schema: z.object({ unit: z.string().min(1).max(256), dryRun: z.boolean(), patch: z.string().min(1).max(mutationLimits.patchBytes).optional(), patchFile: z.string().min(1).max(500).optional() }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile."),
|
|
248
270
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
|
|
249
|
-
call:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
|
|
254
|
-
await refresh(true);
|
|
255
|
-
}
|
|
256
|
-
catch (error) { result.warnings = [...(result.warnings ?? []), { rule: "mutation-notification", message: `Changes were applied, but preview refresh failed: ${error.message.slice(0, 2000)}. Recheck ads_status and reopen the scenario.` }]; }
|
|
257
|
-
}
|
|
258
|
-
return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
|
|
259
|
-
},
|
|
260
|
-
}] : []),
|
|
261
|
-
...options.config.mcp.apps.map((app) => ({ name: `ads_show_${app.id.replaceAll("-", "_")}`, description: `${app.description ?? app.title}. Renders the project's built ${app.kind} examples through MCP Apps; text fallback provides the showcase URL.`, schema: empty, _meta: { ui: { resourceUri: appUri(app.id) } }, call: async (_args, signal) => {
|
|
271
|
+
call: applyEdits,
|
|
272
|
+
}, {name:"ads_apply_transaction",description:"Apply genuinely coupled ADS changes as 2–8 distinct owner Git patches. Each patch must obey its unit and narrow companion rules. Patches run in order; later patches use the earlier candidate's shared-entry content. Core guards, registered Oxlint/project checks and affected behavior checks validate the complete final candidate before any live source is written. Maximum 32 distinct files/16 MiB combined. Dry runs, failures and cancellation publish nothing; publication rolls back on write failure. Prefer ads_apply_patch for independent edits.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean()}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
|
|
273
|
+
...options.config.mcp.apps.map((app) => ({ name: `ads_show_${app.id.replaceAll("-", "_")}`, title: app.title, description: `${app.description ?? app.title}. Renders the project's built ${app.kind} examples through MCP Apps; text fallback provides the showcase URL.`, schema: empty, _meta: { ui: { resourceUri: appUri(app.id) } }, call: async (_args, signal) => {
|
|
262
274
|
// Validate readiness without embedding executable HTML in model results.
|
|
263
275
|
const build = await previewBuild(app, signal);
|
|
264
276
|
projectApp(options, app);
|
|
@@ -320,7 +332,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
320
332
|
}).join("\n");
|
|
321
333
|
}
|
|
322
334
|
const fingerprint = () => fileFingerprint([...sourceFiles(options.dsRoot), ...getDocuments().map((doc) => doc.file), path.join(root, "ds.config.json"), path.join(root, "package.json"), path.join(root, ".ds/behavior-results.json")]);
|
|
323
|
-
const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, app.file)));
|
|
335
|
+
const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, previewArtifactPath(previewOptions(), app.file))));
|
|
324
336
|
const assetFingerprint = () => resourceAssets().map(asset => asset.uri).sort().join("\n");
|
|
325
337
|
let previous = fingerprint();
|
|
326
338
|
let previousArtifacts = artifactFingerprint();
|
|
@@ -410,5 +422,5 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
|
|
|
410
422
|
}
|
|
411
423
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, disconnect);
|
|
412
424
|
process.once("exit", forceCleanup);
|
|
413
|
-
return { close, url: host?.url };
|
|
425
|
+
return { close, url: host?.url, previewRevision: () => readPreviewManifest(withPreviewSession(loadConfig(root), previewWatch.resourceSession()))?.revision ?? null };
|
|
414
426
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One exact Git-style hunk, retaining only shared context around the change. */
|
|
2
|
+
export function textDiff(file, before, after) {
|
|
3
|
+
if (before === after) return "";
|
|
4
|
+
const lines = value => value?.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
5
|
+
const emit = (prefix, line) => prefix + line.replace(/\n$/, "") + "\n" + (line.endsWith("\n") ? "" : "\\n");
|
|
6
|
+
const left = lines(before), right = lines(after);
|
|
7
|
+
let common = 0, suffix = 0;
|
|
8
|
+
while (common < left.length && common < right.length && left[common] === right[common]) common++;
|
|
9
|
+
while (suffix < left.length - common && suffix < right.length - common && left.at(-suffix - 1) === right.at(-suffix - 1)) suffix++;
|
|
10
|
+
const start = Math.max(0, common - 3), tail = Math.min(3, suffix);
|
|
11
|
+
const oldCount = left.length - suffix + tail - start, newCount = right.length - suffix + tail - start;
|
|
12
|
+
const headers = `--- ${before === null ? "/dev/null" : "a/" + file}\n+++ ${after === null ? "/dev/null" : "b/" + file}\n`;
|
|
13
|
+
if (!oldCount && !newCount) return headers;
|
|
14
|
+
return headers + `@@ -${oldCount ? start + 1 : start},${oldCount} +${newCount ? start + 1 : start},${newCount} @@\n`
|
|
15
|
+
+ left.slice(start, common).map(line => emit(" ", line)).join("")
|
|
16
|
+
+ left.slice(common, left.length - suffix).map(line => emit("-", line)).join("")
|
|
17
|
+
+ right.slice(common, right.length - suffix).map(line => emit("+", line)).join("")
|
|
18
|
+
+ left.slice(left.length - suffix, left.length - suffix + tail).map(line => emit(" ", line)).join("");
|
|
19
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import ts from "typescript";
|
|
5
5
|
import valueParser from "postcss-value-parser";
|
|
6
6
|
import { isInside } from "./config.mjs";
|
|
7
|
+
import { previewArtifactPath, previewManifestPath } from "./preview-scope.mjs";
|
|
7
8
|
|
|
8
9
|
export const PREVIEW_APP_URI = "ui://ads/preview";
|
|
9
10
|
export const PREVIEW_MANIFEST_URI = "ads://preview/manifest";
|
|
@@ -118,11 +119,41 @@ export function createPreviewResources(output, options, entries) {
|
|
|
118
119
|
const graph = { schemaVersion: 1, framework: options.config.framework, assets: records, scenarios };
|
|
119
120
|
const revision = hash(JSON.stringify(graph));
|
|
120
121
|
validateManifest({ ...graph, revision });
|
|
121
|
-
|
|
122
|
+
return publication(options, entries, { ...graph, revision }, contents);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function previewArtifactHtml(id, revision) {
|
|
126
|
+
const descriptor = JSON.stringify({ schemaVersion: 1, manifestUri: PREVIEW_MANIFEST_URI, revision, id });
|
|
127
|
+
return `<!doctype html><html><head></head><body><script type="application/json" id="ads-preview-resource">${descriptor.replaceAll("<", "\\u003c")}</script></body></html>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cacheOptions(options, revision) {
|
|
131
|
+
return { ...options, previewSession: undefined, config: { ...options.config, mcp: { ...options.config.mcp, previewManifest: `.ds/preview-graphs/${revision}/manifest.json` } } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A cache hit still verifies every compiled byte before republishing it. */
|
|
135
|
+
export function cachedPreviewPublication(options, entries, revision) {
|
|
136
|
+
if (!/^[a-f0-9]{64}$/.test(revision ?? "")) return null;
|
|
137
|
+
try {
|
|
138
|
+
const cached = cacheOptions(options, revision), manifest = readPreviewManifest(cached, { verify: true });
|
|
139
|
+
if (!manifest || manifest.revision !== revision || manifest.framework !== options.config.framework
|
|
140
|
+
|| JSON.stringify(manifest.scenarios.map(entry => entry.id)) !== JSON.stringify(entries.map(entry => entry.id))) return null;
|
|
141
|
+
const contents = new Map(Object.keys(manifest.assets).map(uri => {
|
|
142
|
+
const value = readPreviewAsset(cached, uri, manifest);
|
|
143
|
+
return [uri, "text" in value ? Buffer.from(value.text) : Buffer.from(value.blob, "base64")];
|
|
144
|
+
}));
|
|
145
|
+
const { session: _session, ...graph } = manifest;
|
|
146
|
+
return publication(options, entries, graph, contents);
|
|
147
|
+
} catch { return null; } // Missing, pruned or corrupt cache means a real build.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function publication(options, entries, manifest, contents, saveCache = true) {
|
|
151
|
+
const { revision } = manifest;
|
|
152
|
+
const target = previewManifestPath(options);
|
|
122
153
|
const assetsRoot = path.join(path.dirname(target), "assets");
|
|
123
154
|
const assetFiles = [...contents.keys()].map(uri => path.join(assetsRoot, uri.slice("ads://assets/".length)));
|
|
124
|
-
return { assetFiles, publish(session) {
|
|
125
|
-
const encoded = JSON.stringify({ ...
|
|
155
|
+
return { assetFiles, revision, publish(session) {
|
|
156
|
+
const encoded = JSON.stringify({ ...manifest, session });
|
|
126
157
|
if (Buffer.byteLength(encoded) > 2 * 1024 * 1024) throw new Error("Preview manifest exceeds the 2 MiB resource limit.");
|
|
127
158
|
const history = path.join(path.dirname(target), "history");
|
|
128
159
|
if (existsSync(target)) {
|
|
@@ -133,8 +164,7 @@ export function createPreviewResources(output, options, entries) {
|
|
|
133
164
|
}
|
|
134
165
|
for (const [uri, bytes] of contents) atomic(path.join(assetsRoot, uri.slice("ads://assets/".length)), bytes);
|
|
135
166
|
for (const entry of entries) {
|
|
136
|
-
|
|
137
|
-
atomic(path.join(options.root, entry.artifact), `<!doctype html><html><head></head><body><script type="application/json" id="ads-preview-resource">${descriptor.replaceAll("<", "\\u003c")}</script></body></html>`);
|
|
167
|
+
atomic(path.join(options.root, previewArtifactPath(options, entry.artifact)), previewArtifactHtml(entry.id, revision));
|
|
138
168
|
}
|
|
139
169
|
atomic(target, encoded);
|
|
140
170
|
const retained = existsSync(history) ? readdirSync(history).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).sort((a, b) => statSync(path.join(history, b)).mtimeMs - statSync(path.join(history, a)).mtimeMs) : [];
|
|
@@ -145,13 +175,20 @@ export function createPreviewResources(output, options, entries) {
|
|
|
145
175
|
}
|
|
146
176
|
for (const file of retained.slice(6)) rmSync(path.join(history, file));
|
|
147
177
|
for (const file of readdirSync(assetsRoot)) if (/^[a-f0-9]{64}\.[a-z0-9]+$/.test(file) && !keep.has(`ads://assets/${file}`)) rmSync(path.join(assetsRoot, file));
|
|
178
|
+
if (saveCache) try {
|
|
179
|
+
publication(cacheOptions(options, revision), [], manifest, contents, false).publish("cache");
|
|
180
|
+
const directory = path.join(options.root, ".ds/preview-graphs");
|
|
181
|
+
const snapshots = readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name))
|
|
182
|
+
.sort((a, b) => statSync(path.join(directory, b.name)).mtimeMs - statSync(path.join(directory, a.name)).mtimeMs);
|
|
183
|
+
for (const entry of snapshots.slice(6)) rmSync(path.join(directory, entry.name), { recursive: true, force: true });
|
|
184
|
+
} catch { /* Cache races/storage failures never invalidate published output. */ }
|
|
148
185
|
} };
|
|
149
186
|
}
|
|
150
187
|
|
|
151
188
|
export function readPreviewManifest(options, { verify = false } = {}) {
|
|
152
189
|
const relative = options.config.mcp.previewManifest;
|
|
153
190
|
if (!relative) return null;
|
|
154
|
-
const file =
|
|
191
|
+
const file = previewManifestPath(options);
|
|
155
192
|
if (!existsSync(file)) return null;
|
|
156
193
|
const manifest = readManifestFile(file);
|
|
157
194
|
if (verify) for (const uri of Object.keys(manifest.assets)) readPreviewAsset(options, uri, manifest);
|
|
@@ -180,7 +217,7 @@ export function readPreviewAsset(options, uri, manifest = readPreviewManifest(op
|
|
|
180
217
|
if (!assetUriPattern.test(uri)) throw unknownResource("Unknown preview asset resource.");
|
|
181
218
|
let record = manifest?.assets?.[uri];
|
|
182
219
|
if (!record) {
|
|
183
|
-
const history = path.join(path.dirname(
|
|
220
|
+
const history = path.join(path.dirname(previewManifestPath(options)), "history");
|
|
184
221
|
for (const file of existsSync(history) ? readdirSync(history).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).slice(0, 6) : []) {
|
|
185
222
|
const candidate = path.join(history, file);
|
|
186
223
|
try { record = readManifestFile(candidate).assets[uri]; }
|
|
@@ -189,7 +226,7 @@ export function readPreviewAsset(options, uri, manifest = readPreviewManifest(op
|
|
|
189
226
|
}
|
|
190
227
|
}
|
|
191
228
|
if (!record) throw unknownResource("Unknown or expired preview asset resource. Refresh the manifest.");
|
|
192
|
-
const directory = path.join(path.dirname(
|
|
229
|
+
const directory = path.join(path.dirname(previewManifestPath(options)), "assets");
|
|
193
230
|
const file = path.join(directory, uri.slice("ads://assets/".length));
|
|
194
231
|
if (!isInside(directory, file) || realpathSync(file) !== file || statSync(file).size > 16 * 1024 * 1024) throw new Error("Invalid preview asset file.");
|
|
195
232
|
const bytes = readFileSync(file);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isInside } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
/** Logical resource names stay stable; managed builders own physical output. */
|
|
6
|
+
export function withPreviewSession(options, session) {
|
|
7
|
+
if (!options.config.mcp.previewManifest || !session) return options;
|
|
8
|
+
if (!/^[\w-]{1,100}$/.test(session)) throw new Error("Invalid preview build session.");
|
|
9
|
+
return { ...options, previewSession: session };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function previewManifestPath(options) {
|
|
13
|
+
const configured = path.resolve(options.root, options.config.mcp.previewManifest);
|
|
14
|
+
return options.previewSession ? path.join(path.dirname(configured), "sessions", options.previewSession, "manifest.json") : configured;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function previewArtifactPath(options, file) {
|
|
18
|
+
if (!options.previewSession || !file.replaceAll("\\", "/").startsWith(".ds/scenarios/")) return file;
|
|
19
|
+
return path.join(".ds/preview-sessions", options.previewSession, path.relative(".ds", file)).replaceAll("\\", "/");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function previewSessionDirectories(options) {
|
|
23
|
+
if (!options.previewSession) return [];
|
|
24
|
+
const directories = [path.dirname(previewManifestPath(options)), path.join(options.root, ".ds/preview-sessions", options.previewSession)];
|
|
25
|
+
if (directories.some(directory => !isInside(path.join(options.root, ".ds"), directory))) throw new Error("Preview session output must stay inside .ds/.");
|
|
26
|
+
for (const directory of directories) for (let current = directory; isInside(path.join(options.root, ".ds"), current); current = path.dirname(current)) {
|
|
27
|
+
if (existsSync(current) && realpathSync(current) !== current) throw new Error("Preview session output cannot resolve through a symlink.");
|
|
28
|
+
}
|
|
29
|
+
return directories;
|
|
30
|
+
}
|