@reqvire-org/ads 0.3.12 → 0.3.14
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 +20 -1
- package/dist/bootstrap-ds/ADS.md +4 -1
- package/dist/capture/worker.mjs +38 -0
- package/dist/capture-store.mjs +123 -0
- package/dist/capture.mjs +117 -0
- package/dist/check.mjs +8 -2
- package/dist/config.mjs +6 -1
- package/dist/mcp/context.mjs +1 -1
- package/dist/mcp/mutations.mjs +25 -5
- package/dist/mcp/server.mjs +62 -15
- package/dist/scenario-slots.mjs +124 -0
- package/dist/scenarios.mjs +9 -3
- package/dist/showcase/client.bundle.js +18 -18
- package/dist/showcase/style.css +2 -0
- package/dist/skills/ads-break-ui/SKILL.md +6 -1
- package/dist/skills/ads-break-ui/references/captures.md +147 -0
- package/dist/skills/ads-break-ui/references/guarded-editing.md +72 -3
- package/dist/skills/ads-break-ui/references/scenarios.md +40 -1
- package/dist/skills/ads-designer/SKILL.md +6 -1
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +4 -1
- package/dist/skills/ads-designer/references/captures.md +147 -0
- package/dist/skills/ads-designer/references/guarded-editing.md +72 -3
- package/dist/skills/ads-designer/references/scenarios.md +40 -1
- package/dist/skills/ads-import/SKILL.md +6 -1
- package/dist/skills/ads-import/references/captures.md +147 -0
- package/dist/skills/ads-import/references/guarded-editing.md +72 -3
- package/dist/skills/ads-import/references/scenarios.md +40 -1
- package/dist/skills/ads-migrate/SKILL.md +6 -1
- package/dist/skills/ads-migrate/references/captures.md +147 -0
- package/dist/skills/ads-migrate/references/guarded-editing.md +72 -3
- package/dist/skills/ads-migrate/references/scenarios.md +40 -1
- package/dist/skills/ads-motion/SKILL.md +6 -1
- package/dist/skills/ads-motion/references/captures.md +147 -0
- package/dist/skills/ads-motion/references/guarded-editing.md +72 -3
- package/dist/skills/ads-motion/references/scenarios.md +40 -1
- package/dist/source-values.mjs +2 -1
- package/dist/verification/browser.mjs +8 -3
- package/dist/verification/playwright-reporter.cjs +23 -8
- package/dist/verification.mjs +142 -33
- package/docs/ADS.md +10 -2
- package/docs/ARCHITECTURE.md +43 -2
- package/docs/BEHAVIORS.md +66 -7
- package/docs/CHANGELOG.md +27 -0
- package/docs/GUIDE.md +48 -1
- package/docs/MCP.md +54 -1
- package/package.json +1 -1
package/dist/mcp/server.mjs
CHANGED
|
@@ -21,12 +21,21 @@ import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow }
|
|
|
21
21
|
import { createMutationController, mutationLimits } from "./mutations.mjs";
|
|
22
22
|
import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
|
|
23
23
|
import { reconcileTokenSamples } from "../token-fixtures.mjs";
|
|
24
|
+
import { createCaptureController } from "../capture.mjs";
|
|
25
|
+
import { CAPTURES_URI, captureSpec, screenshotFields } from "../capture-store.mjs";
|
|
26
|
+
import { previewSourceRevision } from "../preview-inputs.mjs";
|
|
24
27
|
|
|
25
28
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
26
29
|
class InvalidParamsError extends ProtocolError {
|
|
27
30
|
constructor(message) { super(INVALID_PARAMS, message); }
|
|
28
31
|
}
|
|
29
32
|
const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
|
|
33
|
+
function captureResponse(value, resourceLinks = false) {
|
|
34
|
+
const result = json(value);
|
|
35
|
+
if (!resourceLinks) return result; // Older protocol clients retain text/structured URI discovery.
|
|
36
|
+
for (const capture of value.captures ?? value.screenshots?.captures ?? []) for (const slot of ["current", "candidate"]) if (capture[slot]) result.content.push({ type: "resource_link", uri: capture[slot].resourceUri, mimeType: "image/png", name: `${slot === "current" ? "Current" : "Candidate"}: ${capture.scenario}${capture.target ? ` / ${capture.target}` : ""}` });
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
30
39
|
const empty = z.object({}).strict();
|
|
31
40
|
const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
|
|
32
41
|
const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
@@ -37,7 +46,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
37
46
|
const root = path.resolve(directory);
|
|
38
47
|
const cache = inspection ?? {};
|
|
39
48
|
const skills = cache.skills ??= createSkillStore(skillDirectory);
|
|
40
|
-
const
|
|
49
|
+
const captures = cache.captures ??= createCaptureController(root);
|
|
50
|
+
const edits = cache.edits ??= createMutationController(root, { captures });
|
|
41
51
|
const verification = cache.verification ??= createVerificationController(root);
|
|
42
52
|
let options = loadConfig(root);
|
|
43
53
|
const signature = `${root}:${JSON.stringify(options.config)}`;
|
|
@@ -62,6 +72,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
62
72
|
extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [APP_MIME] }, ...(era === "modern" ? { [SKILLS_EXTENSION]: { directoryRead: true } } : {}) } },
|
|
63
73
|
});
|
|
64
74
|
const getRegistry = () => cache.registry ??= inspectScenarios(options);
|
|
75
|
+
function captureInventory() {
|
|
76
|
+
const current = loadConfig(root), registry = inspectScenarios(current);
|
|
77
|
+
return { ...registry, revision: previewSourceRevision(current) };
|
|
78
|
+
}
|
|
79
|
+
function captureIndex() { const inventory = captureInventory(); return captures.store.list(inventory.scenarios, inventory.revision, { prune: !inventory.diagnostics.length }); }
|
|
65
80
|
const scenarioApps = () => getRegistry().scenarios.map(item => ({ id: item.id, title: item.title, kind: item.kind, ...(item.samples ? { samples: item.samples } : {}), file: item.artifact }));
|
|
66
81
|
const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
|
|
67
82
|
const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
|
|
@@ -95,12 +110,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
95
110
|
{ uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
|
|
96
111
|
{ uri: "ads://guides/atomic-styles", name: "Atomic styles: composition conflicts and custom-property extraction", mimeType: "text/markdown" },
|
|
97
112
|
{ uri: "ads://guides/scenarios", name: "Scenario coverage and public class-helper contracts", mimeType: "text/markdown" },
|
|
113
|
+
{ uri: "ads://guides/captures", name: "On-demand captures and candidate visual review", mimeType: "text/markdown" },
|
|
98
114
|
{ uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
|
|
99
115
|
{ uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
|
|
100
116
|
{ uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
|
|
101
117
|
{ uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
|
|
102
118
|
{ uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
|
|
103
119
|
{ uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
|
|
120
|
+
{ uri: CAPTURES_URI, name: "On-demand current and candidate screenshots", mimeType: "application/json" },
|
|
104
121
|
{ uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
|
|
105
122
|
...documents.filter(document => promoted.has(document.uri)).sort((a, b) => Number(b.uri === context.publicEntryUri) - Number(a.uri === context.publicEntryUri)).map(({ file: _file, ...document }) => document),
|
|
106
123
|
{ uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
|
|
@@ -126,6 +143,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
126
143
|
previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
|
|
127
144
|
scenarios: { count: getRegistry().scenarios.length, diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios`, tool: "ads_show_scenario" },
|
|
128
145
|
behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", ...verification.status() },
|
|
146
|
+
captures: { resourceUri: CAPTURES_URI, tool: "ads_capture", clearTool: "ads_capture_clear", ...captures.status() },
|
|
129
147
|
experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
|
|
130
148
|
}
|
|
131
149
|
async function read(uri, signal) {
|
|
@@ -133,6 +151,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
133
151
|
if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
|
|
134
152
|
if (uri === "ads://guides/atomic-styles") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/atomic-styles.md", import.meta.url), "utf8") }] };
|
|
135
153
|
if (uri === "ads://guides/scenarios") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/scenarios.md", import.meta.url), "utf8") }] };
|
|
154
|
+
if (uri === "ads://guides/captures") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/captures.md", import.meta.url), "utf8") }] };
|
|
136
155
|
if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
|
|
137
156
|
let value;
|
|
138
157
|
if (uri === `${PROJECT_URI}/status`) value = status();
|
|
@@ -142,11 +161,16 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
142
161
|
else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
|
|
143
162
|
else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
|
|
144
163
|
else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
|
|
164
|
+
else if (uri === CAPTURES_URI) value = captureIndex();
|
|
145
165
|
else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
|
|
146
166
|
value = readPreviewManifest(previewOptions());
|
|
147
167
|
if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
|
|
148
168
|
}
|
|
149
169
|
if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
|
|
170
|
+
if (uri.startsWith("ads://captures/")) {
|
|
171
|
+
try { const inventory = captureInventory(); return { contents: [captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length })] }; }
|
|
172
|
+
catch (error) { if (error.code === "ADS_CAPTURE_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
|
|
173
|
+
}
|
|
150
174
|
if (uri === TOKENS_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: tokensApp(tokenWidgetData(inspectTokens(root))), _meta: appMetadata({ nested: true }) }] };
|
|
151
175
|
if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
|
|
152
176
|
if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
|
|
@@ -174,8 +198,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
174
198
|
return build;
|
|
175
199
|
}
|
|
176
200
|
|
|
177
|
-
async function applyEdits(args,signal) {
|
|
178
|
-
const result = await edits.apply(args,signal);
|
|
201
|
+
async function applyEdits(args,signal,progress) {
|
|
202
|
+
const result = await edits.apply(args,signal,progress);
|
|
203
|
+
if (result.screenshots) await refresh();
|
|
179
204
|
if (result.applied) {
|
|
180
205
|
try {
|
|
181
206
|
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);
|
|
@@ -183,11 +208,15 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
183
208
|
await refresh(true);
|
|
184
209
|
} 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.`}];}
|
|
185
210
|
}
|
|
186
|
-
return {...
|
|
211
|
+
return {...captureResponse(result, era === "modern"),...(!result.ok ? {isError:true} : {})};
|
|
187
212
|
}
|
|
188
213
|
function tools() {
|
|
189
214
|
return [
|
|
190
215
|
{ 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()) },
|
|
216
|
+
{ name: "ads_capture", title: "Capture a design-system scenario", description: "Capture one discovered scenario or its named captureTargets element inside the real MCP App iframe, excluding showcase navigation. Uses project-local Playwright/browser and an isolated host; installs nothing. Returns PNG resource URIs and source/build/settings metadata, never image bytes in text. Successful standalone capture replaces current and clears a prior candidate for that target. No screenshots are created until requested. Inspect ads://project/captures for stale/current evidence. Appearance is not behavior verification.", schema: captureSpec, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, call: async (args, signal, progress) => {
|
|
217
|
+
const result = await captures.run({ captures: [args] }, signal, progress); await refresh(); return captureResponse(result, era === "modern");
|
|
218
|
+
} },
|
|
219
|
+
{ name: "ads_capture_clear", description: "Delete generated screenshot slots for one scenario/target. candidateOnly discards a proposal while retaining current. Source is unchanged. No TTL/history is kept; each target has at most current and candidate.", schema: z.object({ scenario: z.string().min(1).max(256), target: z.string().regex(/^[a-zA-Z0-9][\w.-]{0,79}$/).optional(), candidateOnly: z.boolean().default(false) }).strict(), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, call: async args => { const result = captures.store.clear(args); await refresh(); return json(result); } },
|
|
191
220
|
{ 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 }) => {
|
|
192
221
|
const matches = getCatalog().filter((item) => (!kind || item.kind === kind) && item.name.toLowerCase().includes(search.toLowerCase()));
|
|
193
222
|
const units = edits.units();
|
|
@@ -255,8 +284,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
255
284
|
demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
|
|
256
285
|
}), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
|
|
257
286
|
} },
|
|
258
|
-
{ 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) => {
|
|
259
|
-
const result = await verification.run(args, signal);
|
|
287
|
+
{ 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. Progress and per-check timings are available through ads_status; a profile timeout bounds the whole run. 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, progress) => {
|
|
288
|
+
const result = await verification.run(args, signal, progress);
|
|
260
289
|
await refresh();
|
|
261
290
|
return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
|
|
262
291
|
} },
|
|
@@ -270,11 +299,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
270
299
|
const {patch,...result}=reconcileTokenSamples(options,args);
|
|
271
300
|
return json({...result,removed:result.removed.slice(0,50),unresolved:result.unresolved.slice(0,50),unresolvedCount:result.unresolved.length,...storePreparedPatch(options,patch)});
|
|
272
301
|
}},
|
|
273
|
-
{ 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.",
|
|
274
|
-
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."),
|
|
302
|
+
{ 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. Optional screenshot:true plus captures:[{scenario,target?,theme?,viewport?}] captures current/candidate only after gates pass. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance.",
|
|
303
|
+
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(), ...screenshotFields }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile."),
|
|
275
304
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
|
|
276
305
|
call: applyEdits,
|
|
277
|
-
}, {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}] : []),
|
|
306
|
+
}, {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. Optional screenshot:true with dryRun:true and explicit captures selects current/candidate images. Prefer ads_apply_patch for independent edits.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean(),...screenshotFields}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
|
|
278
307
|
...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) => {
|
|
279
308
|
// Validate readiness without embedding executable HTML in model results.
|
|
280
309
|
const build = await previewBuild(app, signal);
|
|
@@ -295,7 +324,12 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
295
324
|
const input = tool.schema.safeParse(args ?? {});
|
|
296
325
|
if (!input.success) throw new InvalidParamsError(input.error.message);
|
|
297
326
|
previewWatch?.start();
|
|
298
|
-
|
|
327
|
+
let progress = 0;
|
|
328
|
+
const token = context.mcpReq._meta?.progressToken;
|
|
329
|
+
const notify = token === undefined ? undefined : value => {
|
|
330
|
+
void context.mcpReq.notify({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, message: value.message ?? `${value.profile}: ${value.completed}/${value.total} completed; ${value.active.map(item => `${item.unit}#${item.check}`).join(", ") || "no active check reported"}; ${value.elapsedMs} ms elapsed` } }).catch(() => {});
|
|
331
|
+
};
|
|
332
|
+
try { return await tool.call(input.data, context.mcpReq.signal, notify); }
|
|
299
333
|
catch (error) { return { isError: true, content: [{ type: "text", text: error.message }], ...(error.build ? { structuredContent: { message: error.message, build: error.build } } : {}) }; }
|
|
300
334
|
});
|
|
301
335
|
server.setRequestHandler("resources/list", ({ params = {} }) => {
|
|
@@ -304,10 +338,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
304
338
|
const listing = page(resources(), cursor, "resources");
|
|
305
339
|
return { resources: listing.items, ...(listing.nextCursor ? { nextCursor: listing.nextCursor } : {}) };
|
|
306
340
|
});
|
|
307
|
-
server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [] }));
|
|
341
|
+
server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [{ uriTemplate: "ads://captures/{key}/{slot}", name: "Current/candidate screenshot", description: "Exact keys and available slots come from ads://project/captures. Slots are replaced in place, not revision history.", mimeType: "image/png" }] }));
|
|
308
342
|
server.setRequestHandler("resources/read", ({ params: { uri } }, context) => read(uri, context.mcpReq.signal));
|
|
309
343
|
server.setRequestHandler("resources/subscribe", ({ params: { uri } }) => {
|
|
310
|
-
if (!resources().some((entry) => entry.uri === uri))
|
|
344
|
+
if (!resources().some((entry) => entry.uri === uri)) {
|
|
345
|
+
if (!uri.startsWith("ads://captures/")) throw new ResourceNotFoundError(uri, "Unknown ADS resource subscription.");
|
|
346
|
+
try { const inventory = captureInventory(); captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length }); } catch { throw new ResourceNotFoundError(uri, "Unknown or absent screenshot slot."); }
|
|
347
|
+
}
|
|
311
348
|
subscribed.add(uri); return {};
|
|
312
349
|
});
|
|
313
350
|
server.setRequestHandler("resources/unsubscribe", ({ params: { uri } }) => { subscribed.delete(uri); return {}; });
|
|
@@ -343,6 +380,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
343
380
|
let previousArtifacts = artifactFingerprint();
|
|
344
381
|
let previousAssets = assetFingerprint();
|
|
345
382
|
let previousBuild = JSON.stringify(builds());
|
|
383
|
+
let previousCaptures = captures.store.fingerprint();
|
|
384
|
+
let previousCaptureUris = captures.store.resourceUris();
|
|
346
385
|
async function refresh(force = false) {
|
|
347
386
|
// Polling covers replaced files and new directories consistently across OSes.
|
|
348
387
|
cache.documents = undefined;
|
|
@@ -354,7 +393,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
354
393
|
const buildChanged = build !== previousBuild;
|
|
355
394
|
const artifactsChanged = artifacts !== previousArtifacts;
|
|
356
395
|
const assetsChanged = assets !== previousAssets;
|
|
357
|
-
|
|
396
|
+
const capturesChanged = captures.store.fingerprint() !== previousCaptures;
|
|
397
|
+
if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged && !capturesChanged) return false;
|
|
358
398
|
if (filesChanged) {
|
|
359
399
|
const updated = loadConfig(root);
|
|
360
400
|
await previewWatch?.reconfigure?.(updated.config.mcp);
|
|
@@ -372,14 +412,20 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
372
412
|
previousBuild = JSON.stringify(builds());
|
|
373
413
|
const targets = filesChanged ? era === "modern" ? resources().map((entry) => entry.uri).filter((uri) => !skills.files.has(uri)) : [...subscribed]
|
|
374
414
|
: [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));
|
|
375
|
-
|
|
415
|
+
if (capturesChanged || filesChanged) {
|
|
416
|
+
const current = (captures.store.fingerprint() ? captureIndex().captures : []).flatMap(record => [record.current?.resourceUri, record.candidate?.resourceUri].filter(Boolean));
|
|
417
|
+
targets.push(...[CAPTURES_URI, ...previousCaptureUris, ...current].filter(uri => era === "modern" || subscribed.has(uri)));
|
|
418
|
+
previousCaptureUris = current;
|
|
419
|
+
previousCaptures = captures.store.fingerprint();
|
|
420
|
+
}
|
|
421
|
+
for (const uri of new Set(targets)) { if (notifications) notifications.resourceUpdated(uri); else await server.sendResourceUpdated({ uri }); }
|
|
376
422
|
return true;
|
|
377
423
|
}
|
|
378
424
|
const timer = pollInterval > 0 ? setInterval(() => { refresh().catch((error) => process.stderr.write(`ads mcp: ${error.message}\n`)); }, pollInterval) : null;
|
|
379
425
|
timer?.unref();
|
|
380
426
|
server.oninitialized = () => previewWatch?.start();
|
|
381
427
|
server.onclose = () => { if (timer) clearInterval(timer); };
|
|
382
|
-
return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) await verification.close(); await server.close(); } };
|
|
428
|
+
return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) { await verification.close(); await captures.close(); } await server.close(); } };
|
|
383
429
|
}
|
|
384
430
|
|
|
385
431
|
export async function startAdsMcp(directory = process.cwd(), { stdio = true, showcase = true, port = 0, mutations = false } = {}) {
|
|
@@ -405,6 +451,7 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
|
|
|
405
451
|
Promise.resolve().then(() => host?.close()),
|
|
406
452
|
previewWatch.stop(),
|
|
407
453
|
Promise.resolve().then(() => inspection.verification?.close()),
|
|
454
|
+
Promise.resolve().then(() => inspection.captures?.close()),
|
|
408
455
|
Promise.resolve().then(() => handle?.close()),
|
|
409
456
|
]);
|
|
410
457
|
for (const event of ["end", "close", "error"]) process.stdin.removeListener(event, disconnect);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isDevelopmentFile } from "./config.mjs";
|
|
3
|
+
import { publicApi } from "./public-api.mjs";
|
|
4
|
+
import { createSourceValues, isFunction, reference, unwrap } from "./source-values.mjs";
|
|
5
|
+
import { uiOperation } from "./ui-framework.mjs";
|
|
6
|
+
|
|
7
|
+
/** A native scenario child may supply semantics to a public runtime slot. The
|
|
8
|
+
* proof follows actual child cloning, never a prop/component/helper spelling. */
|
|
9
|
+
export function createScenarioSlots(program, options) {
|
|
10
|
+
const checker = program.getTypeChecker(), cache = new WeakMap();
|
|
11
|
+
const operation = node => uiOperation(node, checker);
|
|
12
|
+
const values = createSourceValues(program, options, { strict: true, preserveCall: node => ["createElement", "cloneElement", "Children.only"].includes(operation(node)) });
|
|
13
|
+
const { state, next, resolve, get, keys, props, returns, declaration } = values;
|
|
14
|
+
const publicNodes = new Set(publicApi(program, options).filter(item => item.renderable).map(item => resolve(reference(item.declaration.name ?? item.declaration), state())).filter(value => value.kind === "node").map(value => value.node));
|
|
15
|
+
|
|
16
|
+
function element(node, env = new Map(), via = []) {
|
|
17
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
18
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
19
|
+
const attributes = props(opening, env, via);
|
|
20
|
+
const children = ts.isJsxElement(node) ? node.children.filter(child => !ts.isJsxText(child) || child.text.trim()).filter(child => !ts.isJsxExpression(child) || child.expression) : [];
|
|
21
|
+
if (children.length) attributes.layers.push(new Map([["children", children.length === 1 ? reference(children[0], env, via) : { kind: "array", values: children.map(child => reference(child, env, via)) }]]));
|
|
22
|
+
return { tag: reference(opening.tagName, env, via), attributes, intrinsic: ts.isIdentifier(opening.tagName) && /^[a-z]/.test(opening.tagName.text) };
|
|
23
|
+
}
|
|
24
|
+
if (ts.isCallExpression(node) && operation(node.expression) === "createElement") {
|
|
25
|
+
const attributes = { kind: "object", layers: node.arguments[1] && node.arguments[1].kind !== ts.SyntaxKind.NullKeyword ? [reference(node.arguments[1], env, via)] : [] };
|
|
26
|
+
const children = node.arguments.slice(2).map(child => reference(child, env, via));
|
|
27
|
+
if (children.length) attributes.layers.push(new Map([["children", children.length === 1 ? children[0] : { kind: "array", values: children }]]));
|
|
28
|
+
return { tag: reference(node.arguments[0], env, via), attributes, intrinsic: !!node.arguments[0] && ts.isStringLiteralLike(node.arguments[0]) };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return function nativeSlot(site) {
|
|
34
|
+
if (cache.has(site)) return cache.get(site);
|
|
35
|
+
const reasons = new Set(); let cloned = false, exposed = false, uncertain = false, steps = 0;
|
|
36
|
+
const fail = reason => { uncertain = true; reasons.add(reason); };
|
|
37
|
+
function childValue(input, context) {
|
|
38
|
+
const value = resolve(input, next(context));
|
|
39
|
+
if (value.kind === "node" && ts.isJsxExpression(value.node)) return childValue(reference(value.node.expression, value.env, value.via), next(context));
|
|
40
|
+
if (value.kind === "node" && ts.isCallExpression(value.node) && operation(value.node.expression) === "Children.only") return childValue(reference(value.node.arguments[0], value.env, value.via), next(context));
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function ownedProps(input, context, seen = new Set()) {
|
|
44
|
+
if (context.depth > 40) return false;
|
|
45
|
+
const value = resolve(input, next(context));
|
|
46
|
+
const opaque = value.kind === "unknown" || value.kind === "union" && value.values.length <= 32 && value.values.every(branch => resolve(branch, next(context)).kind === "unknown");
|
|
47
|
+
if (value.kind === "union" && !opaque) return value.values.length <= 32 && value.values.every(branch => ownedProps(branch, next(context), new Set(seen)));
|
|
48
|
+
if (keys(value, next(context)).some(key => key !== "children")) return true;
|
|
49
|
+
// Local prop mergers may use loops and a ref assignment, beyond the value
|
|
50
|
+
// evaluator. Retain runtime ownership through their actual input props;
|
|
51
|
+
// a known empty result, external call or arbitrary identifier cannot qualify.
|
|
52
|
+
if (!opaque || input.kind !== "node") return false;
|
|
53
|
+
const node = unwrap(input.node), { env, via } = input;
|
|
54
|
+
if (seen.has(node)) return false;
|
|
55
|
+
seen.add(node);
|
|
56
|
+
if (ts.isIdentifier(node)) {
|
|
57
|
+
if (env.has(checker.getSymbolAtLocation(node))) return ownedProps(env.get(checker.getSymbolAtLocation(node)), next(context), seen);
|
|
58
|
+
const binding = declaration(node);
|
|
59
|
+
return !!(binding && ts.isVariableDeclaration(binding) && binding.parent.flags & ts.NodeFlags.Const && binding.initializer && !isDevelopmentFile(binding.getSourceFile().fileName, options) && ownedProps(reference(binding.initializer, env, via), next(context), seen));
|
|
60
|
+
}
|
|
61
|
+
if (ts.isCallExpression(node)) {
|
|
62
|
+
const target = resolve(reference(node.expression, env, via), next(context));
|
|
63
|
+
return target.kind === "node" && isFunction(target.node) && !isDevelopmentFile(target.node.getSourceFile().fileName, options) && node.arguments.some(argument => ownedProps(reference(argument, env, via), next(context), new Set(seen)));
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
function render(input, context) {
|
|
68
|
+
if (++steps > 500 || context.depth > 48) { fail("Slot tracing reached its source work limit."); return; }
|
|
69
|
+
const value = childValue(input, context);
|
|
70
|
+
context = value.context ?? input.context ?? context;
|
|
71
|
+
if (value.kind === "slot-child") { exposed = true; return; }
|
|
72
|
+
if (["union", "array"].includes(value.kind)) {
|
|
73
|
+
if (value.values.length > 32) { fail("Slot tracing reached its alternative limit."); return; }
|
|
74
|
+
for (const child of value.values) render(child, next(context));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (value.kind === "unknown") { fail(value.reason || "The runtime slot's render path could not be traced."); return; }
|
|
78
|
+
if (value.kind !== "node") return;
|
|
79
|
+
const { node, env, via } = value;
|
|
80
|
+
if (ts.isJsxFragment(node)) { for (const child of node.children) render(reference(child, env, via), next(context)); return; }
|
|
81
|
+
if (ts.isArrayLiteralExpression(node)) { for (const child of node.elements) render(reference(ts.isSpreadElement(child) ? child.expression : child, env, via), next(context)); return; }
|
|
82
|
+
if (ts.isCallExpression(node) && operation(node.expression) === "cloneElement") {
|
|
83
|
+
const child = childValue(reference(node.arguments[0], env, via), next(context));
|
|
84
|
+
if (child.kind === "slot-child") {
|
|
85
|
+
if (!node.arguments[1] || !ownedProps(reference(node.arguments[1], env, via), state())) fail("A slot must clone its child with runtime-owned props.");
|
|
86
|
+
else cloned = true;
|
|
87
|
+
} else render(child, next(context));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const view = element(node, env, via);
|
|
91
|
+
if (!view) return;
|
|
92
|
+
function component(input) {
|
|
93
|
+
const target = resolve(input, next(context));
|
|
94
|
+
if (target.kind === "union") {
|
|
95
|
+
if (target.values.length > 32) { fail("Slot tracing reached its alternative limit."); return; }
|
|
96
|
+
for (const branch of target.values) component(branch);
|
|
97
|
+
} else if (view.intrinsic || ["provider", "context"].includes(target.kind) || target.kind === "literal" && typeof target.value === "string") render(get(view.attributes, "children", next(context)), next(context));
|
|
98
|
+
else if (target.kind === "node" && isFunction(target.node) && !isDevelopmentFile(target.node.getSourceFile().fileName, options)) {
|
|
99
|
+
for (const output of returns(target, [view.attributes], next(context), "component")) render(output, next(context));
|
|
100
|
+
} else fail("The runtime slot's component target could not be traced.");
|
|
101
|
+
}
|
|
102
|
+
component(view.tag);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const native = ts.isJsxOpeningElement(site) ? site.parent : site;
|
|
106
|
+
let child = native;
|
|
107
|
+
while (child.parent && (ts.isJsxExpression(child.parent) || ts.isParenthesizedExpression(child.parent) || ts.isAsExpression(child.parent) || ts.isSatisfiesExpression(child.parent) || ts.isNonNullExpression(child.parent))) child = child.parent;
|
|
108
|
+
let parent = child.parent;
|
|
109
|
+
if (parent && ts.isJsxAttribute(parent) && parent.name.text === "children") parent = parent.parent.parent;
|
|
110
|
+
if (parent && ts.isPropertyAssignment(parent) && parent.name.getText().replace(/^["']|["']$/g, "") === "children" && ts.isObjectLiteralExpression(parent.parent) && ts.isCallExpression(parent.parent.parent) && parent.parent.parent.arguments[1] === parent.parent) parent = parent.parent.parent;
|
|
111
|
+
const view = parent && element(parent), context = state();
|
|
112
|
+
if (view) {
|
|
113
|
+
const target = resolve(view.tag, next(context));
|
|
114
|
+
const original = childValue(get(view.attributes, "children", next(context)), next(context));
|
|
115
|
+
if (target.kind === "node" && publicNodes.has(target.node) && original.kind === "node" && original.node === native) {
|
|
116
|
+
view.attributes.layers.push(new Map([["children", { kind: "slot-child" }]]));
|
|
117
|
+
for (const output of returns(target, [view.attributes], next(context), "component")) render(output, next(context));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const result = { ok: cloned && !exposed && !uncertain, reasons: [...reasons].slice(0, 2) };
|
|
121
|
+
cache.set(site, result);
|
|
122
|
+
return result;
|
|
123
|
+
};
|
|
124
|
+
}
|
package/dist/scenarios.mjs
CHANGED
|
@@ -85,8 +85,9 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
|
|
88
|
-
|
|
89
|
-
if (!
|
|
88
|
+
const fixture = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].map(extension => fixtureBase + extension).find(existsSync);
|
|
89
|
+
if (!fixture) 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.");
|
|
90
|
+
if (!metadata || Object.keys(metadata).some((key) => !["title", "kind", "covers", "exercises", "tokens", "samples", "captureTargets"].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)
|
|
90
91
|
|| Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--")))
|
|
91
92
|
|| kind === "tokens" && (Object.hasOwn(metadata, "tokens") || !validTokenSamples(metadata.samples) || !metadata.covers.some(name => known.get(name)?.kind === "patterns"))
|
|
92
93
|
|| Object.hasOwn(metadata, "samples") && !validTokenSamples(metadata.samples)) {
|
|
@@ -95,6 +96,11 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
95
96
|
// A bad behavioral entry must fail validation without erasing independent
|
|
96
97
|
// render evidence and manufacturing missing-coverage errors for real UI.
|
|
97
98
|
const exercises = [];
|
|
99
|
+
let captureTargets;
|
|
100
|
+
if (Object.hasOwn(metadata, "captureTargets")) {
|
|
101
|
+
if (!metadata.captureTargets || typeof metadata.captureTargets !== "object" || Array.isArray(metadata.captureTargets) || Object.keys(metadata.captureTargets).length > 20 || Object.entries(metadata.captureTargets).some(([name, selector]) => !/^[a-zA-Z0-9][\w.-]{0,79}$/.test(name) || typeof selector !== "string" || !selector.trim() || selector.length > 500)) report(file, "captureTargets must map up to 20 named targets to nonempty CSS selectors (max 500 characters), scoped to this scenario's App iframe. Each selected target must resolve to exactly one visible element at capture time.");
|
|
102
|
+
else captureTargets = metadata.captureTargets;
|
|
103
|
+
}
|
|
98
104
|
if (Object.hasOwn(metadata, "exercises")) {
|
|
99
105
|
if (!Array.isArray(metadata.exercises)) report(file, "Scenario exercises must be a static array naming public behavioral APIs.");
|
|
100
106
|
else for (const [index, name] of metadata.exercises.entries()) {
|
|
@@ -111,7 +117,7 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
111
117
|
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.`);
|
|
112
118
|
}
|
|
113
119
|
const stylesheets = kind === "tokens" ? scoped.definitions.filter(entry => entry.unit === scoped.inventory.owner(relative)).map(entry => entry.file) : [];
|
|
114
|
-
scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], ...(Object.hasOwn(metadata, "exercises") ? { exercises: [...new Set(exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), ...(stylesheets.length ? { stylesheets } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
120
|
+
scenarios.push({ id, kind, title: metadata.title, ...(fixture ? { fixture: path.relative(root, fixture).replaceAll("\\", "/") } : {}), covers: [...new Set(metadata.covers)], ...(captureTargets ? { captureTargets } : {}), ...(Object.hasOwn(metadata, "exercises") ? { exercises: [...new Set(exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), ...(stylesheets.length ? { stylesheets } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
115
121
|
}
|
|
116
122
|
return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
|
|
117
123
|
}
|