@reqvire-org/ads 0.3.3 → 0.3.6
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 +17 -5
- package/dist/behaviors.mjs +157 -0
- package/dist/bootstrap-ds/ADS.md +16 -3
- package/dist/bootstrap-ds/README.md +10 -0
- package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
- package/dist/boundaries.mjs +3 -0
- package/dist/check.mjs +20 -2
- package/dist/cli.mjs +17 -0
- package/dist/config.mjs +23 -3
- package/dist/css-values.mjs +26 -0
- package/dist/index.mjs +2 -0
- package/dist/init.mjs +1 -1
- package/dist/mcp/build-status.mjs +17 -7
- package/dist/mcp/catalog.mjs +11 -8
- package/dist/mcp/context.mjs +3 -3
- package/dist/mcp/edit-preparation.mjs +111 -0
- package/dist/mcp/mutations.mjs +64 -38
- package/dist/mcp/preview-owner-guard.mjs +29 -0
- package/dist/mcp/preview-watch.mjs +38 -8
- package/dist/mcp/preview-widget.bundle.js +2 -2
- package/dist/mcp/prompts.mjs +2 -2
- package/dist/mcp/server.mjs +88 -41
- package/dist/mcp/showcase-host.mjs +9 -5
- 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 +39 -0
- package/dist/scenarios-compiler.mjs +41 -19
- package/dist/scenarios.mjs +72 -42
- package/dist/scoped-tokens.mjs +57 -0
- package/dist/skills/README.md +3 -3
- package/dist/skills/ads-break-ui/SKILL.md +26 -2
- 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 +27 -3
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +16 -3
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
- package/dist/skills/ads-designer/references/cli.md +1 -1
- package/dist/skills/ads-designer/references/design-craft.md +1 -1
- package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
- package/dist/skills/ads-import/SKILL.md +26 -2
- package/dist/skills/ads-import/references/cli.md +1 -1
- 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-assimilate → ads-migrate}/SKILL.md +32 -8
- package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
- package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
- package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
- package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +11 -0
- package/dist/skills/ads-motion/SKILL.md +26 -2
- package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
- package/dist/token-fixtures.mjs +30 -0
- package/dist/tokens.mjs +11 -4
- package/dist/units.mjs +35 -9
- package/dist/verification/browser.mjs +27 -0
- package/dist/verification/playwright-reporter.cjs +20 -0
- package/dist/verification.mjs +214 -0
- package/docs/ADS.md +72 -4
- package/docs/ARCHITECTURE.md +60 -1
- package/docs/BEHAVIORS.md +241 -0
- package/docs/CHANGELOG.md +56 -0
- package/docs/GUIDE.md +24 -4
- package/docs/MCP.md +106 -12
- package/package.json +4 -2
- package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
- /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
- /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
package/dist/mcp/server.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Server, ProtocolError, INVALID_PARAMS, ResourceNotFoundError } from "@modelcontextprotocol/server";
|
|
4
|
-
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
+
import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { checkProject } from "../check.mjs";
|
|
7
7
|
import { loadConfig, sourceFiles } from "../config.mjs";
|
|
8
|
+
import { behaviorStatus, createVerificationController, BEHAVIOR_URI } from "../verification.mjs";
|
|
8
9
|
import { inspectTokens } from "../tokens.mjs";
|
|
9
10
|
import { inspectScenarios } from "../scenarios.mjs";
|
|
10
11
|
import { startShowcaseHost } from "./showcase-host.mjs";
|
|
@@ -12,11 +13,14 @@ import { createSkillStore, page, SKILLS_EXTENSION } from "./skills.mjs";
|
|
|
12
13
|
import { inspectCatalog, projectDocuments, PROJECT_URI, readProjectDocument } from "./catalog.mjs";
|
|
13
14
|
import { APP_MIME, appMetadata, appUri, projectApp, TOKENS_APP_URI, tokensApp, tokenWidgetData, SCENARIO_APP_URI, scenarioApp, previewApp, PREVIEW_APP_URI } from "./apps.mjs";
|
|
14
15
|
import { PREVIEW_MANIFEST_URI, readPreviewManifest, readPreviewAsset } from "../preview-resources.mjs";
|
|
16
|
+
import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
|
|
15
17
|
import { getWorkflowPrompt, promptDefinitions } from "./prompts.mjs";
|
|
16
18
|
import { createReconfigurablePreviewWatch } from "./preview-watch.mjs";
|
|
17
19
|
import { BUILD_URI, buildFailure, inspectBuildStatus, summarizeBuild } from "./build-status.mjs";
|
|
18
20
|
import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow } from "./context.mjs";
|
|
19
21
|
import { createMutationController, mutationLimits } from "./mutations.mjs";
|
|
22
|
+
import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
|
|
23
|
+
import { reconcileTokenSamples } from "../token-fixtures.mjs";
|
|
20
24
|
|
|
21
25
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
22
26
|
class InvalidParamsError extends ProtocolError {
|
|
@@ -27,12 +31,14 @@ const empty = z.object({}).strict();
|
|
|
27
31
|
const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
|
|
28
32
|
const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
29
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.");
|
|
30
35
|
|
|
31
36
|
export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollInterval = 1000, previewWatch, showcaseUrl, notifications, inspection, mutations = false } = {}) {
|
|
32
37
|
const root = path.resolve(directory);
|
|
33
38
|
const cache = inspection ?? {};
|
|
34
39
|
const skills = cache.skills ??= createSkillStore(skillDirectory);
|
|
35
40
|
const edits = cache.edits ??= createMutationController(root);
|
|
41
|
+
const verification = cache.verification ??= createVerificationController(root);
|
|
36
42
|
let options = loadConfig(root);
|
|
37
43
|
const signature = `${root}:${JSON.stringify(options.config)}`;
|
|
38
44
|
if (cache.signature !== signature) {
|
|
@@ -52,13 +58,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
52
58
|
const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
|
|
53
59
|
const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
|
|
54
60
|
const scenarioWidget = () => options.config.mcp.previewManifest ? PREVIEW_APP_URI : SCENARIO_APP_URI;
|
|
61
|
+
const previewOptions = () => withPreviewSession(options, previewWatch?.resourceSession?.());
|
|
55
62
|
const resourceAssets = () => {
|
|
56
|
-
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 })); }
|
|
57
64
|
catch { return []; } // Discovery and diagnostics remain usable after a failed graph.
|
|
58
65
|
};
|
|
59
66
|
function graphSelection(id) {
|
|
60
67
|
if (!options.config.mcp.previewManifest) return {};
|
|
61
|
-
const manifest = readPreviewManifest(
|
|
68
|
+
const manifest = readPreviewManifest(previewOptions());
|
|
62
69
|
if (!manifest?.scenarios.some(entry => entry.id === id)) throw new Error("The preview resource graph is not ready for this scenario. Rebuild previews.");
|
|
63
70
|
return { manifestUri: PREVIEW_MANIFEST_URI, manifestRevision: manifest.revision };
|
|
64
71
|
}
|
|
@@ -77,11 +84,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
77
84
|
const skillEntries = new Set(skills.skills.map((skill) => skill.uri));
|
|
78
85
|
return [
|
|
79
86
|
{ uri: GUIDE_URI, name: "Start here: design-system workflow", description: "Read first for UI/ADS tasks, especially when only resource access is exposed. Explains the exact workspace, tool sequence, skill selection, previews, project commands and fallback.", mimeType: "text/markdown" },
|
|
87
|
+
{ uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
|
|
80
88
|
{ uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
|
|
81
89
|
{ uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
|
|
82
90
|
{ uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
|
|
83
91
|
{ uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
|
|
84
92
|
{ uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
|
|
93
|
+
{ uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
|
|
85
94
|
{ uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
|
|
86
95
|
{ uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
|
|
87
96
|
{ uri: SCENARIO_APP_URI, name: "Experimental scenario widget", mimeType: APP_MIME },
|
|
@@ -98,18 +107,20 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
98
107
|
const context = project();
|
|
99
108
|
return { projectRoot: root, workflow: workflow([...previews(), ...scenarioApps()]), project: context,
|
|
100
109
|
package: pkg.name, version: pkg.version, dsDir: options.config.dsDir, publicAlias: options.config.publicAlias,
|
|
101
|
-
components: getCatalog().filter((item) => item.kind === "components").length,
|
|
102
|
-
patterns: getCatalog().filter((item) => item.kind === "patterns").length,
|
|
110
|
+
components: getCatalog().filter((item) => item.kind === "components" && item.apiKind === "ui").length,
|
|
111
|
+
patterns: getCatalog().filter((item) => item.kind === "patterns" && item.apiKind === "ui").length,
|
|
103
112
|
tokenCount: inspectTokens(root).tokens.length, showcaseUrl: getShowcaseUrl(),
|
|
104
113
|
previewWatch: previewWatch?.status() ?? { state: "disabled" },
|
|
105
114
|
mutations: mutations ? edits.status() : { enabled: false },
|
|
106
115
|
build: summarizeBuild(builds()),
|
|
107
116
|
previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
|
|
108
117
|
scenarios: { count: getRegistry().scenarios.length, diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios`, tool: "ads_show_scenario" },
|
|
118
|
+
behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", ...verification.status() },
|
|
109
119
|
experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
|
|
110
120
|
}
|
|
111
121
|
async function read(uri, signal) {
|
|
112
122
|
if (uri === GUIDE_URI) return { contents: [{ uri, mimeType: "text/markdown", text: projectGuide(project(), [...previews(), ...scenarioApps()]) }] };
|
|
123
|
+
if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
|
|
113
124
|
if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
|
|
114
125
|
let value;
|
|
115
126
|
if (uri === `${PROJECT_URI}/status`) value = status();
|
|
@@ -118,8 +129,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
118
129
|
else if (uri === `${PROJECT_URI}/catalog`) value = { items: getCatalog() };
|
|
119
130
|
else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
|
|
120
131
|
else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
|
|
132
|
+
else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
|
|
121
133
|
else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
|
|
122
|
-
value = readPreviewManifest(
|
|
134
|
+
value = readPreviewManifest(previewOptions());
|
|
123
135
|
if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
|
|
124
136
|
}
|
|
125
137
|
if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
|
|
@@ -127,7 +139,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
127
139
|
if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
|
|
128
140
|
if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
|
|
129
141
|
if (uri.startsWith("ads://assets/") && options.config.mcp.previewManifest) {
|
|
130
|
-
try { return { contents: [readPreviewAsset(
|
|
142
|
+
try { return { contents: [readPreviewAsset(previewOptions(), uri)] }; }
|
|
131
143
|
catch (error) { if (error.code === "ADS_PREVIEW_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
|
|
132
144
|
}
|
|
133
145
|
const app = allApps().find((entry) => appUri(entry.id) === uri);
|
|
@@ -150,20 +162,30 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
150
162
|
return build;
|
|
151
163
|
}
|
|
152
164
|
|
|
165
|
+
async function applyEdits(args,signal) {
|
|
166
|
+
const result = await edits.apply(args,signal);
|
|
167
|
+
if (result.applied) {
|
|
168
|
+
try {
|
|
169
|
+
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);
|
|
170
|
+
await refresh(true);
|
|
171
|
+
} 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.`}];}
|
|
172
|
+
}
|
|
173
|
+
return {...json(result),...(!result.ok ? {isError:true} : {})};
|
|
174
|
+
}
|
|
153
175
|
function tools() {
|
|
154
176
|
return [
|
|
155
177
|
{ 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()) },
|
|
156
|
-
{ name: "ads_catalog", description: "Before creating/changing UI, find existing public components/patterns with a small query/limit. Returns import alias and exact source/guide URIs so you can reuse or edit the owner. Read ads_status first for workspace/workflow. Metadata only; does not render UI.", schema: query.extend({ kind: z.enum(["components", "patterns"]).optional() }), call: ({ query: search = "", limit, kind }) => {
|
|
178
|
+
{ name: "ads_catalog", description: "Before creating/changing UI or behavior, find existing public components/patterns/controllers and hooks with a small query/limit. Returns import alias and exact source/guide URIs so you can reuse or edit the owner. Read ads_status first for workspace/workflow. Metadata only; does not render UI.", schema: query.extend({ kind: z.enum(["components", "patterns", "controllers"]).optional() }), call: ({ query: search = "", limit, kind }) => {
|
|
157
179
|
const matches = getCatalog().filter((item) => (!kind || item.kind === kind) && item.name.toLowerCase().includes(search.toLowerCase()));
|
|
158
180
|
const units = edits.units();
|
|
159
181
|
return json({ total: matches.length, items: matches.slice(0, limit).map(item => ({ ...item, unit: units.find(unit => unit.files.includes(item.file))?.id ?? null })), resourceUri: `${PROJECT_URI}/catalog` });
|
|
160
182
|
} },
|
|
161
|
-
{ name: "ads_scenarios", description: "Find an independently mountable token, component, pattern or composition preview. IDs derive from source filenames; covers lists public UI and tokens lists the real token inputs. Use ads_show_scenario with an exact returned id. Showcase and MCP use this same registry.", schema: query.extend({ kind: z.enum(["tokens", "components", "patterns", "composition"]).optional(), offset: z.number().int().min(0).default(0) }), call: ({ query: search = "", limit, kind, offset }) => {
|
|
162
|
-
const matches = getRegistry().scenarios.filter(item => (!kind || item.kind === kind) && `${item.id} ${item.title} ${item.covers.join(" ")} ${item.tokens.join(" ")}`.toLowerCase().includes(search.toLowerCase()));
|
|
183
|
+
{ name: "ads_scenarios", description: "Find an independently mountable token, component, pattern or composition preview. IDs derive from source filenames; covers lists rendered public UI, exercises lists behavioral APIs and tokens lists the real token inputs. Use ads_show_scenario with an exact returned id. Showcase and MCP use this same registry.", schema: query.extend({ kind: z.enum(["tokens", "components", "patterns", "composition"]).optional(), offset: z.number().int().min(0).default(0) }), call: ({ query: search = "", limit, kind, offset }) => {
|
|
184
|
+
const matches = getRegistry().scenarios.filter(item => (!kind || item.kind === kind) && `${item.id} ${item.title} ${item.covers.join(" ")} ${(item.exercises ?? []).join(" ")} ${item.tokens.join(" ")}`.toLowerCase().includes(search.toLowerCase()));
|
|
163
185
|
const units = edits.units();
|
|
164
186
|
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` });
|
|
165
187
|
} },
|
|
166
|
-
{ 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) => {
|
|
188
|
+
{ 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) => {
|
|
167
189
|
const entry = getRegistry().scenarios.find(item => item.id === id);
|
|
168
190
|
if (!entry) throw new Error("Unknown scenario id. Read ads_scenarios; IDs must match discovered source entries.");
|
|
169
191
|
if (getRegistry().diagnostics.length) throw new Error("Scenario registry has errors. Read ads://project/scenarios and fix its diagnostics.");
|
|
@@ -185,7 +207,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
185
207
|
const result = checkProject(root);
|
|
186
208
|
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` });
|
|
187
209
|
} },
|
|
188
|
-
{ name: "ads_skills", description: "Select one ADS workflow after ads_status: ads-designer for building/refining/debugging UI; ads-
|
|
210
|
+
{ name: "ads_skills", description: "Select one ADS workflow after ads_status: ads-designer for building/refining/debugging UI; ads-migrate for a library reference; ads-import for another project's ADS; ads-motion for animation; ads-break-ui for stress testing. Then read that workflow with ads_read_skill. Listing does not activate a native skill.", schema: empty, call: () => json({ skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, ...frontmatter })) }) },
|
|
189
211
|
{ name: "ads_read_skill", description: "Read a selected skill or relative supporting text file in bounded chunks. This is explicit instruction access, not automatic native skill activation/install.", schema: z.object({ name: z.string().max(64), file: z.string().max(300).default("SKILL.md"), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(8000).default(6000) }).strict(), call: ({ name, file, offset, limit }) => {
|
|
190
212
|
const skill = skills.skills.find((entry) => entry.frontmatter.name === name);
|
|
191
213
|
if (!skill) throw new Error("Unknown ADS skill name.");
|
|
@@ -196,7 +218,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
196
218
|
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." });
|
|
197
219
|
return json({ ...manifest, text: content.text.slice(offset, offset + limit), offset, ...(offset + limit < content.text.length ? { nextOffset: offset + limit } : {}) });
|
|
198
220
|
} },
|
|
199
|
-
{ 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) => {
|
|
221
|
+
{ 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) => {
|
|
200
222
|
if (!category && options.config.mcp.previewManifest) category = tokenScenarios()[0]?.id;
|
|
201
223
|
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.");
|
|
202
224
|
if (category) {
|
|
@@ -214,27 +236,39 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
214
236
|
}
|
|
215
237
|
return json({ widget: { resourceUri: TOKENS_APP_URI }, category: null, tokenCount: inspectTokens(root).tokens.length, dataUri: `${PROJECT_URI}/tokens`, showcaseUrl: getShowcaseUrl() });
|
|
216
238
|
} },
|
|
217
|
-
{ name: "
|
|
239
|
+
{ name: "ads_behaviors", description: "Discover owner-local behavioral guarantees for public hooks/APIs or private controllers, their scenario demonstrations, registered checks/profiles and fresh or stale execution evidence. Optional exercises links behavioral APIs to scenarios; covers remains rendered UI. Metadata and reachable calls do not prove a guarantee passed. Use ads_verify for selected registered checks.", schema: query.extend({ unit: z.string().max(256).optional(), api: z.string().max(200).optional() }), call: ({ query: search = "", limit, unit, api }) => {
|
|
240
|
+
const inventory = behaviorStatus(options);
|
|
241
|
+
const matches = inventory.contracts.filter(item => (!unit || item.unit === unit) && (!api || Object.values(item.subjects).some(subject => subject.api === api)) && JSON.stringify(item).toLowerCase().includes(search.toLowerCase()));
|
|
242
|
+
return json({ total: matches.length, contracts: matches.slice(0, limit).map(item => {
|
|
243
|
+
if (unit) return item;
|
|
244
|
+
const apis = Object.values(item.subjects).filter(subject => subject.api).map(subject => subject.api);
|
|
245
|
+
const demonstrations = [...new Set(Object.values(item.demonstrations).flat())];
|
|
246
|
+
return { unit: item.unit, file: item.file, apis: apis.slice(0, 20), apiCount: apis.length,
|
|
247
|
+
guarantees: item.guarantees.slice(0, 5).map(guarantee => ({ id: guarantee.id, result: guarantee.result })), guaranteeCount: item.guarantees.length,
|
|
248
|
+
demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
|
|
249
|
+
}), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
|
|
250
|
+
} },
|
|
251
|
+
{ name: "ads_verify", description: "Run one owner's registered development behavior checks using its configured runner/profile. Executes trusted project commands with bounded output, cancellation and process-tree cleanup; installs nothing. Browser profiles use an isolated managed showcase/MCP App. React StrictMode requires a project development harness, not a production widget. Missing/skipped cases, stale inputs and process exit alone cannot produce passing evidence. Discover exact unit/check IDs with ads_behaviors first.", schema: z.object({ unit: z.string().min(1).max(256), checks: z.array(z.string().min(1).max(100)).min(1).max(100).optional() }).strict(), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, call: async (args, signal) => {
|
|
252
|
+
const result = await verification.run(args, signal);
|
|
253
|
+
await refresh();
|
|
254
|
+
return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
|
|
255
|
+
} },
|
|
256
|
+
{ name: "ads_units", description: "Discover editable ADS units and their files: components/patterns with colocated previews; shared controllers; composition; tokens; foundation shared files; documentation; assets/brand, assets/fonts and asset groups; configuration with ds.config.json, package/build/lint configuration and registered project guard/compiler sources. List summaries with query, or pass an exact unit ID for its manifest. Manifests include companionFiles with permitted shared-entry or owner-only style-boundary bookkeeping; preserve peer permissions and unrelated configuration. Imported peers stay separate. New owners follow the existing colocation convention.", schema: query.extend({ unit: z.string().min(1).max(256).optional() }), call: ({ unit, query: search = "", limit }) => {
|
|
218
257
|
const matches = edits.units().filter(item => unit ? item.id === unit : item.id.toLowerCase().includes(search.toLowerCase()));
|
|
219
258
|
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 } : {}) } : {}) })) });
|
|
220
259
|
} },
|
|
221
260
|
{ 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)) },
|
|
222
|
-
...(mutations ? [{ name: "
|
|
261
|
+
...(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)) },
|
|
262
|
+
{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=>{
|
|
263
|
+
const {patch,...result}=reconcileTokenSamples(options,args);
|
|
264
|
+
return json({...result,removed:result.removed.slice(0,50),unresolved:result.unresolved.slice(0,50),unresolvedCount:result.unresolved.length,...storePreparedPatch(options,patch)});
|
|
265
|
+
}},
|
|
266
|
+
{ 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.",
|
|
223
267
|
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."),
|
|
224
268
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
|
|
225
|
-
call:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
|
|
230
|
-
await refresh(true);
|
|
231
|
-
}
|
|
232
|
-
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.` }]; }
|
|
233
|
-
}
|
|
234
|
-
return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
|
|
235
|
-
},
|
|
236
|
-
}] : []),
|
|
237
|
-
...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) => {
|
|
269
|
+
call: applyEdits,
|
|
270
|
+
}, {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}] : []),
|
|
271
|
+
...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) => {
|
|
238
272
|
// Validate readiness without embedding executable HTML in model results.
|
|
239
273
|
const build = await previewBuild(app, signal);
|
|
240
274
|
projectApp(options, app);
|
|
@@ -295,8 +329,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
295
329
|
try { const stat = statSync(file); return `${file}:${stat.mtimeMs}:${stat.size}`; } catch { return `${file}:missing`; }
|
|
296
330
|
}).join("\n");
|
|
297
331
|
}
|
|
298
|
-
const fingerprint = () => fileFingerprint([...sourceFiles(options.dsRoot), ...getDocuments().map((doc) => doc.file), path.join(root, "ds.config.json"), path.join(root, "package.json")]);
|
|
299
|
-
const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, app.file)));
|
|
332
|
+
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")]);
|
|
333
|
+
const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, previewArtifactPath(previewOptions(), app.file))));
|
|
300
334
|
const assetFingerprint = () => resourceAssets().map(asset => asset.uri).sort().join("\n");
|
|
301
335
|
let previous = fingerprint();
|
|
302
336
|
let previousArtifacts = artifactFingerprint();
|
|
@@ -330,7 +364,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
330
364
|
previousAssets = assetFingerprint();
|
|
331
365
|
previousBuild = JSON.stringify(builds());
|
|
332
366
|
const targets = filesChanged ? era === "modern" ? resources().map((entry) => entry.uri).filter((uri) => !skills.files.has(uri)) : [...subscribed]
|
|
333
|
-
: [BUILD_URI, `${PROJECT_URI}/status`, ...(options.config.mcp.previewManifest ? [PREVIEW_MANIFEST_URI] : []), ...(artifactsChanged ? allApps().map(app => appUri(app.id)) : [])].filter((uri) => era === "modern" || subscribed.has(uri));
|
|
367
|
+
: [BUILD_URI, BEHAVIOR_URI, `${PROJECT_URI}/status`, ...(options.config.mcp.previewManifest ? [PREVIEW_MANIFEST_URI] : []), ...(artifactsChanged ? allApps().map(app => appUri(app.id)) : [])].filter((uri) => era === "modern" || subscribed.has(uri));
|
|
334
368
|
for (const uri of targets) { if (notifications) notifications.resourceUpdated(uri); else await server.sendResourceUpdated({ uri }); }
|
|
335
369
|
return true;
|
|
336
370
|
}
|
|
@@ -338,7 +372,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
338
372
|
timer?.unref();
|
|
339
373
|
server.oninitialized = () => previewWatch?.start();
|
|
340
374
|
server.onclose = () => { if (timer) clearInterval(timer); };
|
|
341
|
-
return { server, refresh, async close() { if (timer) clearInterval(timer); await server.close(); } };
|
|
375
|
+
return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) await verification.close(); await server.close(); } };
|
|
342
376
|
}
|
|
343
377
|
|
|
344
378
|
export async function startAdsMcp(directory = process.cwd(), { stdio = true, showcase = true, port = 0, mutations = false } = {}) {
|
|
@@ -353,25 +387,38 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
|
|
|
353
387
|
{ port, observe: notifications => createAdsMcp(directory, { era: "modern", previewWatch, showcaseUrl, inspection, mutations, notifications }) });
|
|
354
388
|
process.stderr.write(`ADS showcase: ${host.url}\n`);
|
|
355
389
|
}
|
|
356
|
-
const
|
|
390
|
+
const transport = stdio ? new StdioServerTransport() : null;
|
|
391
|
+
const handle = stdio ? serveStdio(({ era }) => createAdsMcp(directory, { era, previewWatch, showcaseUrl, inspection, mutations }).server, { transport, legacy: "serve", onerror: (error) => process.stderr.write(`ads mcp: ${error.message}\n`) }) : null;
|
|
357
392
|
let closing;
|
|
358
393
|
function close() {
|
|
359
394
|
return closing ??= (async () => {
|
|
360
395
|
inspection.edits?.cancel();
|
|
361
|
-
|
|
362
|
-
await
|
|
363
|
-
|
|
364
|
-
|
|
396
|
+
// Stop every owned resource even if another close fails or has in-flight RPCs.
|
|
397
|
+
const results = await Promise.allSettled([
|
|
398
|
+
Promise.resolve().then(() => host?.close()),
|
|
399
|
+
previewWatch.stop(),
|
|
400
|
+
Promise.resolve().then(() => inspection.verification?.close()),
|
|
401
|
+
Promise.resolve().then(() => handle?.close()),
|
|
402
|
+
]);
|
|
403
|
+
for (const event of ["end", "close", "error"]) process.stdin.removeListener(event, disconnect);
|
|
365
404
|
for (const signal of ["SIGINT", "SIGTERM"]) process.removeListener(signal, disconnect);
|
|
366
405
|
process.removeListener("exit", forceCleanup);
|
|
406
|
+
const errors = results.filter(result => result.status === "rejected").map(result => result.reason);
|
|
407
|
+
if (errors.length) throw new AggregateError(errors, "ADS shutdown failed");
|
|
367
408
|
})();
|
|
368
409
|
}
|
|
369
|
-
function disconnect() { void close(); }
|
|
410
|
+
function disconnect() { void close().catch(error => { process.stderr.write(`ads mcp: ${error.message}\n`); process.exitCode = 1; }); }
|
|
370
411
|
function forceCleanup() { previewWatch.kill(); }
|
|
371
412
|
// Probe instances have no managed child; a real connection activates it.
|
|
372
|
-
//
|
|
373
|
-
|
|
413
|
+
// The SDK also closes its wire on stdout failure. Closing a probe instance
|
|
414
|
+
// must not shut the runtime down, so observe the wire, not server.onclose.
|
|
415
|
+
if (transport) {
|
|
416
|
+
const wireClosed = transport.onclose;
|
|
417
|
+
transport.onclose = () => { try { wireClosed?.(); } finally { disconnect(); } };
|
|
418
|
+
for (const event of ["end", "close", "error"]) process.stdin.once(event, disconnect);
|
|
419
|
+
if (process.stdin.readableEnded || process.stdin.destroyed) disconnect();
|
|
420
|
+
}
|
|
374
421
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, disconnect);
|
|
375
422
|
process.once("exit", forceCleanup);
|
|
376
|
-
return { close, url: host?.url };
|
|
423
|
+
return { close, url: host?.url, previewRevision: () => readPreviewManifest(withPreviewSession(loadConfig(root), previewWatch.resourceSession()))?.revision ?? null };
|
|
377
424
|
}
|
|
@@ -56,13 +56,17 @@ export async function startShowcaseHost(factory, { port = 0, observe } = {}) {
|
|
|
56
56
|
let closing;
|
|
57
57
|
return { url: `http://127.0.0.1:${web.address().port}/`, close() {
|
|
58
58
|
return closing ??= (async () => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await handler.close();
|
|
62
|
-
await sandbox?.close();
|
|
59
|
+
// Close sockets first: live subscriptions and preview requests must not
|
|
60
|
+
// hold shutdown open while protocol instances wait for cancellation.
|
|
63
61
|
const stopped = new Promise(resolve => web.close(resolve));
|
|
62
|
+
for (const request of requests) request.abort();
|
|
64
63
|
web.closeAllConnections();
|
|
65
|
-
await stopped
|
|
64
|
+
const results = await Promise.allSettled([stopped,
|
|
65
|
+
Promise.resolve().then(() => observer?.close()),
|
|
66
|
+
handler.close(), sandbox?.close(),
|
|
67
|
+
]);
|
|
68
|
+
const errors = results.filter(result => result.status === "rejected").map(result => result.reason);
|
|
69
|
+
if (errors.length) throw new AggregateError(errors, "Showcase shutdown failed");
|
|
66
70
|
})();
|
|
67
71
|
} };
|
|
68
72
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { isDevelopmentFile, isInside } from "./config.mjs";
|
|
4
|
+
import { uiOperation } from "./ui-framework.mjs";
|
|
5
|
+
|
|
6
|
+
export function publicApi(program, options) {
|
|
7
|
+
const checker = program.getTypeChecker();
|
|
8
|
+
const entry = program.getSourceFile(path.join(options.dsRoot, options.config.publicEntry));
|
|
9
|
+
const module = entry && checker.getSymbolAtLocation(entry);
|
|
10
|
+
const items = [];
|
|
11
|
+
for (const exported of module ? checker.getExportsOfModule(module) : []) {
|
|
12
|
+
let symbol = exported;
|
|
13
|
+
if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
|
|
14
|
+
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
|
|
15
|
+
if (!declaration || !(symbol.flags & ts.SymbolFlags.Value)) continue;
|
|
16
|
+
const file = declaration.getSourceFile().fileName;
|
|
17
|
+
if (isDevelopmentFile(file, options)) continue;
|
|
18
|
+
const kind = isInside(path.join(options.dsRoot, "components"), file) ? "components" : isInside(path.join(options.dsRoot, "patterns"), file) ? "patterns" : isInside(path.join(options.dsRoot, "controllers"), file) ? "controllers" : null;
|
|
19
|
+
if (!kind) continue;
|
|
20
|
+
let renderable = false;
|
|
21
|
+
function visit(node) {
|
|
22
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) renderable = true;
|
|
23
|
+
if (ts.isCallExpression(node) && ["createElement", "forwardRef", "memo"].includes(uiOperation(node.expression, checker))) renderable = true;
|
|
24
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
25
|
+
let tag = node.tag;
|
|
26
|
+
if (ts.isCallExpression(tag)) tag = tag.expression;
|
|
27
|
+
let base = tag;
|
|
28
|
+
while (ts.isPropertyAccessExpression(base)) base = base.expression;
|
|
29
|
+
const symbol = checker.getSymbolAtLocation(base);
|
|
30
|
+
if (symbol?.declarations?.some(item => ts.isImportSpecifier(item) && (item.propertyName ?? item.name).text === "styled" && item.parent.parent.parent.moduleSpecifier.text.startsWith("@linaria/") || ts.isNamespaceImport(item) && item.parent.parent.moduleSpecifier.text.startsWith("@linaria/") && /^\.styled(?:\.|$)/.test(tag.getText().slice(base.getText().length)))) renderable = true;
|
|
31
|
+
}
|
|
32
|
+
ts.forEachChild(node, visit);
|
|
33
|
+
}
|
|
34
|
+
visit(declaration);
|
|
35
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
|
|
36
|
+
items.push({ name: exported.name, kind, file, declaration, renderable: kind !== "controllers" && renderable, behavioral: !!(type.getCallSignatures().length || type.getConstructSignatures().length) });
|
|
37
|
+
}
|
|
38
|
+
return items;
|
|
39
|
+
}
|