@reqvire-org/ads 0.3.5 → 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 +5 -1
- package/dist/bootstrap-ds/ADS.md +9 -3
- package/dist/bootstrap-ds/README.md +10 -0
- package/dist/boundaries.mjs +3 -0
- package/dist/check.mjs +13 -0
- package/dist/config.mjs +6 -2
- package/dist/mcp/build-status.mjs +7 -4
- package/dist/mcp/context.mjs +2 -2
- package/dist/mcp/edit-preparation.mjs +111 -0
- package/dist/mcp/mutations.mjs +54 -38
- package/dist/mcp/preview-watch.mjs +10 -4
- package/dist/mcp/server.mjs +32 -22
- package/dist/mcp/text-diff.mjs +19 -0
- package/dist/preview-resources.mjs +45 -8
- package/dist/preview-scope.mjs +30 -0
- package/dist/scenarios-compiler.mjs +24 -17
- package/dist/scenarios.mjs +46 -4
- package/dist/scoped-tokens.mjs +57 -0
- package/dist/skills/ads-break-ui/SKILL.md +4 -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 +4 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +9 -3
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
- package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
- package/dist/skills/ads-import/SKILL.md +4 -2
- package/dist/skills/ads-import/references/guarded-editing.md +44 -0
- package/dist/skills/ads-import/references/tokens.md +4 -2
- package/dist/skills/ads-migrate/SKILL.md +4 -2
- package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
- package/dist/skills/ads-motion/SKILL.md +4 -2
- package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
- package/dist/token-fixtures.mjs +30 -0
- package/dist/tokens.mjs +8 -2
- package/dist/units.mjs +15 -0
- package/dist/verification.mjs +1 -1
- package/docs/ADS.md +45 -2
- package/docs/ARCHITECTURE.md +21 -0
- package/docs/BEHAVIORS.md +11 -0
- package/docs/CHANGELOG.md +20 -0
- package/docs/GUIDE.md +8 -3
- package/docs/MCP.md +60 -5
- package/package.json +1 -1
package/dist/mcp/server.mjs
CHANGED
|
@@ -13,11 +13,14 @@ import { createSkillStore, page, SKILLS_EXTENSION } from "./skills.mjs";
|
|
|
13
13
|
import { inspectCatalog, projectDocuments, PROJECT_URI, readProjectDocument } from "./catalog.mjs";
|
|
14
14
|
import { APP_MIME, appMetadata, appUri, projectApp, TOKENS_APP_URI, tokensApp, tokenWidgetData, SCENARIO_APP_URI, scenarioApp, previewApp, PREVIEW_APP_URI } from "./apps.mjs";
|
|
15
15
|
import { PREVIEW_MANIFEST_URI, readPreviewManifest, readPreviewAsset } from "../preview-resources.mjs";
|
|
16
|
+
import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
|
|
16
17
|
import { getWorkflowPrompt, promptDefinitions } from "./prompts.mjs";
|
|
17
18
|
import { createReconfigurablePreviewWatch } from "./preview-watch.mjs";
|
|
18
19
|
import { BUILD_URI, buildFailure, inspectBuildStatus, summarizeBuild } from "./build-status.mjs";
|
|
19
20
|
import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow } from "./context.mjs";
|
|
20
21
|
import { createMutationController, mutationLimits } from "./mutations.mjs";
|
|
22
|
+
import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
|
|
23
|
+
import { reconcileTokenSamples } from "../token-fixtures.mjs";
|
|
21
24
|
|
|
22
25
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
23
26
|
class InvalidParamsError extends ProtocolError {
|
|
@@ -28,6 +31,7 @@ const empty = z.object({}).strict();
|
|
|
28
31
|
const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
|
|
29
32
|
const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
30
33
|
const schemas = { params: z.object({ cursor: z.string().optional() }).passthrough(), result: z.object({ resultType: z.literal("complete") }).passthrough() };
|
|
34
|
+
const ownerPatch = z.object({ unit: z.string().min(1).max(256), patch: z.string().min(1).max(mutationLimits.patchBytes).optional(), patchFile: z.string().min(1).max(500).optional() }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile.");
|
|
31
35
|
|
|
32
36
|
export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollInterval = 1000, previewWatch, showcaseUrl, notifications, inspection, mutations = false } = {}) {
|
|
33
37
|
const root = path.resolve(directory);
|
|
@@ -54,13 +58,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
54
58
|
const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
|
|
55
59
|
const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
|
|
56
60
|
const scenarioWidget = () => options.config.mcp.previewManifest ? PREVIEW_APP_URI : SCENARIO_APP_URI;
|
|
61
|
+
const previewOptions = () => withPreviewSession(options, previewWatch?.resourceSession?.());
|
|
57
62
|
const resourceAssets = () => {
|
|
58
|
-
try { return Object.entries(readPreviewManifest(
|
|
63
|
+
try { return Object.entries(readPreviewManifest(previewOptions())?.assets ?? {}).map(([uri, record]) => ({ uri, name: `Preview asset ${uri.slice("ads://assets/".length)}`, mimeType: record.mimeType, size: record.size })); }
|
|
59
64
|
catch { return []; } // Discovery and diagnostics remain usable after a failed graph.
|
|
60
65
|
};
|
|
61
66
|
function graphSelection(id) {
|
|
62
67
|
if (!options.config.mcp.previewManifest) return {};
|
|
63
|
-
const manifest = readPreviewManifest(
|
|
68
|
+
const manifest = readPreviewManifest(previewOptions());
|
|
64
69
|
if (!manifest?.scenarios.some(entry => entry.id === id)) throw new Error("The preview resource graph is not ready for this scenario. Rebuild previews.");
|
|
65
70
|
return { manifestUri: PREVIEW_MANIFEST_URI, manifestRevision: manifest.revision };
|
|
66
71
|
}
|
|
@@ -126,7 +131,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
126
131
|
else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
|
|
127
132
|
else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
|
|
128
133
|
else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
|
|
129
|
-
value = readPreviewManifest(
|
|
134
|
+
value = readPreviewManifest(previewOptions());
|
|
130
135
|
if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
|
|
131
136
|
}
|
|
132
137
|
if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
|
|
@@ -134,7 +139,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
134
139
|
if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
|
|
135
140
|
if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
|
|
136
141
|
if (uri.startsWith("ads://assets/") && options.config.mcp.previewManifest) {
|
|
137
|
-
try { return { contents: [readPreviewAsset(
|
|
142
|
+
try { return { contents: [readPreviewAsset(previewOptions(), uri)] }; }
|
|
138
143
|
catch (error) { if (error.code === "ADS_PREVIEW_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
|
|
139
144
|
}
|
|
140
145
|
const app = allApps().find((entry) => appUri(entry.id) === uri);
|
|
@@ -157,6 +162,16 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
157
162
|
return build;
|
|
158
163
|
}
|
|
159
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
|
+
}
|
|
160
175
|
function tools() {
|
|
161
176
|
return [
|
|
162
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()) },
|
|
@@ -170,7 +185,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
170
185
|
const units = edits.units();
|
|
171
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` });
|
|
172
187
|
} },
|
|
173
|
-
{ name: "ads_show_scenario", description: "Render one real token, component, pattern or composition scenario with its colocated fixtures and state adapter. First get an exact id from ads_scenarios or ads_catalog.previews. The managed compiler builds it on demand; errors return diagnostics instead of falsely current output. Requires MCP Apps plus host resource forwarding; fallback links to the same scenario in the project showcase.", schema: z.object({ id: z.string().min(1).max(256) }).strict(), _meta: { ui: { resourceUri: scenarioWidget() } }, call: async ({ id }, signal) => {
|
|
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) => {
|
|
174
189
|
const entry = getRegistry().scenarios.find(item => item.id === id);
|
|
175
190
|
if (!entry) throw new Error("Unknown scenario id. Read ads_scenarios; IDs must match discovered source entries.");
|
|
176
191
|
if (getRegistry().diagnostics.length) throw new Error("Scenario registry has errors. Read ads://project/scenarios and fix its diagnostics.");
|
|
@@ -203,7 +218,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
203
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." });
|
|
204
219
|
return json({ ...manifest, text: content.text.slice(offset, offset + limit), offset, ...(offset + limit < content.text.length ? { nextOffset: offset + limit } : {}) });
|
|
205
220
|
} },
|
|
206
|
-
{ name: "ads_show_tokens", description: "Render an exact discovered token scenario with its project-owned templates and fixtures. Pass its id as category, discovered through ads_tokens.categories or ads_scenarios(kind: tokens). Omit category for the first project token scenario, or a generic definition inspector for older compilers.", schema: z.object({ category: z.string().max(256).optional() }).strict(), _meta: { ui: { resourceUri: options.config.mcp.previewManifest ? PREVIEW_APP_URI : TOKENS_APP_URI } }, call: async ({ category }, signal) => {
|
|
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) => {
|
|
207
222
|
if (!category && options.config.mcp.previewManifest) category = tokenScenarios()[0]?.id;
|
|
208
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.");
|
|
209
224
|
if (category) {
|
|
@@ -243,22 +258,17 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
243
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 } : {}) } : {}) })) });
|
|
244
259
|
} },
|
|
245
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)) },
|
|
246
|
-
...(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.",
|
|
247
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."),
|
|
248
268
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
|
|
249
|
-
call:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration") await previewWatch?.reconfigure?.(loadConfig(root).config.mcp, true);
|
|
254
|
-
await refresh(true);
|
|
255
|
-
}
|
|
256
|
-
catch (error) { result.warnings = [...(result.warnings ?? []), { rule: "mutation-notification", message: `Changes were applied, but preview refresh failed: ${error.message.slice(0, 2000)}. Recheck ads_status and reopen the scenario.` }]; }
|
|
257
|
-
}
|
|
258
|
-
return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
|
|
259
|
-
},
|
|
260
|
-
}] : []),
|
|
261
|
-
...options.config.mcp.apps.map((app) => ({ name: `ads_show_${app.id.replaceAll("-", "_")}`, description: `${app.description ?? app.title}. Renders the project's built ${app.kind} examples through MCP Apps; text fallback provides the showcase URL.`, schema: empty, _meta: { ui: { resourceUri: appUri(app.id) } }, call: async (_args, signal) => {
|
|
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) => {
|
|
262
272
|
// Validate readiness without embedding executable HTML in model results.
|
|
263
273
|
const build = await previewBuild(app, signal);
|
|
264
274
|
projectApp(options, app);
|
|
@@ -320,7 +330,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
320
330
|
}).join("\n");
|
|
321
331
|
}
|
|
322
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")]);
|
|
323
|
-
const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, app.file)));
|
|
333
|
+
const artifactFingerprint = () => fileFingerprint(allApps().map(app => path.resolve(root, previewArtifactPath(previewOptions(), app.file))));
|
|
324
334
|
const assetFingerprint = () => resourceAssets().map(asset => asset.uri).sort().join("\n");
|
|
325
335
|
let previous = fingerprint();
|
|
326
336
|
let previousArtifacts = artifactFingerprint();
|
|
@@ -410,5 +420,5 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
|
|
|
410
420
|
}
|
|
411
421
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, disconnect);
|
|
412
422
|
process.once("exit", forceCleanup);
|
|
413
|
-
return { close, url: host?.url };
|
|
423
|
+
return { close, url: host?.url, previewRevision: () => readPreviewManifest(withPreviewSession(loadConfig(root), previewWatch.resourceSession()))?.revision ?? null };
|
|
414
424
|
}
|
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
import { loadConfig, isInside } from "./config.mjs";
|
|
5
5
|
import { inspectScenarios } from "./scenarios.mjs";
|
|
6
6
|
import { artifactDigest, createBuildReporter } from "./mcp/build-status.mjs";
|
|
7
|
-
import { readPreviewManifest } from "./preview-resources.mjs";
|
|
7
|
+
import { cachedPreviewPublication, previewArtifactHtml, readPreviewManifest } from "./preview-resources.mjs";
|
|
8
|
+
import { previewArtifactPath, withPreviewSession } from "./preview-scope.mjs";
|
|
8
9
|
|
|
9
10
|
const digest = value => createHash("sha256").update(value).digest("hex");
|
|
10
11
|
const fileDigest = file => { try { return digest(readFileSync(file)); } catch { return null; } };
|
|
@@ -13,7 +14,7 @@ const fileDigest = file => { try { return digest(readFileSync(file)); } catch {
|
|
|
13
14
|
* Cache state is development output, never proof that a failed build is current. */
|
|
14
15
|
export function createPreviewCache(options, compilerFiles = []) {
|
|
15
16
|
const { root, config } = options;
|
|
16
|
-
const adapterFiles = [import.meta.filename, path.join(import.meta.dirname, "preview-resources.mjs"), path.join(import.meta.dirname, "scenarios-vite.mjs"), ...compilerFiles,
|
|
17
|
+
const adapterFiles = [import.meta.filename, path.join(import.meta.dirname, "preview-resources.mjs"), path.join(import.meta.dirname, "preview-scope.mjs"), path.join(import.meta.dirname, "scenarios-vite.mjs"), ...compilerFiles,
|
|
17
18
|
...["package.json", "package-lock.json", "bun.lock", "bun.lockb", "node_modules/.package-lock.json"].map(file => path.join(root, file))];
|
|
18
19
|
const adapterDigest = () => digest(JSON.stringify(adapterFiles.map(file => [file, fileDigest(file)])));
|
|
19
20
|
const loadedAdapter = adapterDigest();
|
|
@@ -62,14 +63,17 @@ export function createPreviewCache(options, compilerFiles = []) {
|
|
|
62
63
|
["scheduler", fileDigest(import.meta.filename)],
|
|
63
64
|
["node-runtime", `${process.version}/${process.platform}/${process.arch}`],
|
|
64
65
|
]));
|
|
65
|
-
function reusable(entry) {
|
|
66
|
+
function reusable(entry, expectedHtml) {
|
|
66
67
|
const cached = value.entries[entry.id];
|
|
67
68
|
if (!cached || cached.metadata !== JSON.stringify(entry) || cached.compiler !== compilerDigest()) return false;
|
|
68
69
|
if (!cached.dependencies || !Object.keys(cached.dependencies).length) return false;
|
|
69
70
|
if (Object.entries(cached.dependencies).some(([file, hash]) => fileDigest(path.resolve(root, file)) !== hash)) return false;
|
|
70
|
-
|
|
71
|
+
if (expectedHtml !== undefined && cached.artifact !== `sha256:${digest(expectedHtml)}`) return false;
|
|
72
|
+
const file = path.join(root, previewArtifactPath(options, entry.artifact));
|
|
73
|
+
if (expectedHtml !== undefined && !existsSync(file)) return true;
|
|
74
|
+
try { return cached.artifact === artifactDigest(file); } catch { return false; }
|
|
71
75
|
}
|
|
72
|
-
return { inputs, reusable, validated: stamp => value.validation === stamp,
|
|
76
|
+
return { inputs, reusable, graphRevision: () => value.graphRevision, recordGraph(revision) { value.graphRevision = revision; save(); }, validated: stamp => value.validation === stamp,
|
|
73
77
|
assertCompiler() {
|
|
74
78
|
if (adapterDigest() !== loadedAdapter) throw Object.assign(new Error("Preview compiler code or dependencies changed. Invoke the preview again to restart the builder and load current code."), { code: "ADS_PREVIEW_RESTART" });
|
|
75
79
|
},
|
|
@@ -78,7 +82,7 @@ export function createPreviewCache(options, compilerFiles = []) {
|
|
|
78
82
|
value.entries[entry.id] = { metadata: JSON.stringify(entry), compiler: compilerDigest(),
|
|
79
83
|
dependencies: Object.fromEntries([...new Set([path.join(root, entry.file), ...dependencies])]
|
|
80
84
|
.filter(file => isInside(root, file) && !file.includes(`${path.sep}node_modules${path.sep}`)).map(file => [path.relative(root, file), fileDigest(file)])),
|
|
81
|
-
artifact: artifactDigest(path.join(root, entry.artifact)) };
|
|
85
|
+
artifact: artifactDigest(path.join(root, previewArtifactPath(options, entry.artifact))) };
|
|
82
86
|
save();
|
|
83
87
|
},
|
|
84
88
|
};
|
|
@@ -87,11 +91,11 @@ export function createPreviewCache(options, compilerFiles = []) {
|
|
|
87
91
|
/** Shared scheduling and cache; the project supplies its own React/Preact build. */
|
|
88
92
|
export async function runScenarioCompiler(directory, { watched = false, validate, build, buildGraph, compilerFiles = [] }) {
|
|
89
93
|
const root = path.resolve(directory);
|
|
90
|
-
let options = loadConfig(root);
|
|
94
|
+
let options = withPreviewSession(loadConfig(root), process.env.ADS_MCP_BUILD_SESSION);
|
|
91
95
|
if (buildGraph && (!options.config.mcp.previewManifest || !options.config.framework)) throw new Error("Resource preview builds require an explicit framework and mcp.previewManifest in ds.config.json.");
|
|
92
96
|
const cache = createPreviewCache(options, compilerFiles);
|
|
93
97
|
function registry() {
|
|
94
|
-
options = loadConfig(root);
|
|
98
|
+
options = withPreviewSession(loadConfig(root), process.env.ADS_MCP_BUILD_SESSION);
|
|
95
99
|
const result = inspectScenarios(options);
|
|
96
100
|
if (result.diagnostics.length) throw Object.assign(new Error(result.diagnostics.map(item => `${item.file}: ${item.message}`).join("\n")), { scenarios: result.scenarios });
|
|
97
101
|
mkdirSync(path.join(root, ".ds/scenarios"), { recursive: true });
|
|
@@ -104,7 +108,8 @@ export async function runScenarioCompiler(directory, { watched = false, validate
|
|
|
104
108
|
if (!watched) throw error;
|
|
105
109
|
registryFailure = error; entries = error.scenarios ?? [];
|
|
106
110
|
}
|
|
107
|
-
const
|
|
111
|
+
const buildApps = () => entries.map(entry => ({ id: entry.id, file: previewArtifactPath(options, entry.artifact) }));
|
|
112
|
+
const reporter = createBuildReporter(root, { apps: buildApps() });
|
|
108
113
|
const requested = new Set();
|
|
109
114
|
const pending = new Set();
|
|
110
115
|
let active;
|
|
@@ -202,8 +207,12 @@ export async function runScenarioCompiler(directory, { watched = false, validate
|
|
|
202
207
|
}
|
|
203
208
|
} };
|
|
204
209
|
cache.assertCompiler();
|
|
205
|
-
|
|
206
|
-
|
|
210
|
+
const cached = cachedPreviewPublication(options, entries, cache.graphRevision());
|
|
211
|
+
let reusable = cached && entries.every(entry => cache.reusable(entry, previewArtifactHtml(entry.id, cached.revision)));
|
|
212
|
+
if (reusable) {
|
|
213
|
+
try { readPreviewManifest(options, { verify: true }); }
|
|
214
|
+
catch { reusable = false; }
|
|
215
|
+
}
|
|
207
216
|
const outcomes = await checkedBuild(stamp, () => reusable ? null : buildGraph(entries, options, track));
|
|
208
217
|
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
209
218
|
if (failure) throw failure.reason;
|
|
@@ -212,12 +221,10 @@ export async function runScenarioCompiler(directory, { watched = false, validate
|
|
|
212
221
|
const publication = outcomes[1].value;
|
|
213
222
|
if (publication) {
|
|
214
223
|
publication.publish(reporter.session);
|
|
215
|
-
for (const entry of entries) cache.record(entry,
|
|
224
|
+
for (const entry of entries) cache.record(entry, dependencies);
|
|
225
|
+
cache.recordGraph(publication.revision);
|
|
216
226
|
} else {
|
|
217
|
-
|
|
218
|
-
const manifest = readPreviewManifest(options);
|
|
219
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ ...manifest, session: reporter.session }));
|
|
220
|
-
renameSync(`${file}.tmp`, file);
|
|
227
|
+
cached.publish(reporter.session);
|
|
221
228
|
}
|
|
222
229
|
for (const entry of entries) { pending.delete(entry.id); reporter.ready(entry.id, revisions.get(entry.id)); }
|
|
223
230
|
publishedStamp = stamp;
|
|
@@ -281,7 +288,7 @@ export async function runScenarioCompiler(directory, { watched = false, validate
|
|
|
281
288
|
try {
|
|
282
289
|
entries = registry();
|
|
283
290
|
registryFailure = undefined;
|
|
284
|
-
reporter.setApps(
|
|
291
|
+
reporter.setApps(buildApps());
|
|
285
292
|
reporter.clearFailure();
|
|
286
293
|
request(false);
|
|
287
294
|
for (const id of requested) if (!entries.some(entry => entry.id === id)) { requested.delete(id); pending.delete(id); }
|
package/dist/scenarios.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { projectCompilerOptions } from "./boundaries.mjs";
|
|
|
8
8
|
import { inspectTokens } from "./tokens.mjs";
|
|
9
9
|
import { isSharedToken } from "./token-policy.mjs";
|
|
10
10
|
import { validTokenSamples } from "./token-samples.mjs";
|
|
11
|
+
import { scopedTokens } from "./scoped-tokens.mjs";
|
|
11
12
|
|
|
12
13
|
export const scenarioArtifact = (root, id) => path.join(root, ".ds/scenarios", id, "index.html");
|
|
13
14
|
|
|
@@ -39,14 +40,16 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
39
40
|
const known = new Map(publicUi(program, options).map((item) => [item.name, item]));
|
|
40
41
|
const checker = program.getTypeChecker();
|
|
41
42
|
const behaviorApis = new Map(publicApi(program, options).filter(item => !item.renderable && item.behavioral).map(item => [item.name, item]));
|
|
42
|
-
const
|
|
43
|
+
const tokenInventory = inspectTokens(root);
|
|
44
|
+
const tokenNames = new Set(tokenInventory.tokens.map(token => token.name));
|
|
45
|
+
const scoped = scopedTokens(options);
|
|
43
46
|
const scenarios = [];
|
|
44
47
|
const diagnostics = [];
|
|
45
48
|
const ids = new Set();
|
|
46
49
|
function report(file, message) { diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "scenario-contract", line: 1, column: 1, message }); }
|
|
47
50
|
for (const file of files) {
|
|
48
51
|
const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
|
|
49
|
-
|
|
52
|
+
let kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
|
|
50
53
|
if (!["tokens", "components", "patterns", "composition"].includes(kind)) { report(file, "Scenarios live beside tokens/components/patterns or under composition/, not inside showcase pages."); continue; }
|
|
51
54
|
const id = relative.replace(/(?:\/Scenario|\.scenario)\.[^.]+$/, "");
|
|
52
55
|
if (id.length > 256) { report(file, "Scenario paths must fit within 256 characters."); continue; }
|
|
@@ -62,6 +65,11 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
62
65
|
for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === "scenario") metadata = literal(declaration.initializer, checker);
|
|
63
66
|
}
|
|
64
67
|
}
|
|
68
|
+
if (metadata?.kind !== undefined) {
|
|
69
|
+
const owner = scoped.inventory.owner(relative);
|
|
70
|
+
if (metadata.kind !== "tokens" || !scoped.definitions.some(entry => entry.unit === owner)) { report(file, "Only an owner with registered local tokens may set kind: tokens for a colocated token preview."); continue; }
|
|
71
|
+
kind = "tokens";
|
|
72
|
+
}
|
|
65
73
|
if (!entry) report(file, "Export a default independently mountable scenario entrypoint.");
|
|
66
74
|
const module = checker.getSymbolAtLocation(source);
|
|
67
75
|
const defaultExport = module && checker.getExportsOfModule(module).find(item => item.name === "default");
|
|
@@ -77,7 +85,7 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
77
85
|
}
|
|
78
86
|
const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
|
|
79
87
|
if (![".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].some(extension => existsSync(fixtureBase + extension))) report(file, "Keep this scenario's inputs beside it: fixture.ts (directory entry) or <Owner>.fixture.ts (flat entry). An empty fixture is valid for a component without inputs.");
|
|
80
|
-
if (!metadata || Object.keys(metadata).some((key) => !["title", "covers", "exercises", "tokens", "samples"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
|
|
88
|
+
if (!metadata || Object.keys(metadata).some((key) => !["title", "kind", "covers", "exercises", "tokens", "samples"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
|
|
81
89
|
|| Object.hasOwn(metadata, "exercises") && (!Array.isArray(metadata.exercises) || metadata.exercises.some(name => typeof name !== "string" || !behaviorApis.has(name)))
|
|
82
90
|
|| Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--")))
|
|
83
91
|
|| kind === "tokens" && (Object.hasOwn(metadata, "tokens") || !validTokenSamples(metadata.samples) || !metadata.covers.some(name => known.get(name)?.kind === "patterns"))
|
|
@@ -86,7 +94,11 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
86
94
|
}
|
|
87
95
|
if (!["composition", "tokens"].includes(kind) && metadata.covers.some((name) => known.get(name).kind !== kind)) report(file, "Component scenarios cover primitives; pattern scenarios cover patterns. Use composition/ for larger interfaces.");
|
|
88
96
|
const tokens = [...new Set([...(metadata.tokens ?? []), ...(metadata.samples ?? []).map(sample => sample.token)])];
|
|
89
|
-
for (const name of tokens) if (!tokenNames.has(name)) diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "token-scenario-reference", line: 1, column: 1, message: `Scenario references undeclared token ${name}. Use a real
|
|
97
|
+
for (const name of tokens) if (!tokenNames.has(name)) diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "token-scenario-reference", line: 1, column: 1, message: `Scenario references undeclared token ${name}. Use a real token from its defining owner.` });
|
|
98
|
+
if (kind === "tokens") {
|
|
99
|
+
const owner = scoped.inventory.owner(relative);
|
|
100
|
+
for (const name of tokens) if (tokenInventory.tokens.find(token => token.name === name)?.owner !== owner) report(file, `Token ${name} needs a preview beside its defining owner. Shared tokens belong under the configured token directory; local tokens stay with their component/pattern.`);
|
|
101
|
+
}
|
|
90
102
|
scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], ...(metadata.exercises ? { exercises: [...new Set(metadata.exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
91
103
|
}
|
|
92
104
|
return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
|
|
@@ -209,3 +221,33 @@ export function writeScenarioRegistry(options, write) {
|
|
|
209
221
|
write({ schemaVersion: 1, scenarios: registry.scenarios });
|
|
210
222
|
return registry.scenarios;
|
|
211
223
|
}
|
|
224
|
+
|
|
225
|
+
/** Locate the actual consumed array, rather than regenerating a whole fixture. */
|
|
226
|
+
export function tokenSampleSource(options, scenario) {
|
|
227
|
+
const file = path.join(options.root, scenario.file);
|
|
228
|
+
const program = ts.createProgram([file], { ...projectCompilerOptions(options), allowJs:true, resolveJsonModule:true });
|
|
229
|
+
const checker = program.getTypeChecker(), source = program.getSourceFile(file);
|
|
230
|
+
function resolve(node, seen = new Set()) {
|
|
231
|
+
while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression;
|
|
232
|
+
if (!node || seen.has(node)) return null;
|
|
233
|
+
if (ts.isArrayLiteralExpression(node) || ts.isObjectLiteralExpression(node)) return node;
|
|
234
|
+
let symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
|
|
235
|
+
if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
|
|
236
|
+
const declaration = symbol?.valueDeclaration;
|
|
237
|
+
return declaration?.initializer ? resolve(declaration.initializer, new Set([...seen,node])) : null;
|
|
238
|
+
}
|
|
239
|
+
for (const statement of source.statements) if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) {
|
|
240
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "scenario") continue;
|
|
241
|
+
const metadata = resolve(declaration.initializer);
|
|
242
|
+
const property = metadata && ts.isObjectLiteralExpression(metadata) && metadata.properties.find(node => ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && node.name.text === "samples");
|
|
243
|
+
const array = property && resolve(property.initializer);
|
|
244
|
+
if (array && ts.isArrayLiteralExpression(array)) {
|
|
245
|
+
const source = array.getSourceFile();
|
|
246
|
+
const scoped = scopedTokens(options), owner = scoped.inventory.owner(scoped.inventory.relative(scenario.file));
|
|
247
|
+
const relative = path.relative(options.root, source.fileName).replaceAll("\\", "/");
|
|
248
|
+
if (!isDevelopmentFile(source.fileName, options) || scoped.inventory.owner(scoped.inventory.relative(relative)) !== owner) throw new Error("The consumed samples array must live in this scenario's own development fixture.");
|
|
249
|
+
return {file:relative, content:source.text, start:array.getStart(source), end:array.end};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw new Error("Cannot locate a literal consumed samples array. Keep fixture.samples statically inspectable; edit other fixture shapes with a normal owner patch.");
|
|
253
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import postcss from "postcss";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { inspectUnits } from "./units.mjs";
|
|
6
|
+
import { isDevelopmentFile } from "./config.mjs";
|
|
7
|
+
import { foundationSelector } from "./foundation.mjs";
|
|
8
|
+
import { isSharedToken, tokenContract } from "./token-policy.mjs";
|
|
9
|
+
|
|
10
|
+
/** An explicit owner contract, never a heuristic based on a token's name. */
|
|
11
|
+
export function scopedTokens(options) {
|
|
12
|
+
if (options.scopedTokens) return options.scopedTokens;
|
|
13
|
+
const inventory = inspectUnits(options), definitions = [], diagnostics = [];
|
|
14
|
+
const report = (file, message, line = 1) => diagnostics.push({rule:"scoped-token", file, message, line, column:1});
|
|
15
|
+
for (const unit of inventory.units.filter(unit => ["components", "patterns"].includes(unit.kind))) {
|
|
16
|
+
for (const manifest of unit.files.filter(file => /(?:^|\/)tokens\.json$|\.tokens\.json$/.test(file))) {
|
|
17
|
+
const absolute = path.join(options.root, manifest);
|
|
18
|
+
try {
|
|
19
|
+
if (statSync(absolute).size > 65536 || realpathSync(absolute) !== absolute) throw new Error("Token manifests must be ordinary files no larger than 64 KiB.");
|
|
20
|
+
const data = JSON.parse(readFileSync(absolute, "utf8"));
|
|
21
|
+
const stylesheet = manifest.replace(/\.json$/, ".css");
|
|
22
|
+
if (!data || data.schemaVersion !== 1 || Object.keys(data).some(key => !["schemaVersion", "namespace"].includes(key))
|
|
23
|
+
|| typeof data.namespace !== "string" || !/^--[a-z][a-z0-9-]*-$/.test(data.namespace)
|
|
24
|
+
|| /^(?:--ds-|--ux-|--showcase-)/.test(data.namespace) || isSharedToken(data.namespace + "value")
|
|
25
|
+
|| tokenContract.naming.prefixes.some(prefix => prefix.startsWith(data.namespace))
|
|
26
|
+
|| [...tokenContract.naming.names,...Object.keys(tokenContract.tokens)].some(name => name.startsWith(data.namespace))) throw new Error("Use {schemaVersion:1, namespace:\"--<owner>-\"}; shared and runtime-variable prefixes are reserved.");
|
|
27
|
+
if (definitions.some(entry => entry.unit === unit.id || entry.namespace.startsWith(data.namespace) || data.namespace.startsWith(entry.namespace))) throw new Error("Each unit has one token namespace; namespaces must not overlap other owners.");
|
|
28
|
+
if (!unit.files.includes(stylesheet) || !existsSync(path.join(options.root, stylesheet))) throw new Error(`Colocate the definition-only stylesheet ${stylesheet}.`);
|
|
29
|
+
const file = path.join(options.root, stylesheet);
|
|
30
|
+
if (realpathSync(file) !== file) throw new Error("Scoped token stylesheets cannot resolve through symlinks.");
|
|
31
|
+
const css = postcss.parse(readFileSync(file, "utf8"), {from:file});
|
|
32
|
+
let count = 0;
|
|
33
|
+
css.walk(node => {
|
|
34
|
+
if (node.type === "comment" || node.type === "root") return;
|
|
35
|
+
if (node.type === "atrule" && ["media", "supports", "layer"].includes(node.name) && node.nodes) return;
|
|
36
|
+
if (node.type === "rule" && node.parent.type !== "rule" && !foundationSelector(node.selector, {definitions:true})) return;
|
|
37
|
+
if (node.type === "decl" && node.parent.type === "rule" && node.prop.startsWith(data.namespace) && node.prop.length > data.namespace.length && !node.important) { count++; return; }
|
|
38
|
+
report(stylesheet, "Owner token sheets contain only namespaced custom-property definitions under root/theme conditions. UI selectors, imports, font faces, animations and ordinary declarations belong in their runtime owners.", node.source?.start.line);
|
|
39
|
+
});
|
|
40
|
+
if (!count) report(stylesheet, "Define at least one real namespaced token.");
|
|
41
|
+
const imported = unit.files.filter(file => /\.[cm]?[jt]sx?$/.test(file) && !isDevelopmentFile(path.join(options.root, file), options)).some(source => {
|
|
42
|
+
const ast = ts.createSourceFile(source, readFileSync(path.join(options.root, source), "utf8"), ts.ScriptTarget.Latest, true);
|
|
43
|
+
return ast.statements.some(node => ts.isImportDeclaration(node) && !node.importClause && ts.isStringLiteralLike(node.moduleSpecifier) && path.resolve(options.root, path.dirname(source), node.moduleSpecifier.text) === file);
|
|
44
|
+
});
|
|
45
|
+
if (!imported) report(manifest, `Import ./${path.basename(stylesheet)} from this owner's runtime source so its real preview and product consume the same definitions.`);
|
|
46
|
+
definitions.push({unit:unit.id, namespace:data.namespace, manifest, file:stylesheet});
|
|
47
|
+
} catch (error) { report(manifest, error.reason ?? error.message, error.line); }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const result = {definitions, diagnostics, inventory};
|
|
51
|
+
options.scopedTokens = result;
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function scopedTokenOwner(options, name) {
|
|
56
|
+
return scopedTokens(options).definitions.find(entry => name.startsWith(entry.namespace));
|
|
57
|
+
}
|
|
@@ -36,8 +36,8 @@ and `boundaries.styleWrites` entries together with its source. Unused permission
|
|
|
36
36
|
fail the core checks; do not pre-register a boundary without its implementation.
|
|
37
37
|
Use `dryRun: true` to
|
|
38
38
|
inspect validation; applying re-runs the gates. Fix rejected patches in their
|
|
39
|
-
owner.
|
|
40
|
-
direct source writes. Shared styles/helpers use `foundation`, root guides use
|
|
39
|
+
owner. Use `ads_apply_transaction` for genuinely coupled owner patches;
|
|
40
|
+
never bypass a rejection with direct source writes. Shared styles/helpers use `foundation`, root guides use
|
|
41
41
|
`documentation`, and branding/fonts use their discovered `assets/*` unit.
|
|
42
42
|
Intentional configuration, rule exceptions, registered guard code and compiler
|
|
43
43
|
changes use `configuration`; validate the proposed settings and keep mandatory
|
|
@@ -45,6 +45,8 @@ ADS gates. For binary/large assets, generate `git diff --binary --full-index` in
|
|
|
45
45
|
`.ds/patches/<name>.patch` and send `patchFile` instead of inline `patch`, so bytes
|
|
46
46
|
stay out of model context. Binary changes require Git. When editing is disabled,
|
|
47
47
|
use the client's normal source-editing tools.
|
|
48
|
+
For owner drafts, token-sample reconciliation, coupled transactions and local
|
|
49
|
+
constant namespaces, read [guarded-editing.md](references/guarded-editing.md).
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
Controllers are UI behavior owners: private helpers stay with their component or
|