@reqvire-org/ads 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +284 -2
- package/dist/assets.mjs +52 -0
- package/dist/bootstrap-ds/ADS.md +203 -0
- package/dist/bootstrap-ds/README.md +98 -0
- package/dist/bootstrap-ds/biome.css.json +21 -0
- package/dist/bootstrap-ds/design-system/assets/brand/README.md +28 -0
- package/dist/bootstrap-ds/design-system/assets/fonts/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/components/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/composition/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/index.ts +5 -0
- package/dist/bootstrap-ds/design-system/patterns/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/showcase/mcp/index.html +1 -0
- package/dist/bootstrap-ds/design-system/showcase/mcp/main.tsx +8 -0
- package/dist/bootstrap-ds/design-system/styles.css +5 -0
- package/dist/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +47 -0
- package/dist/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +14 -0
- package/dist/bootstrap-ds/design-system/tokens/base.css +27 -0
- package/dist/bootstrap-ds/design-system/tokens/colors.css +29 -0
- package/dist/bootstrap-ds/design-system/tokens/elevation.css +15 -0
- package/dist/bootstrap-ds/design-system/tokens/spacing.css +15 -0
- package/dist/bootstrap-ds/design-system/tokens/typography.css +13 -0
- package/dist/bootstrap-ds/ds.config.json +13 -0
- package/dist/bootstrap-ds/gitignore.template +8 -0
- package/dist/bootstrap-ds/package-lock.json +4660 -0
- package/dist/bootstrap-ds/package.json +41 -0
- package/dist/bootstrap-ds/scripts/build-mcp-previews.mjs +33 -0
- package/dist/bootstrap-ds/src/vite-env.d.ts +7 -0
- package/dist/bootstrap-ds/tsconfig.build.json +12 -0
- package/dist/bootstrap-ds/tsconfig.json +20 -0
- package/dist/bootstrap-ds/vite.config.ts +21 -0
- package/dist/boundaries.mjs +130 -0
- package/dist/brand-assets.mjs +82 -0
- package/dist/check.mjs +222 -0
- package/dist/cli.mjs +106 -0
- package/dist/config.mjs +181 -0
- package/dist/contracts/tokens.json +52 -0
- package/dist/html.mjs +30 -0
- package/dist/index.mjs +9 -0
- package/dist/init.mjs +194 -0
- package/dist/lint-config.mjs +40 -0
- package/dist/mcp/THIRD_PARTY_NOTICES.md +786 -0
- package/dist/mcp/apps.mjs +53 -0
- package/dist/mcp/build-status.mjs +164 -0
- package/dist/mcp/catalog.mjs +65 -0
- package/dist/mcp/context.mjs +69 -0
- package/dist/mcp/mutations.mjs +301 -0
- package/dist/mcp/patch.mjs +114 -0
- package/dist/mcp/preview-watch.mjs +169 -0
- package/dist/mcp/preview-widget.bundle.js +113 -0
- package/dist/mcp/project-widget.bundle.js +113 -0
- package/dist/mcp/prompts.mjs +28 -0
- package/dist/mcp/resource-loader.js +138 -0
- package/dist/mcp/sandbox-host.mjs +22 -0
- package/dist/mcp/server.mjs +377 -0
- package/dist/mcp/showcase-host.mjs +68 -0
- package/dist/mcp/skills.mjs +77 -0
- package/dist/mcp/widget.bundle.js +150 -0
- package/dist/oxlint.mjs +81 -0
- package/dist/preview-resources.mjs +198 -0
- package/dist/project-documents.mjs +27 -0
- package/dist/public-props.mjs +74 -0
- package/dist/scenarios-compiler.mjs +287 -0
- package/dist/scenarios-vite.d.mts +3 -0
- package/dist/scenarios-vite.mjs +71 -0
- package/dist/scenarios.mjs +212 -0
- package/dist/showcase/client.bundle.js +127 -0
- package/dist/showcase/index.html +17 -0
- package/dist/showcase/sandbox.js +30 -0
- package/dist/showcase/style.css +7 -0
- package/dist/showcase-coverage.mjs +118 -0
- package/dist/showcase.mjs +77 -0
- package/dist/skills/README.md +68 -0
- package/dist/skills/ads-assimilate/SKILL.md +292 -0
- package/dist/skills/ads-assimilate/agents/openai.yaml +4 -0
- package/dist/skills/ads-assimilate/references/cli.md +111 -0
- package/dist/skills/ads-assimilate/references/lint-and-docs.md +87 -0
- package/dist/skills/ads-assimilate/references/native-implementation.md +98 -0
- package/dist/skills/ads-assimilate/references/testing.md +107 -0
- package/dist/skills/ads-break-ui/LICENSE.upstream +21 -0
- package/dist/skills/ads-break-ui/README.md +16 -0
- package/dist/skills/ads-break-ui/SKILL.md +235 -0
- package/dist/skills/ads-break-ui/agents/openai.yaml +4 -0
- package/dist/skills/ads-break-ui/references/catalog.md +135 -0
- package/dist/skills/ads-break-ui/references/verification.md +48 -0
- package/dist/skills/ads-designer/LICENSE.upstream +21 -0
- package/dist/skills/ads-designer/README.md +19 -0
- package/dist/skills/ads-designer/SKILL.md +400 -0
- package/dist/skills/ads-designer/agents/openai.yaml +4 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +203 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +98 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/biome.css.json +21 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/assets/brand/README.md +28 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/assets/fonts/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/components/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/composition/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/index.ts +5 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/showcase/mcp/index.html +1 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/showcase/mcp/main.tsx +8 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/styles.css +5 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +47 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +14 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/base.css +27 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/colors.css +29 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/elevation.css +15 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/spacing.css +15 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/typography.css +13 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/ds.config.json +13 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/gitignore.template +8 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/package-lock.json +4660 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/package.json +41 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/scripts/build-mcp-previews.mjs +33 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/src/vite-env.d.ts +7 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/tsconfig.build.json +12 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/tsconfig.json +20 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/vite.config.ts +21 -0
- package/dist/skills/ads-designer/references/cli.md +111 -0
- package/dist/skills/ads-designer/references/design-craft.md +65 -0
- package/dist/skills/ads-designer/references/mobile.md +73 -0
- package/dist/skills/ads-import/SKILL.md +335 -0
- package/dist/skills/ads-import/agents/openai.yaml +4 -0
- package/dist/skills/ads-import/references/branding.md +44 -0
- package/dist/skills/ads-import/references/cli.md +111 -0
- package/dist/skills/ads-import/references/implementation.md +111 -0
- package/dist/skills/ads-import/references/tokens.md +96 -0
- package/dist/skills/ads-motion/LICENSE.upstream +21 -0
- package/dist/skills/ads-motion/README.md +22 -0
- package/dist/skills/ads-motion/SKILL.md +217 -0
- package/dist/skills/ads-motion/agents/openai.yaml +4 -0
- package/dist/skills/ads-motion/references/audit.md +44 -0
- package/dist/skills/ads-motion/references/build.md +81 -0
- package/dist/skills/ads-motion/references/performance.md +55 -0
- package/dist/skills/ads-motion/references/review.md +35 -0
- package/dist/skills/ads-motion/references/verification.md +39 -0
- package/dist/token-policy.mjs +10 -0
- package/dist/tokens.mjs +142 -0
- package/dist/ui-framework.mjs +59 -0
- package/dist/units.mjs +168 -0
- package/docs/ADS.md +310 -0
- package/docs/ARCHITECTURE.md +311 -0
- package/docs/CHANGELOG.md +106 -0
- package/docs/GUIDE.md +722 -0
- package/docs/MCP.md +690 -0
- package/package.json +107 -4
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
import { isInside, loadConfig } from "../config.mjs";
|
|
8
|
+
import { checkProject } from "../check.mjs";
|
|
9
|
+
import { generateLintConfig } from "../lint-config.mjs";
|
|
10
|
+
import { generateBrandAssets } from "../brand-assets.mjs";
|
|
11
|
+
import { projectContext } from "./context.mjs";
|
|
12
|
+
import { applyFilePatch, parseUnifiedPatch } from "./patch.mjs";
|
|
13
|
+
import { checkUnitCandidate, checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
|
|
14
|
+
|
|
15
|
+
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
|
+
const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
17
|
+
const ignored = new Set([".git", ".ds", "node_modules", ".vite", "coverage", ".playwright-cli", "test-results", "output"]);
|
|
18
|
+
const textFile = file => /\.(?:[cm]?[jt]sx?|css|html|jsonc?|md|txt|svg)$/.test(file);
|
|
19
|
+
const setupDotfile = file => /^\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?$/.test(file);
|
|
20
|
+
|
|
21
|
+
function fail(rule, message, file) {
|
|
22
|
+
return Object.assign(new Error(message), { diagnostic: { rule, message, ...(file ? { file } : {}) } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function address(options, file) {
|
|
26
|
+
if (typeof file !== "string" || !file || file.includes("\\") || file.includes("\0") || file.split("/").some(part => !part || part === "." || part === "..") || path.isAbsolute(file)) throw fail("mutation-path", "Use a canonical project-relative ADS file path.", file);
|
|
27
|
+
const target = path.resolve(options.root, file);
|
|
28
|
+
if (!isInside(options.root, target) || target === options.root) throw fail("mutation-path", "Mutation paths must stay inside the project.", file);
|
|
29
|
+
for (let current = options.root; ; ) {
|
|
30
|
+
const relative = path.relative(current, target).split(path.sep);
|
|
31
|
+
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
|
+
current = path.join(current, relative[0]);
|
|
33
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
34
|
+
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);
|
|
35
|
+
if (current === target) break;
|
|
36
|
+
}
|
|
37
|
+
if (options.config.assets.generatedPaths.some(entry => isInside(path.resolve(options.root, entry), target))) throw fail("mutation-path", "Generated output is not editable.", file);
|
|
38
|
+
if (options.config.assets.generatedFiles.some(entry => target === path.resolve(options.dsRoot, "assets", entry))) throw fail("mutation-path", "Generated brand files are outputs; edit their source instead.", file);
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readPatchFile(options, file) {
|
|
43
|
+
if (typeof file !== "string" || file.includes("\\") || file.includes("\0") || !/^\.ds\/patches\/[^/]+\.(?:patch|diff)$/.test(file)) throw fail("mutation-patch", "Patch files must be regular .patch/.diff files in .ds/patches/.");
|
|
44
|
+
const target = path.join(options.root, file);
|
|
45
|
+
for (const current of [path.join(options.root, ".ds"), path.dirname(target), target]) {
|
|
46
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
47
|
+
if (!stat || stat.isSymbolicLink() || current !== target && !stat.isDirectory() || current === target && (!stat.isFile() || stat.nlink !== 1 || stat.size > mutationLimits.patchFileBytes)) throw fail("mutation-patch", "Patch files must be real files no larger than 16 MiB.");
|
|
48
|
+
}
|
|
49
|
+
const bytes = readFileSync(target);
|
|
50
|
+
if (bytes.includes(0) || !Buffer.from(bytes.toString("utf8")).equals(bytes)) throw fail("mutation-patch", "Patch files must contain UTF-8 Git diff text.");
|
|
51
|
+
return bytes.toString("utf8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function read(options, file) {
|
|
55
|
+
const target = address(options, file);
|
|
56
|
+
if (!existsSync(target)) return { path: file, exists: false, digest: null, content: null };
|
|
57
|
+
if (lstatSync(target).size > mutationLimits.fileBytes) throw fail("mutation-content", "File exceeds the 16 MiB mutation size limit.", file);
|
|
58
|
+
const bytes = readFileSync(target);
|
|
59
|
+
const binary = bytes.includes(0) || !Buffer.from(bytes.toString("utf8")).equals(bytes);
|
|
60
|
+
return { path: file, exists: true, digest: digest(bytes), bytes: bytes.length, binary, content: binary ? bytes : bytes.toString("utf8") };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateContent(change, unit) {
|
|
64
|
+
if (change.content === null) return;
|
|
65
|
+
const bytes = Buffer.from(change.content);
|
|
66
|
+
if (bytes.length > mutationLimits.fileBytes) throw fail("mutation-content", "The patched file exceeds 16 MiB.", change.path);
|
|
67
|
+
if (textFile(change.path) && (bytes.includes(0) || !Buffer.from(bytes.toString("utf8")).equals(bytes))) throw fail("mutation-content", "Text source must contain valid UTF-8 without NUL bytes.", change.path);
|
|
68
|
+
if (/\.jsonc?$/.test(change.path)) {
|
|
69
|
+
const content=bytes.toString("utf8");
|
|
70
|
+
if (unit === "configuration" && !["package.json","ds.config.json"].includes(path.basename(change.path))) {
|
|
71
|
+
if (ts.parseConfigFileTextToJson(change.path,content).error) throw fail("mutation-syntax", "Configuration must contain valid JSON/JSONC.", change.path);
|
|
72
|
+
} else { try { JSON.parse(content); } catch { throw fail("mutation-syntax", "JSON source and configuration must be valid JSON.", change.path); } }
|
|
73
|
+
}
|
|
74
|
+
if (unit !== "configuration" && /\b(?:oxlint|eslint)-(?:disable|enable)|\bbiome-ignore|@ts-(?:ignore|nocheck|expect-error)/.test(bytes.toString("utf8"))) throw fail("mutation-policy", "Configure reviewed lint/typecheck exceptions through the configuration unit instead of source suppression directives.", change.path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snapshot(options, destination) {
|
|
78
|
+
const entries = [];
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
const generated = options.config.assets.generatedPaths.map(file => path.resolve(options.root, file));
|
|
81
|
+
function walk(folder) {
|
|
82
|
+
for (const entry of readdirSync(folder, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
83
|
+
if (ignored.has(entry.name)) continue;
|
|
84
|
+
const file = path.join(folder, entry.name);
|
|
85
|
+
const relative = path.relative(options.root, file);
|
|
86
|
+
if (generated.some(base => isInside(base, file))) continue;
|
|
87
|
+
const target = destination && path.join(destination, relative);
|
|
88
|
+
if (entry.isSymbolicLink()) throw fail("mutation-snapshot", "Project source symlinks require an ordinary project copy for guarded validation.", relative);
|
|
89
|
+
if (entry.isDirectory()) { if (target) mkdirSync(target, { recursive: true }); walk(file); }
|
|
90
|
+
else if (entry.isFile()) {
|
|
91
|
+
if (bytes + lstatSync(file).size > mutationLimits.snapshotBytes || entries.length >= mutationLimits.snapshotFiles) throw fail("mutation-snapshot", "Project source exceeds the bounded validation snapshot limit.");
|
|
92
|
+
const content = readFileSync(file);
|
|
93
|
+
bytes += content.length;
|
|
94
|
+
if (bytes > mutationLimits.snapshotBytes || entries.length >= mutationLimits.snapshotFiles) throw fail("mutation-snapshot", "Project source exceeds the bounded validation snapshot limit.");
|
|
95
|
+
entries.push([relative, digest(content)]);
|
|
96
|
+
if (target) writeFileSync(target, content, { mode: lstatSync(file).mode });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
walk(options.root);
|
|
101
|
+
// Package installation changes also invalidate a candidate's validation.
|
|
102
|
+
const installed = path.join(options.root, "node_modules/.package-lock.json");
|
|
103
|
+
entries.push(["installed-dependencies", existsSync(installed) ? digest(readFileSync(installed)) : null]);
|
|
104
|
+
return JSON.stringify(entries);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function checkCommand(command, args, cwd, signal, input) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
if (signal?.aborted) { reject(fail("mutation-cancelled", "Mutation cancelled before validation.")); return; }
|
|
110
|
+
const child = spawn(command, args, { cwd, shell: false, detached: process.platform !== "win32", windowsHide: true, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
111
|
+
env: { ...process.env, PATH: [path.join(cwd, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) } });
|
|
112
|
+
let output = "", failure;
|
|
113
|
+
if (input !== undefined) { child.stdin.on("error", () => {}); child.stdin.end(input); }
|
|
114
|
+
function kill() {
|
|
115
|
+
if (!child.pid) return;
|
|
116
|
+
if (process.platform === "win32") {
|
|
117
|
+
const stop = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
118
|
+
stop.on("error", () => child.kill("SIGKILL"));
|
|
119
|
+
} else { try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") failure ??= error; } }
|
|
120
|
+
}
|
|
121
|
+
const abort = () => { failure = fail("mutation-cancelled", "Mutation cancelled during validation."); kill(); };
|
|
122
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
123
|
+
const timer = setTimeout(() => { failure = fail("mutation-check", "A mutation check exceeded 60 seconds."); kill(); }, 60000);
|
|
124
|
+
for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { output = (output + chunk.toString()).slice(-12000); });
|
|
125
|
+
child.once("error", error => { failure = error; });
|
|
126
|
+
child.once("exit", kill); // Also clean up descendants of a finished launcher.
|
|
127
|
+
child.once("close", code => {
|
|
128
|
+
clearTimeout(timer); signal?.removeEventListener("abort", abort);
|
|
129
|
+
if (failure) reject(failure);
|
|
130
|
+
else if (code !== 0) reject(fail("mutation-check", `${path.basename(command)} ${args.join(" ")} failed (${code}).\n${output}`));
|
|
131
|
+
else resolve({ command: path.basename(command), args, passed: true });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function checks(options) {
|
|
137
|
+
const context = projectContext(options);
|
|
138
|
+
const pkg = JSON.parse(readFileSync(path.join(options.root, "package.json"), "utf8"));
|
|
139
|
+
const scripts = options.config.mcp.mutationChecks ?? [pkg.scripts?.["lint:project"] ? "lint:project" : "lint", "typecheck", "build:mcp-previews"].filter(name => typeof pkg.scripts?.[name] === "string");
|
|
140
|
+
if (scripts.some(name => typeof pkg.scripts?.[name] !== "string")) throw fail("mutation-check", "Every configured mcp.mutationChecks script must exist in package.json.");
|
|
141
|
+
if (scripts.length && !["npm", "bun"].includes(context.packageManager)) throw fail("mutation-check", "Declare packageManager as npm or bun to run mutation checks.");
|
|
142
|
+
return scripts.map(script => ({ command: context.packageManager, args: ["run", script] }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createMutationController(directory) {
|
|
146
|
+
const root = path.resolve(directory);
|
|
147
|
+
let busy = false;
|
|
148
|
+
let lastResult = null;
|
|
149
|
+
let active;
|
|
150
|
+
return {
|
|
151
|
+
status() {
|
|
152
|
+
const options = loadConfig(root);
|
|
153
|
+
let commands = [], diagnostics = [];
|
|
154
|
+
try { commands = checks(options).map(check => [check.command, ...check.args].join(" ")); }
|
|
155
|
+
catch (error) { diagnostics = [error.diagnostic ?? { rule: "mutation-check", message: error.message }]; }
|
|
156
|
+
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
|
+
},
|
|
158
|
+
cancel() { active?.abort(); },
|
|
159
|
+
units() { return inspectUnits(loadConfig(root)).units; },
|
|
160
|
+
read(file, { offset = 0, limit = mutationLimits.readChars } = {}) {
|
|
161
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > mutationLimits.readChars) throw fail("mutation-range", "Use a non-negative character offset and a limit between 1 and 12000.", file);
|
|
162
|
+
const options = loadConfig(root), source = read(options, file);
|
|
163
|
+
const inventory = inspectUnits(options), unit = inventory.owner(inventory.relative(file));
|
|
164
|
+
if (!unit && inventory.relative(file) !== inventory.publicEntry) throw fail("mutation-unit", "This file is outside the project's ADS and registered configuration/extension units.", file);
|
|
165
|
+
if (!source.exists) return { ...source, offset: 0, nextOffset: null, totalChars: 0, startLine: 1 };
|
|
166
|
+
if (source.binary) return { ...source, unit, content: null, note: "Binary asset: inspect its metadata or preview; edit using a Git binary patch, optionally through patchFile." };
|
|
167
|
+
if (offset > source.content.length) throw fail("mutation-range", "Read offset exceeds the current file length.", file);
|
|
168
|
+
const content = source.content.slice(offset, offset + limit), end = offset + content.length;
|
|
169
|
+
return { ...source, unit, content, offset, nextOffset: end < source.content.length ? end : null, totalChars: source.content.length,
|
|
170
|
+
startLine: source.content.slice(0, offset).split("\n").length };
|
|
171
|
+
},
|
|
172
|
+
async apply({ unit, patch, patchFile, dryRun = false }, signal) {
|
|
173
|
+
if (busy) return { ok: false, applied: false, diagnostics: [{ rule: "mutation-busy", message: "Another mutation is validating; retry after it finishes." }] };
|
|
174
|
+
busy = true;
|
|
175
|
+
active = new AbortController();
|
|
176
|
+
signal = signal ? AbortSignal.any([signal, active.signal]) : active.signal;
|
|
177
|
+
let temporary;
|
|
178
|
+
const verified = [];
|
|
179
|
+
try {
|
|
180
|
+
const options = loadConfig(root);
|
|
181
|
+
validateUnitId(unit);
|
|
182
|
+
if ((typeof patch === "string") === (typeof patchFile === "string")) throw fail("mutation-patch", "Provide exactly one of patch or patchFile.");
|
|
183
|
+
const maxPatch = patchFile === undefined ? mutationLimits.patchBytes : mutationLimits.patchFileBytes;
|
|
184
|
+
if (patchFile !== undefined) patch = readPatchFile(options, patchFile);
|
|
185
|
+
if (typeof patch !== "string" || patch.includes("\0") || Buffer.from(patch).toString("utf8") !== patch || Buffer.byteLength(patch) > maxPatch) throw fail("mutation-patch", "Patch exceeds its UTF-8 Git diff size limit.");
|
|
186
|
+
const entries = parseUnifiedPatch(patch, mutationLimits.files), changes = [];
|
|
187
|
+
const originals = entries.map(entry => {
|
|
188
|
+
const change = { path: entry.path };
|
|
189
|
+
const target = address(options, change.path);
|
|
190
|
+
const before = read(options, change.path);
|
|
191
|
+
if (!entry.binary && before.binary) throw fail("mutation-patch", "Binary assets require a git diff --binary patch.", change.path);
|
|
192
|
+
change.content = entry.binary ? entry.operation === "delete" ? null : before.content ?? Buffer.alloc(0) : applyFilePatch(entry, before.content);
|
|
193
|
+
if (entry.binary) change.binaryPatch = entry.patch;
|
|
194
|
+
change.expectedDigest = before.digest;
|
|
195
|
+
change.originalContent = before.content;
|
|
196
|
+
if (!entry.binary) validateContent(change, unit);
|
|
197
|
+
changes.push(change);
|
|
198
|
+
return { ...before, mode: before.exists ? lstatSync(target).mode : undefined };
|
|
199
|
+
});
|
|
200
|
+
const inventory = checkUnitFiles(options, unit, changes, inspectUnits(options), true);
|
|
201
|
+
const cache = path.join(root, ".ds");
|
|
202
|
+
mkdirSync(cache, { recursive: true });
|
|
203
|
+
if (realpathSync(cache) !== path.join(realpathSync(root), ".ds")) throw fail("mutation-path", "The .ds directory cannot resolve through a symlink.");
|
|
204
|
+
temporary = mkdtempSync(path.join(cache, "mutation-"));
|
|
205
|
+
const candidate = path.join(temporary, "project");
|
|
206
|
+
mkdirSync(candidate);
|
|
207
|
+
const before = snapshot(options, candidate);
|
|
208
|
+
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);
|
|
209
|
+
const dependencies = path.join(root, "node_modules");
|
|
210
|
+
if (!existsSync(dependencies)) throw fail("mutation-check", "Install project dependencies before using guarded mutations.");
|
|
211
|
+
symlinkSync(realpathSync(dependencies), path.join(candidate, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
212
|
+
// The tooling repository's own scripts run dist/cli.mjs. Preserve that
|
|
213
|
+
// already-built runtime without treating generated output as source.
|
|
214
|
+
if (existsSync(path.join(root, "dist")) && !existsSync(path.join(candidate, "dist"))) {
|
|
215
|
+
let count = 0, bytes = 0;
|
|
216
|
+
cpSync(path.join(root, "dist"), path.join(candidate, "dist"), { recursive: true, filter(file) {
|
|
217
|
+
const stat = lstatSync(file);
|
|
218
|
+
if (stat.isSymbolicLink()) throw fail("mutation-snapshot", "Generated runtime copies cannot contain symlinks.");
|
|
219
|
+
if (stat.isFile()) { bytes += stat.size; ++count; }
|
|
220
|
+
if (count > mutationLimits.snapshotFiles || bytes > mutationLimits.snapshotBytes) throw fail("mutation-snapshot", "Generated runtime exceeds the validation snapshot limit.");
|
|
221
|
+
return true;
|
|
222
|
+
} });
|
|
223
|
+
}
|
|
224
|
+
if (entries.some(entry => entry.binary)) {
|
|
225
|
+
const formats = new Set(entries.filter(entry=>entry.binary).map(entry=>entry.objectFormat));
|
|
226
|
+
if (formats.size !== 1) throw fail("mutation-patch", "Binary blocks in one Git diff must use the same object format.");
|
|
227
|
+
await checkCommand("git", ["init", "--quiet", `--object-format=${[...formats][0]}`, candidate], root, signal);
|
|
228
|
+
}
|
|
229
|
+
for (const change of changes) {
|
|
230
|
+
const file = path.join(candidate, change.path);
|
|
231
|
+
if (change.binaryPatch) {
|
|
232
|
+
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);
|
|
233
|
+
await checkCommand("git", ["apply", "--binary", "--whitespace=nowarn", "-"], candidate, signal, change.binaryPatch);
|
|
234
|
+
change.content = existsSync(file) ? readFileSync(file) : null;
|
|
235
|
+
validateContent(change, unit);
|
|
236
|
+
}
|
|
237
|
+
else if (change.content === null) rmSync(file);
|
|
238
|
+
else { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, change.content); }
|
|
239
|
+
}
|
|
240
|
+
const candidateOptions = loadConfig(candidate);
|
|
241
|
+
checkUnitCandidate(candidateOptions, unit, changes, inventory);
|
|
242
|
+
const extraChecks = checks(candidateOptions);
|
|
243
|
+
const expected = snapshot(candidateOptions);
|
|
244
|
+
const result = checkProject(candidate, { trackingRoot: root });
|
|
245
|
+
if (!result.ok) return lastResult = { ok: false, applied: false, diagnostics: result.diagnostics.slice(0, 50), total: result.diagnostics.length, checks: verified };
|
|
246
|
+
verified.push({ name: "ads check", passed: true });
|
|
247
|
+
const lintConfig = generateLintConfig(candidate).file;
|
|
248
|
+
const require = createRequire(path.join(candidate, "package.json"));
|
|
249
|
+
const oxlintPackage = require.resolve("oxlint/package.json");
|
|
250
|
+
const bin = JSON.parse(readFileSync(oxlintPackage, "utf8")).bin;
|
|
251
|
+
const executable = path.resolve(path.dirname(oxlintPackage), typeof bin === "string" ? bin : bin.oxlint);
|
|
252
|
+
const lintPaths = [candidateOptions.config.sourceDir, candidateOptions.config.dsDir].filter(file => existsSync(path.join(candidate, file)));
|
|
253
|
+
if (unit === "configuration") for (const file of inspectUnits(candidateOptions).units.find(item => item.id === "configuration").files) {
|
|
254
|
+
if (/\.[cm]?[jt]sx?$/.test(file) && !lintPaths.some(directory => isInside(path.resolve(candidate, directory), path.resolve(candidate, file)))) lintPaths.push(file);
|
|
255
|
+
}
|
|
256
|
+
await checkCommand(process.execPath, [executable, "--deny-warnings", "--config", lintConfig, ...lintPaths], candidate, signal);
|
|
257
|
+
verified.push({ name: "registered Oxlint rules", passed: true });
|
|
258
|
+
if (unit.startsWith("assets") || unit === "configuration") {
|
|
259
|
+
const brand = await generateBrandAssets(candidate);
|
|
260
|
+
verified.push({ name: "brand assets", passed: true, skipped: !!brand.skipped });
|
|
261
|
+
}
|
|
262
|
+
for (const check of extraChecks) verified.push(await checkCommand(check.command, check.args, candidate, signal));
|
|
263
|
+
if (signal?.aborted) throw fail("mutation-cancelled", "Mutation cancelled before application.");
|
|
264
|
+
if (snapshot(candidateOptions) !== expected) throw fail("mutation-check", "A validation command changed source files. Use checks without source-writing side effects.");
|
|
265
|
+
if (snapshot(loadConfig(root)) !== before) throw fail("mutation-conflict", "Project inputs changed during validation; no changes were applied. Read current source and retry.");
|
|
266
|
+
for (const change of changes) address(options, change.path);
|
|
267
|
+
if (!dryRun) {
|
|
268
|
+
const written = [];
|
|
269
|
+
const directories = [];
|
|
270
|
+
function mkdir(directory) { if (!existsSync(directory)) { mkdir(path.dirname(directory)); mkdirSync(directory); directories.push(directory); } }
|
|
271
|
+
function replace(file, content, mode) {
|
|
272
|
+
mkdir(path.dirname(file));
|
|
273
|
+
const staged = path.join(path.dirname(file), `.ads-edit-${randomUUID()}`);
|
|
274
|
+
try { writeFileSync(staged, content, { mode }); renameSync(staged, file); }
|
|
275
|
+
finally { rmSync(staged, { force: true }); }
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
changes.forEach((change, index) => {
|
|
279
|
+
const file = address(options, change.path);
|
|
280
|
+
if (change.content === null) rmSync(file); else replace(file, change.content, originals[index].mode);
|
|
281
|
+
written.push(index);
|
|
282
|
+
});
|
|
283
|
+
} catch (error) {
|
|
284
|
+
for (const index of written.reverse()) {
|
|
285
|
+
const original = originals[index], file = path.join(root, original.path);
|
|
286
|
+
if (original.exists) replace(file, original.content, original.mode); else rmSync(file, { force: true });
|
|
287
|
+
}
|
|
288
|
+
for (const directory of directories.reverse()) if (!readdirSync(directory).length) rmSync(directory, { recursive: true });
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const warnings = [];
|
|
293
|
+
if (!dryRun && (unit.startsWith("assets") || unit === "configuration")) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
|
|
294
|
+
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, ...(warnings.length ? { warnings } : {}),
|
|
295
|
+
preview: "Source validation passed. Inspect the managed preview build and rendered scenario after applying; validation is not visual verification." };
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return lastResult = { ok: false, applied: false, diagnostics: [error.diagnostic ?? { rule: "mutation-error", message: error.message }], checks: verified };
|
|
298
|
+
} finally { try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { busy = false; active = undefined; } }
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
function error(message, file, rule = "mutation-patch") {
|
|
2
|
+
return Object.assign(new Error(message), { diagnostic: { rule, message, ...(file ? { file } : {}) } });
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function fileName(header, prefix) {
|
|
6
|
+
const name = header.slice(4).split("\t", 1)[0];
|
|
7
|
+
if (name === "/dev/null") return null;
|
|
8
|
+
if (!name.startsWith(prefix) || !name.slice(2)) throw error("Use --- a/path and +++ b/path headers, or /dev/null for creation/deletion.");
|
|
9
|
+
return name.slice(2);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Text hunks are exact; Git binary blocks are applied by Git in the candidate. */
|
|
13
|
+
export function parseUnifiedPatch(patch, maxFiles = 32) {
|
|
14
|
+
const lines = patch.split("\n");
|
|
15
|
+
if (lines.at(-1) === "") lines.pop();
|
|
16
|
+
const files = [], paths = new Set();
|
|
17
|
+
let index = 0;
|
|
18
|
+
while (index < lines.length) {
|
|
19
|
+
const start = index;
|
|
20
|
+
let gitPath, binaryOperation = "update", binaryIndex;
|
|
21
|
+
if (lines[index]?.startsWith("diff --git ")) {
|
|
22
|
+
const header = /^diff --git a\/(.+) b\/(.+)$/.exec(lines[index++]);
|
|
23
|
+
if (!header || header[1] !== header[2]) throw error("Git file headers must identify one path; use owned deletion/creation for a move.");
|
|
24
|
+
gitPath = header[1];
|
|
25
|
+
}
|
|
26
|
+
while (/^(?:index [a-f0-9]+\.\.[a-f0-9]+(?: 100644)?$|(?:new|deleted) file mode 100644$)/.test(lines[index] ?? "")) {
|
|
27
|
+
const metadata = lines[index++];
|
|
28
|
+
if (metadata.startsWith("new file")) binaryOperation = "create";
|
|
29
|
+
if (metadata.startsWith("deleted file")) binaryOperation = "delete";
|
|
30
|
+
if (metadata.startsWith("index ")) binaryIndex = /^index ([a-f0-9]{40}|[a-f0-9]{64})\.\.([a-f0-9]{40}|[a-f0-9]{64})(?: 100644)?$/.exec(metadata);
|
|
31
|
+
}
|
|
32
|
+
if (lines[index] === "GIT binary patch") {
|
|
33
|
+
if (!gitPath || !binaryIndex) throw error("Binary patches need a Git file header and full blob hashes. Generate with git diff --binary.");
|
|
34
|
+
const file = gitPath;
|
|
35
|
+
if (paths.has(file) || files.length >= maxFiles) throw error("Provide 1–32 distinct file patches.", file, "mutation-batch");
|
|
36
|
+
paths.add(file);
|
|
37
|
+
while (index < lines.length && !lines[index].startsWith("diff --git ")) index++;
|
|
38
|
+
const block = lines.slice(start, index).join("\n") + "\n";
|
|
39
|
+
files.push({ path: file, operation: binaryOperation, binary: true, objectFormat: binaryIndex[1].length === 64 ? "sha256" : "sha1", patch: block });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!lines[index]?.startsWith("--- ") || !lines[index + 1]?.startsWith("+++ ")) throw error("Expected unified diff file headers or a Git binary block. Renames and mode changes are unsupported.");
|
|
43
|
+
const before = fileName(lines[index++], "a/"), after = fileName(lines[index++], "b/");
|
|
44
|
+
const file = after ?? before;
|
|
45
|
+
if (gitPath && gitPath !== file) throw error("Git metadata and file headers must identify the same path.", file);
|
|
46
|
+
if (!file || before && after && before !== after) throw error("Use separate delete and create entries to rename a file.", file);
|
|
47
|
+
if (paths.has(file) || files.length >= maxFiles) throw error("Provide 1–32 distinct file patches.", file, "mutation-batch");
|
|
48
|
+
paths.add(file);
|
|
49
|
+
const entry = { path: file, operation: before === null ? "create" : after === null ? "delete" : "update", hunks: [] };
|
|
50
|
+
while (lines[index]?.startsWith("@@")) {
|
|
51
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$/.exec(lines[index++]);
|
|
52
|
+
if (!header) throw error("Malformed unified diff hunk header.", file);
|
|
53
|
+
const [oldStart, oldCount, newStart, newCount] = [header[1], header[2] ?? "1", header[3], header[4] ?? "1"].map(Number);
|
|
54
|
+
if (![oldStart, oldCount, newStart, newCount].every(Number.isSafeInteger) || oldCount && oldStart === 0 || newCount && newStart === 0 || !oldCount && !newCount) throw error("Invalid unified diff hunk positions or counts.", file);
|
|
55
|
+
const hunk = { oldStart, oldCount, newStart, newCount, lines: [] };
|
|
56
|
+
let oldLines = 0, newLines = 0;
|
|
57
|
+
while (oldLines < oldCount || newLines < newCount) {
|
|
58
|
+
const line = lines[index++];
|
|
59
|
+
if (line === undefined || ![" ", "-", "+"].includes(line[0])) throw error("Hunk lines must start with a space, - or + and match the declared counts.", file);
|
|
60
|
+
const item = { kind: line[0], text: line.slice(1), noNewline: false, ending: "\n" };
|
|
61
|
+
if (item.kind !== "+") oldLines++;
|
|
62
|
+
if (item.kind !== "-") newLines++;
|
|
63
|
+
if (oldLines > oldCount || newLines > newCount) throw error("Hunk line counts do not match the header.", file);
|
|
64
|
+
if (lines[index] === "\") { item.noNewline = true; index++; }
|
|
65
|
+
if (item.noNewline) item.ending = "";
|
|
66
|
+
else if (item.text.endsWith("\r")) { item.text = item.text.slice(0, -1); item.ending = "\r\n"; }
|
|
67
|
+
hunk.lines.push(item);
|
|
68
|
+
}
|
|
69
|
+
entry.hunks.push(hunk);
|
|
70
|
+
}
|
|
71
|
+
if (entry.operation === "update" && !entry.hunks.length) throw error("An update must contain a text hunk.", file);
|
|
72
|
+
files.push(entry);
|
|
73
|
+
}
|
|
74
|
+
if (!files.length) throw error("Provide a non-empty unified text diff.");
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sourceLines(content) {
|
|
79
|
+
return [...content.matchAll(/([^\n]*)(\n|$)/g)].filter(match => match[0]).map(match => {
|
|
80
|
+
const crlf = match[2] && match[1].endsWith("\r");
|
|
81
|
+
return { text: crlf ? match[1].slice(0, -1) : match[1], ending: match[2] ? crlf ? "\r\n" : "\n" : "" };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function applyFilePatch(entry, content) {
|
|
86
|
+
const conflict = message => { throw error(message, entry.path, "mutation-conflict"); };
|
|
87
|
+
if (entry.operation === "create" && content !== null) conflict("Cannot create an existing file. Read the current source and prepare an update patch.");
|
|
88
|
+
if (entry.operation !== "create" && content === null) conflict("Cannot update or delete a missing file.");
|
|
89
|
+
const source = sourceLines(content ?? ""), output = [];
|
|
90
|
+
let cursor = 0;
|
|
91
|
+
for (const hunk of entry.hunks) {
|
|
92
|
+
const position = hunk.oldCount ? hunk.oldStart - 1 : hunk.oldStart;
|
|
93
|
+
if (position < cursor || position > source.length) conflict("Hunks overlap, are out of order or point outside the current file.");
|
|
94
|
+
for (let index = cursor; index < position; index++) output.push(source[index]);
|
|
95
|
+
cursor = position;
|
|
96
|
+
const newPosition = hunk.newCount ? hunk.newStart - 1 : hunk.newStart;
|
|
97
|
+
if (newPosition !== output.length) conflict("New hunk positions do not match the reconstructed file.");
|
|
98
|
+
for (const line of hunk.lines) {
|
|
99
|
+
if (line.kind !== "+") {
|
|
100
|
+
const current = source[cursor++];
|
|
101
|
+
if (!current || current.text !== line.text || current.ending !== line.ending) conflict("Patch context/removal does not match the current source exactly. Read the affected range and regenerate the patch; no fuzzy matching is used.");
|
|
102
|
+
if (line.kind === " ") output.push(current);
|
|
103
|
+
} else output.push({ text: line.text, ending: line.ending });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (let index = cursor; index < source.length; index++) output.push(source[index]);
|
|
107
|
+
if (output.some((line, index) => !line.ending && index < output.length - 1)) conflict("A no-newline marker is valid only at the end of the resulting file.");
|
|
108
|
+
const result = output.map(line => line.text + line.ending).join("");
|
|
109
|
+
if (entry.operation === "delete") {
|
|
110
|
+
if (result) conflict("A deletion patch must remove the entire file.");
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { statSync, mkdirSync, writeFileSync, renameSync, rmSync, realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { buildFailure, buildStatusFile, inspectBuildStatus } from "./build-status.mjs";
|
|
6
|
+
|
|
7
|
+
function stamp(file) {
|
|
8
|
+
try { const stat = statSync(file); return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; }
|
|
9
|
+
catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** One project-owned compiler process, shared by all protocol instances. */
|
|
13
|
+
export function createPreviewWatch(root, config, { log = process.stderr, buildTimeout = 30000 } = {}) {
|
|
14
|
+
config = { ...config, apps: [...config.apps] };
|
|
15
|
+
const command = config.previewWatch;
|
|
16
|
+
const session = randomUUID();
|
|
17
|
+
let state = command ? "idle" : "disabled";
|
|
18
|
+
let child;
|
|
19
|
+
let error;
|
|
20
|
+
let closed;
|
|
21
|
+
let childClosed = false;
|
|
22
|
+
let killTimer;
|
|
23
|
+
let stopping;
|
|
24
|
+
const baselines = new Map();
|
|
25
|
+
const ready = new Set();
|
|
26
|
+
const requests = new Set();
|
|
27
|
+
let active;
|
|
28
|
+
const requestFile = path.join(root, ".ds", `preview-requests-${session}.json`);
|
|
29
|
+
function request(file) {
|
|
30
|
+
if (!command || requests.has(file) && active === file) return;
|
|
31
|
+
requests.add(file);
|
|
32
|
+
active = file;
|
|
33
|
+
mkdirSync(path.dirname(requestFile), { recursive: true });
|
|
34
|
+
if (realpathSync(path.dirname(requestFile)) !== path.dirname(requestFile)) throw new Error("Preview requests cannot resolve through a symlink.");
|
|
35
|
+
const temporary = `${requestFile}.tmp`;
|
|
36
|
+
writeFileSync(temporary, JSON.stringify({ session, active: path.relative(root, active).replaceAll("\\", "/"), files: [...requests].map((file) => path.relative(root, file).replaceAll("\\", "/")) }));
|
|
37
|
+
renameSync(temporary, requestFile);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function kill(signal) {
|
|
41
|
+
if (!child?.pid || childClosed) return;
|
|
42
|
+
try {
|
|
43
|
+
if (process.platform === "win32") {
|
|
44
|
+
// taskkill also terminates the compiler's own subprocesses.
|
|
45
|
+
const terminator = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
46
|
+
terminator.on("error", () => { child.kill(signal); });
|
|
47
|
+
} else process.kill(-child.pid, signal);
|
|
48
|
+
} catch (failure) { if (failure.code !== "ESRCH") throw failure; }
|
|
49
|
+
}
|
|
50
|
+
function start() {
|
|
51
|
+
if (state !== "idle") return;
|
|
52
|
+
for (const app of config.apps) {
|
|
53
|
+
const file = path.resolve(root, app.file);
|
|
54
|
+
baselines.set(file, stamp(file));
|
|
55
|
+
}
|
|
56
|
+
state = "running";
|
|
57
|
+
// No shell: configured arguments remain literal. Child stdin cannot consume
|
|
58
|
+
// the MCP channel, and both output streams go exclusively to diagnostics.
|
|
59
|
+
const executable = command.command === "node" ? process.execPath : command.command;
|
|
60
|
+
const env = { ...process.env, ADS_MCP_BUILD_SESSION: session, ADS_MCP_BUILD_STATUS: buildStatusFile(root), ADS_MCP_REQUEST_FILE: requestFile, PATH: [path.join(root, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) };
|
|
61
|
+
try {
|
|
62
|
+
child = spawn(executable, command.args, { cwd: root, env, shell: false, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
63
|
+
} catch (failure) {
|
|
64
|
+
state = "failed";
|
|
65
|
+
error = `Cannot start preview watcher: ${failure.message}`;
|
|
66
|
+
log.write(`ads mcp: ${error}\n`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
child.stdout.pipe(log, { end: false });
|
|
70
|
+
child.stderr.pipe(log, { end: false });
|
|
71
|
+
closed = new Promise((resolve) => child.once("close", (code, signal) => {
|
|
72
|
+
// Closing the launcher's pipes does not prove every compiler exited.
|
|
73
|
+
// Finish cleanup now, before discarding the owned process-group ID.
|
|
74
|
+
kill("SIGKILL");
|
|
75
|
+
childClosed = true;
|
|
76
|
+
clearTimeout(killTimer);
|
|
77
|
+
if (state !== "stopping") {
|
|
78
|
+
state = "failed";
|
|
79
|
+
error ??= `Preview watcher exited (${signal ?? code}). Restart MCP after fixing the configured command.`;
|
|
80
|
+
log.write(`ads mcp: ${error}\n`);
|
|
81
|
+
} else state = "stopped";
|
|
82
|
+
resolve();
|
|
83
|
+
}));
|
|
84
|
+
child.once("exit", () => {
|
|
85
|
+
// A failed launcher must not leave its compiler subprocesses alive.
|
|
86
|
+
if (state !== "stopping") {
|
|
87
|
+
kill("SIGTERM");
|
|
88
|
+
killTimer = setTimeout(() => kill("SIGKILL"), 1500);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
child.once("error", (failure) => { error = `Cannot start preview watcher: ${failure.message}`; });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function ensure(file, signal) {
|
|
95
|
+
start();
|
|
96
|
+
request(file);
|
|
97
|
+
if (signal?.aborted) throw new Error("Preview request cancelled.");
|
|
98
|
+
const app = config.apps.find((entry) => path.resolve(root, entry.file) === file);
|
|
99
|
+
const baseline = baselines.get(file) ?? null;
|
|
100
|
+
const deadline = Date.now() + buildTimeout;
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
if (signal?.aborted) throw new Error("Preview request cancelled.");
|
|
103
|
+
const build = buildStatus().previews.find((entry) => entry.id === app?.id);
|
|
104
|
+
if (build?.state === "error") throw buildFailure(build);
|
|
105
|
+
if (command && state !== "running") throw new Error(error ?? "Preview watcher is stopped.");
|
|
106
|
+
if (build?.current) return build;
|
|
107
|
+
if (build?.state === "unreported" && (!command || ready.has(file))) return build;
|
|
108
|
+
const current = stamp(file);
|
|
109
|
+
if (build?.state === "unreported" && current !== null && current !== baseline) { ready.add(file); return build; }
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
111
|
+
}
|
|
112
|
+
throw new Error("Preview build is still pending. Check the MCP build logs and invoke the preview again.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const status = () => ({ state, ...(child?.pid ? { pid: child.pid } : {}), ...(error ? { error } : {}) });
|
|
116
|
+
const buildStatus = () => inspectBuildStatus(root, config, command ? { session, processState: status() } : {});
|
|
117
|
+
|
|
118
|
+
async function stop() {
|
|
119
|
+
rmSync(requestFile, { force: true });
|
|
120
|
+
if (stopping) return stopping;
|
|
121
|
+
if (!child || childClosed) { if (command) state = "stopped"; return; }
|
|
122
|
+
if (state === "stopped") return;
|
|
123
|
+
state = "stopping";
|
|
124
|
+
stopping = (async () => {
|
|
125
|
+
kill("SIGTERM");
|
|
126
|
+
clearTimeout(killTimer);
|
|
127
|
+
const timer = setTimeout(() => kill("SIGKILL"), 1500);
|
|
128
|
+
try { await closed; } finally { clearTimeout(timer); }
|
|
129
|
+
})();
|
|
130
|
+
return stopping;
|
|
131
|
+
}
|
|
132
|
+
return { start, ensure, stop, kill: () => kill("SIGKILL"), status, buildStatus, setApps(apps) {
|
|
133
|
+
for (const app of apps) {
|
|
134
|
+
const file = path.resolve(root, app.file);
|
|
135
|
+
if (!baselines.has(file)) baselines.set(file, stamp(file));
|
|
136
|
+
}
|
|
137
|
+
config.apps = apps;
|
|
138
|
+
} };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Keep one shared handle while validated project configuration replaces its compiler. */
|
|
142
|
+
export function createReconfigurablePreviewWatch(root, config, options) {
|
|
143
|
+
let current = createPreviewWatch(root, config, options);
|
|
144
|
+
let signature = JSON.stringify(config);
|
|
145
|
+
let apps = config.apps;
|
|
146
|
+
let pending = Promise.resolve(), replacing = false, stopped = false;
|
|
147
|
+
return {
|
|
148
|
+
start() { if (!stopped && !replacing) current.start(); },
|
|
149
|
+
async ensure(file, signal) { await pending; if (stopped) throw new Error("Preview watcher is stopped."); return current.ensure(file, signal); },
|
|
150
|
+
status() { return replacing ? { state: "reconfiguring" } : current.status(); },
|
|
151
|
+
buildStatus() { return current.buildStatus(); },
|
|
152
|
+
setApps(next) { apps = next; current.setApps(next); },
|
|
153
|
+
kill() { current.kill(); },
|
|
154
|
+
reconfigure(next, force = false) {
|
|
155
|
+
const nextSignature = JSON.stringify(next);
|
|
156
|
+
if (!force && signature === nextSignature) return pending;
|
|
157
|
+
signature = nextSignature;
|
|
158
|
+
replacing = true;
|
|
159
|
+
pending = pending.then(async () => {
|
|
160
|
+
await current.stop();
|
|
161
|
+
if (stopped) return;
|
|
162
|
+
current = createPreviewWatch(root, { ...next, apps }, options);
|
|
163
|
+
current.start();
|
|
164
|
+
}).finally(() => { replacing = false; });
|
|
165
|
+
return pending;
|
|
166
|
+
},
|
|
167
|
+
async stop() { stopped = true; await pending; await current.stop(); },
|
|
168
|
+
};
|
|
169
|
+
}
|