@reqvire-org/ads 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +284 -2
- package/dist/assets.mjs +52 -0
- package/dist/bootstrap-ds/ADS.md +203 -0
- package/dist/bootstrap-ds/README.md +98 -0
- package/dist/bootstrap-ds/biome.css.json +21 -0
- package/dist/bootstrap-ds/design-system/assets/brand/README.md +28 -0
- package/dist/bootstrap-ds/design-system/assets/fonts/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/components/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/composition/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/index.ts +5 -0
- package/dist/bootstrap-ds/design-system/patterns/.gitkeep +0 -0
- package/dist/bootstrap-ds/design-system/showcase/mcp/index.html +1 -0
- package/dist/bootstrap-ds/design-system/showcase/mcp/main.tsx +8 -0
- package/dist/bootstrap-ds/design-system/styles.css +5 -0
- package/dist/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +47 -0
- package/dist/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +14 -0
- package/dist/bootstrap-ds/design-system/tokens/base.css +27 -0
- package/dist/bootstrap-ds/design-system/tokens/colors.css +29 -0
- package/dist/bootstrap-ds/design-system/tokens/elevation.css +15 -0
- package/dist/bootstrap-ds/design-system/tokens/spacing.css +15 -0
- package/dist/bootstrap-ds/design-system/tokens/typography.css +13 -0
- package/dist/bootstrap-ds/ds.config.json +13 -0
- package/dist/bootstrap-ds/gitignore.template +8 -0
- package/dist/bootstrap-ds/package-lock.json +4660 -0
- package/dist/bootstrap-ds/package.json +41 -0
- package/dist/bootstrap-ds/scripts/build-mcp-previews.mjs +33 -0
- package/dist/bootstrap-ds/src/vite-env.d.ts +7 -0
- package/dist/bootstrap-ds/tsconfig.build.json +12 -0
- package/dist/bootstrap-ds/tsconfig.json +20 -0
- package/dist/bootstrap-ds/vite.config.ts +21 -0
- package/dist/boundaries.mjs +130 -0
- package/dist/brand-assets.mjs +82 -0
- package/dist/check.mjs +222 -0
- package/dist/cli.mjs +106 -0
- package/dist/config.mjs +181 -0
- package/dist/contracts/tokens.json +52 -0
- package/dist/html.mjs +30 -0
- package/dist/index.mjs +9 -0
- package/dist/init.mjs +194 -0
- package/dist/lint-config.mjs +40 -0
- package/dist/mcp/THIRD_PARTY_NOTICES.md +786 -0
- package/dist/mcp/apps.mjs +53 -0
- package/dist/mcp/build-status.mjs +164 -0
- package/dist/mcp/catalog.mjs +65 -0
- package/dist/mcp/context.mjs +69 -0
- package/dist/mcp/mutations.mjs +301 -0
- package/dist/mcp/patch.mjs +114 -0
- package/dist/mcp/preview-watch.mjs +169 -0
- package/dist/mcp/preview-widget.bundle.js +113 -0
- package/dist/mcp/project-widget.bundle.js +113 -0
- package/dist/mcp/prompts.mjs +28 -0
- package/dist/mcp/resource-loader.js +138 -0
- package/dist/mcp/sandbox-host.mjs +22 -0
- package/dist/mcp/server.mjs +377 -0
- package/dist/mcp/showcase-host.mjs +68 -0
- package/dist/mcp/skills.mjs +77 -0
- package/dist/mcp/widget.bundle.js +150 -0
- package/dist/oxlint.mjs +81 -0
- package/dist/preview-resources.mjs +198 -0
- package/dist/project-documents.mjs +27 -0
- package/dist/public-props.mjs +74 -0
- package/dist/scenarios-compiler.mjs +287 -0
- package/dist/scenarios-vite.d.mts +3 -0
- package/dist/scenarios-vite.mjs +71 -0
- package/dist/scenarios.mjs +212 -0
- package/dist/showcase/client.bundle.js +127 -0
- package/dist/showcase/index.html +17 -0
- package/dist/showcase/sandbox.js +30 -0
- package/dist/showcase/style.css +7 -0
- package/dist/showcase-coverage.mjs +118 -0
- package/dist/showcase.mjs +77 -0
- package/dist/skills/README.md +68 -0
- package/dist/skills/ads-assimilate/SKILL.md +292 -0
- package/dist/skills/ads-assimilate/agents/openai.yaml +4 -0
- package/dist/skills/ads-assimilate/references/cli.md +111 -0
- package/dist/skills/ads-assimilate/references/lint-and-docs.md +87 -0
- package/dist/skills/ads-assimilate/references/native-implementation.md +98 -0
- package/dist/skills/ads-assimilate/references/testing.md +107 -0
- package/dist/skills/ads-break-ui/LICENSE.upstream +21 -0
- package/dist/skills/ads-break-ui/README.md +16 -0
- package/dist/skills/ads-break-ui/SKILL.md +235 -0
- package/dist/skills/ads-break-ui/agents/openai.yaml +4 -0
- package/dist/skills/ads-break-ui/references/catalog.md +135 -0
- package/dist/skills/ads-break-ui/references/verification.md +48 -0
- package/dist/skills/ads-designer/LICENSE.upstream +21 -0
- package/dist/skills/ads-designer/README.md +19 -0
- package/dist/skills/ads-designer/SKILL.md +400 -0
- package/dist/skills/ads-designer/agents/openai.yaml +4 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +203 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +98 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/biome.css.json +21 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/assets/brand/README.md +28 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/assets/fonts/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/components/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/composition/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/index.ts +5 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/.gitkeep +0 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/showcase/mcp/index.html +1 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/showcase/mcp/main.tsx +8 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/styles.css +5 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +47 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +14 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/base.css +27 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/colors.css +29 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/elevation.css +15 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/spacing.css +15 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/typography.css +13 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/ds.config.json +13 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/gitignore.template +8 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/package-lock.json +4660 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/package.json +41 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/scripts/build-mcp-previews.mjs +33 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/src/vite-env.d.ts +7 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/tsconfig.build.json +12 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/tsconfig.json +20 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/vite.config.ts +21 -0
- package/dist/skills/ads-designer/references/cli.md +111 -0
- package/dist/skills/ads-designer/references/design-craft.md +65 -0
- package/dist/skills/ads-designer/references/mobile.md +73 -0
- package/dist/skills/ads-import/SKILL.md +335 -0
- package/dist/skills/ads-import/agents/openai.yaml +4 -0
- package/dist/skills/ads-import/references/branding.md +44 -0
- package/dist/skills/ads-import/references/cli.md +111 -0
- package/dist/skills/ads-import/references/implementation.md +111 -0
- package/dist/skills/ads-import/references/tokens.md +96 -0
- package/dist/skills/ads-motion/LICENSE.upstream +21 -0
- package/dist/skills/ads-motion/README.md +22 -0
- package/dist/skills/ads-motion/SKILL.md +217 -0
- package/dist/skills/ads-motion/agents/openai.yaml +4 -0
- package/dist/skills/ads-motion/references/audit.md +44 -0
- package/dist/skills/ads-motion/references/build.md +81 -0
- package/dist/skills/ads-motion/references/performance.md +55 -0
- package/dist/skills/ads-motion/references/review.md +35 -0
- package/dist/skills/ads-motion/references/verification.md +39 -0
- package/dist/token-policy.mjs +10 -0
- package/dist/tokens.mjs +142 -0
- package/dist/ui-framework.mjs +59 -0
- package/dist/units.mjs +168 -0
- package/docs/ADS.md +310 -0
- package/docs/ARCHITECTURE.md +311 -0
- package/docs/CHANGELOG.md +106 -0
- package/docs/GUIDE.md +722 -0
- package/docs/MCP.md +690 -0
- package/package.json +107 -4
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadConfig, isInside } from "./config.mjs";
|
|
5
|
+
import { inspectScenarios } from "./scenarios.mjs";
|
|
6
|
+
import { artifactDigest, createBuildReporter } from "./mcp/build-status.mjs";
|
|
7
|
+
import { readPreviewManifest } from "./preview-resources.mjs";
|
|
8
|
+
|
|
9
|
+
const digest = value => createHash("sha256").update(value).digest("hex");
|
|
10
|
+
const fileDigest = file => { try { return digest(readFileSync(file)); } catch { return null; } };
|
|
11
|
+
|
|
12
|
+
/** Cache only successful validation + matching source/dependency/output content.
|
|
13
|
+
* Cache state is development output, never proof that a failed build is current. */
|
|
14
|
+
export function createPreviewCache(options, compilerFiles = []) {
|
|
15
|
+
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
|
+
...["package.json", "package-lock.json", "bun.lock", "bun.lockb", "node_modules/.package-lock.json"].map(file => path.join(root, file))];
|
|
18
|
+
const adapterDigest = () => digest(JSON.stringify(adapterFiles.map(file => [file, fileDigest(file)])));
|
|
19
|
+
const loadedAdapter = adapterDigest();
|
|
20
|
+
const target = path.join(root, ".ds/preview-cache.json");
|
|
21
|
+
let value = { schemaVersion: 1, validation: null, entries: {} };
|
|
22
|
+
try {
|
|
23
|
+
if (realpathSync(target) === target) {
|
|
24
|
+
const stored = JSON.parse(readFileSync(target, "utf8"));
|
|
25
|
+
if (stored.schemaVersion === 1 && stored.entries && typeof stored.entries === "object") value = stored;
|
|
26
|
+
}
|
|
27
|
+
} catch { /* Missing or corrupt cache means validate and rebuild. */ }
|
|
28
|
+
function inputs() {
|
|
29
|
+
const rows = [];
|
|
30
|
+
const generated = config.assets.generatedPaths.map(file => path.resolve(root, file));
|
|
31
|
+
function visit(directory) {
|
|
32
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
33
|
+
if (entry.isSymbolicLink() || ["node_modules", ".git", ".ds", ".vite", "coverage"].includes(entry.name)) continue;
|
|
34
|
+
const file = path.join(directory, entry.name);
|
|
35
|
+
if (generated.some(base => isInside(base, file))) continue;
|
|
36
|
+
if (entry.isDirectory()) visit(file);
|
|
37
|
+
else if (entry.isFile() && /\.(?:[cm]?[jt]sx?|css|html|json|md|svg|png|jpe?g|webp|gif|woff2?|ttf|otf|lock|lockb)$/.test(file)) rows.push([path.relative(root, file), fileDigest(file)]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
visit(root);
|
|
41
|
+
rows.push(["installed-packages", fileDigest(path.join(root, "node_modules/.package-lock.json"))]);
|
|
42
|
+
rows.push(["node-runtime", `${process.version}/${process.platform}/${process.arch}`]);
|
|
43
|
+
for (const file of compilerFiles) rows.push([file, fileDigest(file)]);
|
|
44
|
+
// Tool upgrades invalidate validation even when the project's lockfile is unchanged.
|
|
45
|
+
for (const name of readdirSync(import.meta.dirname).filter(name => name.endsWith(".mjs"))) {
|
|
46
|
+
rows.push([`ads-tool/${name}`, fileDigest(path.join(import.meta.dirname, name))]);
|
|
47
|
+
}
|
|
48
|
+
return digest(JSON.stringify(rows.sort((a, b) => a[0].localeCompare(b[0]))));
|
|
49
|
+
}
|
|
50
|
+
function save() {
|
|
51
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
52
|
+
if (realpathSync(path.dirname(target)) !== path.dirname(target)) throw new Error("Preview cache cannot resolve through a symlink.");
|
|
53
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
54
|
+
try { writeFileSync(temporary, JSON.stringify(value)); renameSync(temporary, target); }
|
|
55
|
+
finally { rmSync(temporary, { force: true }); }
|
|
56
|
+
}
|
|
57
|
+
const compilerDigest = () => digest(JSON.stringify([
|
|
58
|
+
["adapter", adapterDigest()],
|
|
59
|
+
...compilerFiles.map(file => [file, fileDigest(file)]),
|
|
60
|
+
...readdirSync(root).filter(name => !/^(?:biome|oxlint|\.oxlint)/.test(name) && (/\.(?:json|lock|lockb)$/.test(name) || /^(?:vite|babel)\.config\./.test(name))).map(name => [name, fileDigest(path.join(root, name))]),
|
|
61
|
+
["installed-packages", fileDigest(path.join(root, "node_modules/.package-lock.json"))],
|
|
62
|
+
["scheduler", fileDigest(import.meta.filename)],
|
|
63
|
+
["node-runtime", `${process.version}/${process.platform}/${process.arch}`],
|
|
64
|
+
]));
|
|
65
|
+
function reusable(entry) {
|
|
66
|
+
const cached = value.entries[entry.id];
|
|
67
|
+
if (!cached || cached.metadata !== JSON.stringify(entry) || cached.compiler !== compilerDigest()) return false;
|
|
68
|
+
if (!cached.dependencies || !Object.keys(cached.dependencies).length) return false;
|
|
69
|
+
if (Object.entries(cached.dependencies).some(([file, hash]) => fileDigest(path.resolve(root, file)) !== hash)) return false;
|
|
70
|
+
try { return cached.artifact === artifactDigest(path.join(root, entry.artifact)); } catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
return { inputs, reusable, validated: stamp => value.validation === stamp,
|
|
73
|
+
assertCompiler() {
|
|
74
|
+
if (adapterDigest() !== loadedAdapter) throw new Error("Preview compiler code or dependencies changed. Restart ADS MCP/showcase to load the updated compiler before reviewing previews.");
|
|
75
|
+
},
|
|
76
|
+
validate(stamp) { value.validation = stamp; save(); },
|
|
77
|
+
record(entry, dependencies) {
|
|
78
|
+
value.entries[entry.id] = { metadata: JSON.stringify(entry), compiler: compilerDigest(),
|
|
79
|
+
dependencies: Object.fromEntries([...new Set([path.join(root, entry.file), ...dependencies])]
|
|
80
|
+
.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)) };
|
|
82
|
+
save();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Shared scheduling and cache; the project supplies its own React/Preact build. */
|
|
88
|
+
export async function runScenarioCompiler(directory, { watched = false, validate, build, buildGraph, compilerFiles = [] }) {
|
|
89
|
+
const root = path.resolve(directory);
|
|
90
|
+
let options = loadConfig(root);
|
|
91
|
+
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
|
+
const cache = createPreviewCache(options, compilerFiles);
|
|
93
|
+
function registry() {
|
|
94
|
+
options = loadConfig(root);
|
|
95
|
+
const result = inspectScenarios(options);
|
|
96
|
+
if (result.diagnostics.length) throw new Error(result.diagnostics.map(item => `${item.file}: ${item.message}`).join("\n"));
|
|
97
|
+
mkdirSync(path.join(root, ".ds/scenarios"), { recursive: true });
|
|
98
|
+
writeFileSync(path.join(root, ".ds/scenarios/manifest.json"), JSON.stringify({ schemaVersion: 1, scenarios: result.scenarios }, null, 2));
|
|
99
|
+
return result.scenarios;
|
|
100
|
+
}
|
|
101
|
+
let entries = registry();
|
|
102
|
+
const reporter = createBuildReporter(root, { apps: entries.map(entry => ({ id: entry.id, file: entry.artifact })) });
|
|
103
|
+
const requested = new Set();
|
|
104
|
+
const pending = new Set();
|
|
105
|
+
let active;
|
|
106
|
+
let compiling = false;
|
|
107
|
+
let stopped = false;
|
|
108
|
+
let generation = 0;
|
|
109
|
+
let publishedStamp;
|
|
110
|
+
let validation;
|
|
111
|
+
function gate(stamp) {
|
|
112
|
+
if (cache.validated(stamp)) return Promise.resolve();
|
|
113
|
+
return validation ??= Promise.resolve().then(validate).then(() => {
|
|
114
|
+
if (stamp !== cache.inputs()) throw new Error("Sources changed during preview validation. Waiting for the current build.");
|
|
115
|
+
cache.validate(stamp);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async function compile(entry) {
|
|
119
|
+
if (buildGraph) return compileGraph();
|
|
120
|
+
const revision = reporter.begin(entry.id);
|
|
121
|
+
const started = generation;
|
|
122
|
+
const stamp = cache.inputs();
|
|
123
|
+
try {
|
|
124
|
+
cache.assertCompiler();
|
|
125
|
+
const reuse = cache.reusable(entry);
|
|
126
|
+
const dependencies = new Set();
|
|
127
|
+
function assets(directory) {
|
|
128
|
+
for (const item of readdirSync(directory, { withFileTypes: true })) {
|
|
129
|
+
if (item.isSymbolicLink() || ["node_modules", ".ds", ".vite", "dist"].includes(item.name)) continue;
|
|
130
|
+
const file = path.join(directory, item.name);
|
|
131
|
+
if (item.isDirectory()) assets(file);
|
|
132
|
+
else if (/\.(?:css|svg|png|jpe?g|webp|gif|woff2?|ttf|otf)$/.test(file)) dependencies.add(file);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// CSS url() inputs may be inlined without becoming Rollup JS modules.
|
|
136
|
+
// Shared foundation/styles/assets conservatively invalidate all previews.
|
|
137
|
+
assets(options.dsRoot);
|
|
138
|
+
const track = { name: "ads-preview-dependencies", generateBundle() {
|
|
139
|
+
// Include modules used during extraction, even when their final JS was
|
|
140
|
+
// tree-shaken. A stylesheet evaluation can depend on those exports.
|
|
141
|
+
for (const id of this.getModuleIds()) {
|
|
142
|
+
const file = id.split("?")[0];
|
|
143
|
+
if (path.isAbsolute(file) && existsSync(file)) dependencies.add(file);
|
|
144
|
+
}
|
|
145
|
+
} };
|
|
146
|
+
const outcomes = await Promise.allSettled([gate(stamp), reuse ? Promise.resolve() : build(entry, options, track)]);
|
|
147
|
+
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
148
|
+
if (failure) throw failure.reason;
|
|
149
|
+
cache.assertCompiler();
|
|
150
|
+
if (started !== generation || stamp !== cache.inputs()) return; // Watch queued a newer revision.
|
|
151
|
+
if (!reuse) cache.record(entry, dependencies);
|
|
152
|
+
reporter.ready(entry.id, revision);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (started !== generation) return;
|
|
155
|
+
reporter.error(entry.id, revision, error);
|
|
156
|
+
console.error(error);
|
|
157
|
+
if (!watched) process.exitCode = 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function compileGraph() {
|
|
161
|
+
const revisions = new Map(entries.map(entry => [entry.id, reporter.begin(entry.id)]));
|
|
162
|
+
const started = generation;
|
|
163
|
+
const stamp = cache.inputs();
|
|
164
|
+
const dependencies = new Set();
|
|
165
|
+
function assets(directory) {
|
|
166
|
+
for (const item of readdirSync(directory, { withFileTypes: true })) {
|
|
167
|
+
if (item.isSymbolicLink() || ["node_modules", ".ds", ".vite", "dist"].includes(item.name)) continue;
|
|
168
|
+
const file = path.join(directory, item.name);
|
|
169
|
+
if (item.isDirectory()) assets(file);
|
|
170
|
+
else if (/\.(?:css|svg|png|jpe?g|webp|gif|woff2?|ttf|otf)$/.test(file)) dependencies.add(file);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
assets(options.dsRoot);
|
|
174
|
+
const track = { name: "ads-preview-graph-dependencies", generateBundle() {
|
|
175
|
+
for (const id of this.getModuleIds()) {
|
|
176
|
+
const file = id.split("?")[0];
|
|
177
|
+
if (path.isAbsolute(file) && existsSync(file)) dependencies.add(file);
|
|
178
|
+
}
|
|
179
|
+
} };
|
|
180
|
+
try {
|
|
181
|
+
cache.assertCompiler();
|
|
182
|
+
let reusable = entries.every(entry => cache.reusable(entry));
|
|
183
|
+
if (reusable) { try { reusable = Boolean(readPreviewManifest(options, { verify: true })); } catch { reusable = false; } }
|
|
184
|
+
const outcomes = await Promise.allSettled([gate(stamp), reusable ? Promise.resolve(null) : buildGraph(entries, options, track)]);
|
|
185
|
+
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
186
|
+
if (failure) throw failure.reason;
|
|
187
|
+
cache.assertCompiler();
|
|
188
|
+
if (started !== generation || stamp !== cache.inputs()) return;
|
|
189
|
+
const publication = outcomes[1].value;
|
|
190
|
+
if (publication) {
|
|
191
|
+
publication.publish(reporter.session);
|
|
192
|
+
for (const entry of entries) cache.record(entry, [...dependencies, ...publication.assetFiles]);
|
|
193
|
+
} else {
|
|
194
|
+
const file = path.join(root, options.config.mcp.previewManifest);
|
|
195
|
+
const manifest = readPreviewManifest(options);
|
|
196
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ ...manifest, session: reporter.session }));
|
|
197
|
+
renameSync(`${file}.tmp`, file);
|
|
198
|
+
}
|
|
199
|
+
for (const entry of entries) { pending.delete(entry.id); reporter.ready(entry.id, revisions.get(entry.id)); }
|
|
200
|
+
publishedStamp = stamp;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (started !== generation) return;
|
|
203
|
+
for (const entry of entries) { pending.delete(entry.id); reporter.error(entry.id, revisions.get(entry.id), error); }
|
|
204
|
+
console.error(error);
|
|
205
|
+
if (!watched) process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function drain() {
|
|
209
|
+
if (compiling) return;
|
|
210
|
+
compiling = true;
|
|
211
|
+
try {
|
|
212
|
+
while (pending.size && !stopped) {
|
|
213
|
+
const id = pending.has(active) ? active : pending.values().next().value;
|
|
214
|
+
pending.delete(id);
|
|
215
|
+
const entry = entries.find(entry => entry.id === id);
|
|
216
|
+
if (entry) await compile(entry);
|
|
217
|
+
}
|
|
218
|
+
} finally { compiling = false; }
|
|
219
|
+
}
|
|
220
|
+
function request() {
|
|
221
|
+
const file = process.env.ADS_MCP_REQUEST_FILE;
|
|
222
|
+
if (!file || !existsSync(file)) return;
|
|
223
|
+
try {
|
|
224
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
225
|
+
if (value.session !== process.env.ADS_MCP_BUILD_SESSION) return;
|
|
226
|
+
active = entries.find(entry => entry.artifact === value.active)?.id;
|
|
227
|
+
for (const file of value.files) {
|
|
228
|
+
const entry = entries.find(entry => entry.artifact === file);
|
|
229
|
+
if (entry && !requested.has(entry.id)) {
|
|
230
|
+
requested.add(entry.id);
|
|
231
|
+
if (!buildGraph || publishedStamp !== cache.inputs()) pending.add(entry.id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
void drain();
|
|
235
|
+
} catch (error) { console.error(`Preview requests: ${error.message}`); }
|
|
236
|
+
}
|
|
237
|
+
if (!watched) {
|
|
238
|
+
for (const entry of entries) pending.add(entry.id);
|
|
239
|
+
await drain();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let timer;
|
|
243
|
+
const changed = new Set();
|
|
244
|
+
function sourceChanged(_event, file) {
|
|
245
|
+
if (!file || !/\.(?:[cm]?[jt]sx?|css|html|json|md|svg|png|jpe?g|webp|gif|woff2?|ttf|otf|lock|lockb)$/.test(file)) return;
|
|
246
|
+
const absolute = path.join(root, file);
|
|
247
|
+
if (file.split(path.sep).some(name => ["node_modules", ".git", ".ds", ".vite", "coverage"].includes(name)) || options.config.assets.generatedPaths.some(base => isInside(path.resolve(root, base), absolute))) return;
|
|
248
|
+
changed.add(absolute);
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
timer = setTimeout(() => {
|
|
251
|
+
++generation;
|
|
252
|
+
validation = undefined;
|
|
253
|
+
const changes = [...changed]; changed.clear();
|
|
254
|
+
try {
|
|
255
|
+
entries = registry();
|
|
256
|
+
reporter.setApps(entries.map(entry => ({ id: entry.id, file: entry.artifact })));
|
|
257
|
+
for (const id of requested) if (!entries.some(entry => entry.id === id)) { requested.delete(id); pending.delete(id); }
|
|
258
|
+
// Every source change needs validation; unaffected artifacts need no
|
|
259
|
+
// compilation. Check active first, then rebuild only changed dependencies.
|
|
260
|
+
for (const id of requested) {
|
|
261
|
+
const entry = entries.find(entry => entry.id === id);
|
|
262
|
+
if (entry) { reporter.begin(id); pending.add(id); }
|
|
263
|
+
}
|
|
264
|
+
if (changes.length) void drain();
|
|
265
|
+
} catch (error) {
|
|
266
|
+
for (const id of requested) { const revision = reporter.begin(id); reporter.error(id, revision, error); }
|
|
267
|
+
console.error(error);
|
|
268
|
+
}
|
|
269
|
+
}, 100);
|
|
270
|
+
}
|
|
271
|
+
// Do not allocate recursive OS watches for node_modules or generated trees.
|
|
272
|
+
const watchers = [watch(root, sourceChanged)];
|
|
273
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
274
|
+
const directory = path.join(root, entry.name);
|
|
275
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || ["node_modules", ".git", ".ds", ".vite", "coverage"].includes(entry.name)
|
|
276
|
+
|| options.config.assets.generatedPaths.some(base => isInside(path.resolve(root, base), directory))) continue;
|
|
277
|
+
watchers.push(watch(directory, { recursive: true }, (event, file) => sourceChanged(event, file && path.join(entry.name, file))));
|
|
278
|
+
}
|
|
279
|
+
const requestWatch = watch(path.join(root, ".ds"), (_event, file) => {
|
|
280
|
+
if (file === path.basename(process.env.ADS_MCP_REQUEST_FILE ?? "")) request();
|
|
281
|
+
});
|
|
282
|
+
request();
|
|
283
|
+
console.log(`Scenario compiler ready: ${entries.length} entries; builds are requested on demand.`);
|
|
284
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
|
|
285
|
+
stopped = true; clearTimeout(timer); for (const watcher of watchers) watcher.close(); requestWatch.close();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { loadConfig, isInside } from "./config.mjs";
|
|
4
|
+
import { inspectScenarios } from "./scenarios.mjs";
|
|
5
|
+
export { createPreviewResources, previewEntriesPlugin } from "./preview-resources.mjs";
|
|
6
|
+
|
|
7
|
+
/** Project-owned rendering; this plugin supplies only discoverable module entries. */
|
|
8
|
+
export function scenarioRegistryPlugin(directory = process.cwd()) {
|
|
9
|
+
const options = loadConfig(directory);
|
|
10
|
+
const virtual = "virtual:ads-scenarios";
|
|
11
|
+
const resolved = "\0virtual:ads-scenarios";
|
|
12
|
+
function registry() {
|
|
13
|
+
const value = inspectScenarios(options);
|
|
14
|
+
if (value.diagnostics.length) throw new Error(value.diagnostics.map((item) => `${item.file}: ${item.message}`).join("\n"));
|
|
15
|
+
mkdirSync(path.join(options.root, ".ds/scenarios"), { recursive: true });
|
|
16
|
+
writeFileSync(path.join(options.root, ".ds/scenarios/manifest.json"), JSON.stringify({ schemaVersion: 1, scenarios: value.scenarios }, null, 2) + "\n");
|
|
17
|
+
return `export const scenarios = [${value.scenarios.map((item) => `{...${JSON.stringify(item)}, load: () => import(${JSON.stringify(path.join(options.root, item.file).replaceAll("\\", "/"))})}`).join(",\n")}];`;
|
|
18
|
+
}
|
|
19
|
+
return { name: "ads-scenario-registry", resolveId(id) { if (id === virtual) return resolved; },
|
|
20
|
+
load(id) { if (id === resolved) return registry(); },
|
|
21
|
+
handleHotUpdate({ file, server }) {
|
|
22
|
+
if (!isInside(options.dsRoot, file)) return;
|
|
23
|
+
const module = server.moduleGraph.getModuleById(resolved);
|
|
24
|
+
if (module) server.moduleGraph.invalidateModule(module);
|
|
25
|
+
},
|
|
26
|
+
configureServer(server) {
|
|
27
|
+
const changed = (file) => {
|
|
28
|
+
if (!isInside(options.dsRoot, file) || !/\.(?:scenario\.[jt]sx?|[jt]sx?)$/.test(file)) return;
|
|
29
|
+
const module = server.moduleGraph.getModuleById(resolved);
|
|
30
|
+
if (module) { server.moduleGraph.invalidateModule(module); server.ws.send({ type: "full-reload" }); }
|
|
31
|
+
};
|
|
32
|
+
server.watcher.on("add", changed); server.watcher.on("unlink", changed);
|
|
33
|
+
server.httpServer?.once("close", () => { server.watcher.off("add", changed); server.watcher.off("unlink", changed); });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Optional Vite adapter for self-contained, focused MCP scenario artifacts. */
|
|
39
|
+
export function inlineScenarioHtml() {
|
|
40
|
+
return { name: "ads-inline-scenario", enforce: "post", generateBundle(_options, bundle) {
|
|
41
|
+
const embedded = new Map();
|
|
42
|
+
for (const asset of Object.values(bundle)) {
|
|
43
|
+
if (asset.type === "asset" && asset.fileName.endsWith(".js")) {
|
|
44
|
+
embedded.set(asset.fileName, `data:application/javascript;base64,${Buffer.from(asset.source).toString("base64")}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Vite emits worker bundles separately. Preserve their compiled code as a
|
|
48
|
+
// data URL, so workers still run inside an opaque-origin MCP Apps frame.
|
|
49
|
+
for (const chunk of Object.values(bundle)) if (chunk.type === "chunk") {
|
|
50
|
+
for (const [file, url] of embedded) {
|
|
51
|
+
const relative = path.posix.relative(path.posix.dirname(chunk.fileName), file);
|
|
52
|
+
for (const reference of new Set([file, `./${file}`, `/${file}`, relative, `./${relative}`])) {
|
|
53
|
+
chunk.code = chunk.code.replaceAll(JSON.stringify(reference), JSON.stringify(url));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const asset of Object.values(bundle)) {
|
|
58
|
+
if (asset.type !== "asset" || !asset.fileName.endsWith(".html")) continue;
|
|
59
|
+
asset.source = asset.source.toString().replace(/<script\b[^>]*\bsrc="([^"]+)"[^>]*><\/script>/g, (_match, src) => {
|
|
60
|
+
const chunk = bundle[src.replace(/^\.\//, "").replace(/^\//, "")];
|
|
61
|
+
if (!chunk || chunk.type !== "chunk") throw new Error(`Cannot inline scenario module: ${src}`);
|
|
62
|
+
return `<script type="module">${chunk.code.replaceAll("</script", "<\\/script")}</script>`;
|
|
63
|
+
}).replace(/<link\b[^>]*\bhref="([^"]+)"[^>]*>/g, (_match, href) => {
|
|
64
|
+
const style = bundle[href.replace(/^\.\//, "").replace(/^\//, "")];
|
|
65
|
+
if (!style || style.type !== "asset" || !style.fileName.endsWith(".css")) throw new Error(`Cannot inline scenario style: ${href}`);
|
|
66
|
+
return `<style>${style.source.toString().replaceAll("</style", "<\\/style")}</style>`;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const key of Object.keys(bundle)) if (!key.endsWith(".html")) delete bundle[key];
|
|
70
|
+
} };
|
|
71
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { uiOperation } from "./ui-framework.mjs";
|
|
5
|
+
import { isDevelopmentFile, isInside, isScenarioFile, sourceFiles } from "./config.mjs";
|
|
6
|
+
import { projectCompilerOptions } from "./boundaries.mjs";
|
|
7
|
+
import { inspectTokens } from "./tokens.mjs";
|
|
8
|
+
import { isSharedToken } from "./token-policy.mjs";
|
|
9
|
+
|
|
10
|
+
export const scenarioArtifact = (root, id) => path.join(root, ".ds/scenarios", id, "index.html");
|
|
11
|
+
|
|
12
|
+
function literal(node, checker, seen = new Set()) {
|
|
13
|
+
while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression;
|
|
14
|
+
if (!node || seen.has(node)) return undefined;
|
|
15
|
+
seen = new Set([...seen, node]);
|
|
16
|
+
if (node && ts.isStringLiteralLike(node)) return node.text;
|
|
17
|
+
if (node && ts.isArrayLiteralExpression(node)) return node.elements.map(item => literal(item, checker, seen));
|
|
18
|
+
if (node && ts.isObjectLiteralExpression(node)) return Object.fromEntries(node.properties.map((item) => ts.isPropertyAssignment(item) && item.name && (ts.isIdentifier(item.name) || ts.isStringLiteralLike(item.name)) ? [item.name.text, literal(item.initializer, checker, seen)] : ["invalid", undefined]));
|
|
19
|
+
if (checker && (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node))) {
|
|
20
|
+
let symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
|
|
21
|
+
if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
|
|
22
|
+
const declaration = symbol?.valueDeclaration;
|
|
23
|
+
if (declaration && (ts.isPropertyAssignment(declaration) || ts.isVariableDeclaration(declaration) && declaration.parent.flags & ts.NodeFlags.Const)) return literal(declaration.initializer, checker, seen);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function publicUi(program, options) {
|
|
29
|
+
const checker = program.getTypeChecker();
|
|
30
|
+
const entry = program.getSourceFile(path.join(options.dsRoot, options.config.publicEntry));
|
|
31
|
+
const module = entry && checker.getSymbolAtLocation(entry);
|
|
32
|
+
const items = [];
|
|
33
|
+
for (const exported of module ? checker.getExportsOfModule(module) : []) {
|
|
34
|
+
let symbol = exported;
|
|
35
|
+
if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
|
|
36
|
+
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
|
|
37
|
+
if (!declaration || !(symbol.flags & ts.SymbolFlags.Value)) continue;
|
|
38
|
+
const file = declaration.getSourceFile().fileName;
|
|
39
|
+
if (isDevelopmentFile(file, options)) continue;
|
|
40
|
+
const kind = isInside(path.join(options.dsRoot, "components"), file) ? "components" : isInside(path.join(options.dsRoot, "patterns"), file) ? "patterns" : null;
|
|
41
|
+
if (!kind) continue;
|
|
42
|
+
let renderable = false;
|
|
43
|
+
function visit(node) {
|
|
44
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) renderable = true;
|
|
45
|
+
if (ts.isCallExpression(node) && ["createElement", "forwardRef", "memo"].includes(uiOperation(node.expression, checker))) renderable = true;
|
|
46
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
47
|
+
let tag = node.tag;
|
|
48
|
+
if (ts.isCallExpression(tag)) tag = tag.expression;
|
|
49
|
+
let base = tag;
|
|
50
|
+
while (ts.isPropertyAccessExpression(base)) base = base.expression;
|
|
51
|
+
const symbol = checker.getSymbolAtLocation(base);
|
|
52
|
+
if (symbol?.declarations?.some(item => ts.isImportSpecifier(item) && (item.propertyName ?? item.name).text === "styled" && item.parent.parent.parent.moduleSpecifier.text.startsWith("@linaria/") || ts.isNamespaceImport(item) && item.parent.parent.moduleSpecifier.text.startsWith("@linaria/") && /^\.styled(?:\.|$)/.test(tag.getText().slice(base.getText().length)))) renderable = true;
|
|
53
|
+
}
|
|
54
|
+
ts.forEachChild(node, visit);
|
|
55
|
+
}
|
|
56
|
+
visit(declaration);
|
|
57
|
+
items.push({ name: exported.name, kind, file, declaration, renderable });
|
|
58
|
+
}
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Discover metadata statically. The MCP process never imports scenario modules. */
|
|
63
|
+
export function inspectScenarios(options, suppliedProgram) {
|
|
64
|
+
const { root, dsRoot } = options;
|
|
65
|
+
const files = sourceFiles(dsRoot).filter(isScenarioFile);
|
|
66
|
+
const program = suppliedProgram ?? ts.createProgram([path.join(dsRoot, options.config.publicEntry), ...files], projectCompilerOptions(options));
|
|
67
|
+
const known = new Map(publicUi(program, options).map((item) => [item.name, item]));
|
|
68
|
+
const checker = program.getTypeChecker();
|
|
69
|
+
const tokenNames = new Set(inspectTokens(root).tokens.map(token => token.name));
|
|
70
|
+
const scenarios = [];
|
|
71
|
+
const diagnostics = [];
|
|
72
|
+
const ids = new Set();
|
|
73
|
+
function report(file, message) { diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "scenario-contract", line: 1, column: 1, message }); }
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
|
|
76
|
+
const kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
|
|
77
|
+
if (!["tokens", "components", "patterns", "composition"].includes(kind)) { report(file, "Scenarios live beside tokens/components/patterns or under composition/, not inside showcase pages."); continue; }
|
|
78
|
+
const id = relative.replace(/(?:\/Scenario|\.scenario)\.[^.]+$/, "");
|
|
79
|
+
if (id.length > 256) { report(file, "Scenario paths must fit within 256 characters."); continue; }
|
|
80
|
+
if (ids.has(id)) { report(file, `Duplicate scenario id ${id}. Use one entrypoint per identity.`); continue; }
|
|
81
|
+
ids.add(id);
|
|
82
|
+
const source = program.getSourceFile(file);
|
|
83
|
+
if (!source) { report(file, "Cannot inspect scenario source."); continue; }
|
|
84
|
+
let metadata;
|
|
85
|
+
let entry = false;
|
|
86
|
+
for (const node of source.statements) {
|
|
87
|
+
if (ts.isExportAssignment(node) || node.modifiers?.some((item) => item.kind === ts.SyntaxKind.DefaultKeyword)) entry = true;
|
|
88
|
+
if (ts.isVariableStatement(node) && node.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
89
|
+
for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === "scenario") metadata = literal(declaration.initializer, checker);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!entry) report(file, "Export a default independently mountable scenario entrypoint.");
|
|
93
|
+
const module = checker.getSymbolAtLocation(source);
|
|
94
|
+
const defaultExport = module && checker.getExportsOfModule(module).find(item => item.name === "default");
|
|
95
|
+
if (defaultExport) {
|
|
96
|
+
const type = checker.getTypeOfSymbolAtLocation(defaultExport, source);
|
|
97
|
+
const calls = [...type.getCallSignatures(), ...type.getConstructSignatures()];
|
|
98
|
+
if (!calls.length && !(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))) report(file, "Export a mountable component as default, not an already-created element or data object.");
|
|
99
|
+
for (const call of calls) {
|
|
100
|
+
const props = call.parameters[0];
|
|
101
|
+
const optional = props?.declarations?.some(item => item.initializer || item.questionToken);
|
|
102
|
+
if (props && !optional && checker.getTypeOfSymbolAtLocation(props, source).getProperties().some(item => !(item.flags & ts.SymbolFlags.Optional))) report(file, "Scenario entrypoints cannot require caller props. Supply required inputs in the colocated fixture adapter.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
|
|
106
|
+
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.");
|
|
107
|
+
if (!metadata || Object.keys(metadata).some((key) => !["title", "covers", "tokens"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || kind !== "tokens" && !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable) || Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--"))) || kind === "tokens" && !metadata.tokens?.length) {
|
|
108
|
+
report(file, "Export static scenario metadata: { title: string, covers: [public UI names], tokens?: [token names] }. Token scenarios need tokens; their covers may be empty. Literal const fixture lists are supported; runtime expressions are not."); continue;
|
|
109
|
+
}
|
|
110
|
+
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.");
|
|
111
|
+
for (const name of metadata.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 foundation token.` });
|
|
112
|
+
scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], tokens: [...new Set(metadata.tokens ?? [])], file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
113
|
+
}
|
|
114
|
+
return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function inspectScenarioCoverage(options, program, report) {
|
|
118
|
+
const registry = inspectScenarios(options, program);
|
|
119
|
+
for (const item of registry.diagnostics) report(path.join(options.root, item.file), item.rule, item.message, item.line, item.column);
|
|
120
|
+
const checker = program.getTypeChecker();
|
|
121
|
+
const ui = publicUi(program, options);
|
|
122
|
+
function symbolOf(expression) {
|
|
123
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isNonNullExpression(expression))) expression = expression.expression;
|
|
124
|
+
if (!expression) return null;
|
|
125
|
+
if (ts.isElementAccessExpression(expression) && expression.argumentExpression && ts.isStringLiteralLike(expression.argumentExpression)) {
|
|
126
|
+
return checker.getPropertyOfType(checker.getTypeAtLocation(expression.expression), expression.argumentExpression.text);
|
|
127
|
+
}
|
|
128
|
+
return checker.getSymbolAtLocation(expression);
|
|
129
|
+
}
|
|
130
|
+
function canonical(symbol, seen = new Set()) {
|
|
131
|
+
if (!symbol || seen.has(symbol)) return null;
|
|
132
|
+
seen.add(symbol);
|
|
133
|
+
if (symbol.flags & ts.SymbolFlags.Alias) return canonical(checker.getAliasedSymbol(symbol), seen);
|
|
134
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
135
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
136
|
+
const target = symbolOf(declaration.initializer);
|
|
137
|
+
if (target) return canonical(target, seen);
|
|
138
|
+
}
|
|
139
|
+
if (ts.isBindingElement(declaration) && ts.isObjectBindingPattern(declaration.parent) && ts.isVariableDeclaration(declaration.parent.parent)) {
|
|
140
|
+
const initializer = declaration.parent.parent.initializer;
|
|
141
|
+
const key = declaration.propertyName ?? declaration.name;
|
|
142
|
+
if (initializer && (ts.isIdentifier(key) || ts.isStringLiteralLike(key))) {
|
|
143
|
+
const target = checker.getPropertyOfType(checker.getTypeAtLocation(initializer), key.text);
|
|
144
|
+
if (target) return canonical(target, seen);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return symbol.declarations?.length ? symbol : null;
|
|
149
|
+
}
|
|
150
|
+
const identities = new Map();
|
|
151
|
+
for (const item of ui) {
|
|
152
|
+
if (!identities.has(item.declaration)) identities.set(item.declaration, new Set());
|
|
153
|
+
identities.get(item.declaration).add(item.name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const covered = new Set();
|
|
157
|
+
const coveredTokens = new Set();
|
|
158
|
+
const tokenInventory = inspectTokens(options.root);
|
|
159
|
+
const declaredTokens = new Set(tokenInventory.tokens.map(token => token.name));
|
|
160
|
+
for (const scenario of registry.scenarios) {
|
|
161
|
+
const source = program.getSourceFile(path.join(options.root, scenario.file));
|
|
162
|
+
const rendered = new Set();
|
|
163
|
+
const referencedTokens = new Set();
|
|
164
|
+
const visited = new Set();
|
|
165
|
+
function visit(node, reached = false) {
|
|
166
|
+
if (!node || visited.has(node)) return;
|
|
167
|
+
if (!reached && (ts.isFunctionDeclaration(node) || ts.isVariableDeclaration(node))) return;
|
|
168
|
+
if (!reached && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && !ts.isCallExpression(node.parent) && !ts.isJsxExpression(node.parent)) return;
|
|
169
|
+
visited.add(node);
|
|
170
|
+
if (ts.isStringLiteralLike(node) && (isSharedToken(node.text) || scenario.tokens.includes(node.text))) referencedTokens.add(node.text);
|
|
171
|
+
let expression;
|
|
172
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) expression = node.tagName;
|
|
173
|
+
if (ts.isCallExpression(node) && uiOperation(node.expression, checker) === "createElement") expression = node.arguments[0];
|
|
174
|
+
if (expression) {
|
|
175
|
+
const symbol = canonical(symbolOf(expression));
|
|
176
|
+
const names = identities.get(symbol?.valueDeclaration ?? symbol?.declarations?.[0]);
|
|
177
|
+
for (const name of names ?? []) rendered.add(name);
|
|
178
|
+
}
|
|
179
|
+
// Follow local adapters reachable from the default entry. Unused example
|
|
180
|
+
// functions do not satisfy coverage; production implementations are never
|
|
181
|
+
// traversed to manufacture coverage for their internal dependencies.
|
|
182
|
+
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) || ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) || ts.isElementAccessExpression(node.parent))) {
|
|
183
|
+
const symbol = canonical(symbolOf(node));
|
|
184
|
+
const declaration = symbol?.valueDeclaration;
|
|
185
|
+
if (declaration && isDevelopmentFile(declaration.getSourceFile().fileName, options)) visit(declaration, true);
|
|
186
|
+
}
|
|
187
|
+
ts.forEachChild(node, child => visit(child, reached && (ts.isExportAssignment(node) || ts.isVariableDeclaration(node))));
|
|
188
|
+
}
|
|
189
|
+
for (const node of source.statements) if (ts.isExportAssignment(node) || node.modifiers?.some(item => item.kind === ts.SyntaxKind.DefaultKeyword)) visit(node, true);
|
|
190
|
+
for (const name of scenario.covers) {
|
|
191
|
+
if (!rendered.has(name)) report(source.fileName, "scenario-render", `${name} is declared in covers but has no public render reference in this scenario. Render the real API; an import alone does not count.`);
|
|
192
|
+
else covered.add(name);
|
|
193
|
+
}
|
|
194
|
+
for (const name of referencedTokens) if (!declaredTokens.has(name)) report(source.fileName, "token-scenario-reference", `Reachable scenario input references undeclared token ${name}.`);
|
|
195
|
+
for (const name of scenario.tokens) {
|
|
196
|
+
if (!referencedTokens.has(name)) report(source.fileName, "token-scenario-render", `${name} is declared in tokens but has no reachable reference from the default scenario entry. Consume the real token fixture; metadata alone does not count.`);
|
|
197
|
+
else if (declaredTokens.has(name)) coveredTokens.add(name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const item of ui.filter((item) => item.renderable && !covered.has(item.name))) report(item.file, "scenario-coverage", `Public UI ${item.name} needs a colocated, registered scenario render reference. Related parts may share a scenario.`);
|
|
201
|
+
for (const token of tokenInventory.tokens.filter(token => !coveredTokens.has(token.name))) {
|
|
202
|
+
const definition = token.definitions[0];
|
|
203
|
+
report(path.join(options.root, definition.file), "token-scenario-coverage", `Token ${token.name} needs a registered scenario with a reachable token fixture reference.`, definition.line, definition.column);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function writeScenarioRegistry(options, write) {
|
|
208
|
+
const registry = inspectScenarios(options);
|
|
209
|
+
if (registry.diagnostics.length) throw new Error(registry.diagnostics.map((item) => `${item.file}: ${item.message}`).join("\n"));
|
|
210
|
+
write({ schemaVersion: 1, scenarios: registry.scenarios });
|
|
211
|
+
return registry.scenarios;
|
|
212
|
+
}
|