@mapled/cli 0.1.0 → 0.2.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/README.md +91 -11
- package/dist/api.d.ts +4 -0
- package/dist/api.js +22 -5
- package/dist/commands.d.ts +9 -3
- package/dist/commands.js +342 -5
- package/dist/doctor.d.ts +14 -0
- package/dist/doctor.js +48 -0
- package/dist/index.js +28 -2
- package/dist/manifest.d.ts +95 -0
- package/dist/manifest.js +374 -0
- package/dist/pin.d.ts +41 -0
- package/dist/pin.js +297 -0
- package/dist/scan.d.ts +73 -0
- package/dist/scan.js +1295 -0
- package/dist/schema.d.ts +3 -0
- package/dist/types.js +2 -0
- package/package.json +2 -2
package/dist/scan.js
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { targetFor } from "./manifest.js";
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
".git",
|
|
8
|
+
".next",
|
|
9
|
+
".nuxt",
|
|
10
|
+
".svelte-kit",
|
|
11
|
+
".astro",
|
|
12
|
+
".vercel",
|
|
13
|
+
".turbo",
|
|
14
|
+
".cache",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
"out",
|
|
18
|
+
"coverage",
|
|
19
|
+
"storybook-static",
|
|
20
|
+
"public",
|
|
21
|
+
]);
|
|
22
|
+
const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".astro", ".svelte", ".vue"]);
|
|
23
|
+
const TEST_FILE = /\.(test|spec|stories)\.[cm]?[jt]sx?$|(^|\/)__(tests|mocks)__\//;
|
|
24
|
+
export const SDK_METHODS = new Set(["getRecords", "getRecord", "getRecordBySlug", "getSingle"]);
|
|
25
|
+
const APP_METHODS = new Set(["list", "get"]);
|
|
26
|
+
/** The repository's TypeScript, when it has one; the CLI ships none. */
|
|
27
|
+
export function loadTypeScript(dir) {
|
|
28
|
+
try {
|
|
29
|
+
const req = createRequire(path.join(dir, "package.json"));
|
|
30
|
+
return req(req.resolve("typescript"));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function listFiles(dir, max) {
|
|
37
|
+
const files = [];
|
|
38
|
+
let truncated = false;
|
|
39
|
+
const walk = async (rel) => {
|
|
40
|
+
if (files.length >= max) {
|
|
41
|
+
truncated = true;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await readdir(path.join(dir, rel), { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
const next = rel ? `${rel}/${e.name}` : e.name;
|
|
54
|
+
if (e.isDirectory()) {
|
|
55
|
+
if (SKIP_DIRS.has(e.name) || (e.name.startsWith(".") && e.name !== "."))
|
|
56
|
+
continue;
|
|
57
|
+
await walk(next);
|
|
58
|
+
}
|
|
59
|
+
else if (e.isFile()) {
|
|
60
|
+
if (!CODE_EXT.has(path.extname(e.name)) || e.name.endsWith(".d.ts") || TEST_FILE.test(next))
|
|
61
|
+
continue;
|
|
62
|
+
if (files.length >= max) {
|
|
63
|
+
truncated = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
files.push(next);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
await walk("");
|
|
71
|
+
return { files, truncated };
|
|
72
|
+
}
|
|
73
|
+
const PAGE_EXT = /\.(tsx|jsx|ts|js|mjs|mdx|md|astro|svelte|vue)$/;
|
|
74
|
+
/** Next.js App Router segments: groups and slots vanish, params keep
|
|
75
|
+
the [param] form the manifest uses. */
|
|
76
|
+
function appRoute(segments) {
|
|
77
|
+
const kept = [];
|
|
78
|
+
for (const s of segments) {
|
|
79
|
+
if (s.startsWith("_"))
|
|
80
|
+
return null;
|
|
81
|
+
if (s.startsWith("(") && s.endsWith(")"))
|
|
82
|
+
continue;
|
|
83
|
+
if (s.startsWith("@"))
|
|
84
|
+
continue;
|
|
85
|
+
kept.push(s);
|
|
86
|
+
}
|
|
87
|
+
return "/" + kept.join("/");
|
|
88
|
+
}
|
|
89
|
+
/** Pages Router / Astro / Nuxt style: index files name the directory. */
|
|
90
|
+
function fileRoute(segments, file) {
|
|
91
|
+
const name = file.replace(PAGE_EXT, "");
|
|
92
|
+
if (name.startsWith("_"))
|
|
93
|
+
return null;
|
|
94
|
+
const parts = [...segments, ...(name === "index" ? [] : [name])];
|
|
95
|
+
if (parts.some((p) => p.startsWith("_")))
|
|
96
|
+
return null;
|
|
97
|
+
return "/" + parts.join("/");
|
|
98
|
+
}
|
|
99
|
+
/** Remix / React Router flat routes: dots nest, $param → [param]. */
|
|
100
|
+
function remixRoute(file) {
|
|
101
|
+
const name = file.replace(PAGE_EXT, "");
|
|
102
|
+
if (name.startsWith("_") && name !== "_index")
|
|
103
|
+
return null;
|
|
104
|
+
if (name === "_index")
|
|
105
|
+
return "/";
|
|
106
|
+
const parts = name
|
|
107
|
+
.split(".")
|
|
108
|
+
.filter((p) => p !== "_index" && !(p.startsWith("_") && !p.startsWith("$")))
|
|
109
|
+
.map((p) => p.replace(/^\$(.+)$/, "[$1]").replace(/^\$$/, "[...splat]"));
|
|
110
|
+
return "/" + parts.join("/");
|
|
111
|
+
}
|
|
112
|
+
export function detectRoutes(files, framework) {
|
|
113
|
+
const pages = new Map();
|
|
114
|
+
const layouts = new Map();
|
|
115
|
+
const any = !framework;
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const parts = file.split("/");
|
|
118
|
+
const base = parts[parts.length - 1];
|
|
119
|
+
const dirs = parts.slice(0, -1);
|
|
120
|
+
const under = (root) => {
|
|
121
|
+
const i = dirs.indexOf(root);
|
|
122
|
+
if (i === -1 || (i > 0 && !(i === 1 && dirs[0] === "src")))
|
|
123
|
+
return null;
|
|
124
|
+
return dirs.slice(i + 1);
|
|
125
|
+
};
|
|
126
|
+
if (any || framework === "nextjs") {
|
|
127
|
+
const app = under("app");
|
|
128
|
+
if (app && /^page\.(tsx|jsx|ts|js|mdx|md)$/.test(base)) {
|
|
129
|
+
const route = appRoute(app);
|
|
130
|
+
if (route)
|
|
131
|
+
pages.set(file, route);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (app && /^layout\.(tsx|jsx|ts|js)$/.test(base)) {
|
|
135
|
+
const route = appRoute(app);
|
|
136
|
+
if (route)
|
|
137
|
+
layouts.set(file, route);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const pg = under("pages");
|
|
141
|
+
if (pg && !pg.includes("api") && pg[0] !== "api" && PAGE_EXT.test(base) && !/^_/.test(base)) {
|
|
142
|
+
if (dirs.includes("app") && !dirs.includes("pages"))
|
|
143
|
+
continue;
|
|
144
|
+
const route = fileRoute(pg, base);
|
|
145
|
+
if (route)
|
|
146
|
+
pages.set(file, route);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (any || framework === "astro") {
|
|
151
|
+
const i = dirs.indexOf("pages");
|
|
152
|
+
if (dirs[0] === "src" && i === 1 && /\.(astro|md|mdx|ts|js)$/.test(base)) {
|
|
153
|
+
const route = fileRoute(dirs.slice(2), base);
|
|
154
|
+
if (route)
|
|
155
|
+
pages.set(file, route);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (any || framework === "sveltekit") {
|
|
160
|
+
if (dirs[0] === "src" && dirs[1] === "routes" && /^\+page\.(svelte|ts|js|server\.ts|server\.js)$/.test(base)) {
|
|
161
|
+
const route = appRoute(dirs.slice(2));
|
|
162
|
+
if (route)
|
|
163
|
+
pages.set(file, route);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (dirs[0] === "src" && dirs[1] === "routes" && /^\+layout\./.test(base)) {
|
|
167
|
+
const route = appRoute(dirs.slice(2));
|
|
168
|
+
if (route)
|
|
169
|
+
layouts.set(file, route);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (any || framework === "nuxt") {
|
|
174
|
+
const i = dirs.indexOf("pages");
|
|
175
|
+
if ((i === 0 || (i === 1 && dirs[0] === "app")) && base.endsWith(".vue")) {
|
|
176
|
+
const route = fileRoute(dirs.slice(i + 1), base);
|
|
177
|
+
if (route)
|
|
178
|
+
pages.set(file, route);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (any || framework === "remix") {
|
|
183
|
+
if (dirs[0] === "app" && dirs[1] === "routes" && dirs.length === 2 && /\.(tsx|jsx|ts|js)$/.test(base)) {
|
|
184
|
+
const route = remixRoute(base);
|
|
185
|
+
if (route)
|
|
186
|
+
pages.set(file, route);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { pages, layouts };
|
|
192
|
+
}
|
|
193
|
+
function stripJsonComments(text) {
|
|
194
|
+
return text
|
|
195
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
196
|
+
.replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
|
|
197
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
198
|
+
}
|
|
199
|
+
async function readAliases(dir) {
|
|
200
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
201
|
+
let raw;
|
|
202
|
+
try {
|
|
203
|
+
raw = await readFile(path.join(dir, name), "utf8");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const cfg = JSON.parse(stripJsonComments(raw));
|
|
210
|
+
const base = cfg.compilerOptions?.baseUrl ?? ".";
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const [pattern, targets] of Object.entries(cfg.compilerOptions?.paths ?? {})) {
|
|
213
|
+
out.push({
|
|
214
|
+
prefix: pattern.replace(/\*$/, ""),
|
|
215
|
+
targets: targets.map((t) => path.posix.join(base, t.replace(/\*$/, ""))),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
const RESOLVE_EXT = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".astro", ".svelte", ".vue"];
|
|
227
|
+
function resolveImport(spec, fromFile, files, aliases) {
|
|
228
|
+
const candidates = [];
|
|
229
|
+
if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
230
|
+
candidates.push(path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)));
|
|
231
|
+
}
|
|
232
|
+
else if (spec.startsWith("/")) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
for (const a of aliases) {
|
|
237
|
+
if (spec.startsWith(a.prefix)) {
|
|
238
|
+
for (const t of a.targets)
|
|
239
|
+
candidates.push(path.posix.normalize(path.posix.join(t, spec.slice(a.prefix.length))));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (candidates.length === 0 && spec.startsWith("@/")) {
|
|
243
|
+
candidates.push(path.posix.join("src", spec.slice(2)), spec.slice(2));
|
|
244
|
+
}
|
|
245
|
+
if (candidates.length === 0 && spec.startsWith("~/")) {
|
|
246
|
+
candidates.push(path.posix.join("src", spec.slice(2)), spec.slice(2));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const c of candidates) {
|
|
250
|
+
const clean = c.replace(/^\.\//, "");
|
|
251
|
+
if (files.has(clean))
|
|
252
|
+
return clean;
|
|
253
|
+
for (const ext of RESOLVE_EXT) {
|
|
254
|
+
if (files.has(clean + ext))
|
|
255
|
+
return clean + ext;
|
|
256
|
+
if (files.has(`${clean}/index${ext}`))
|
|
257
|
+
return `${clean}/index${ext}`;
|
|
258
|
+
}
|
|
259
|
+
const stripped = clean.replace(/\.(js|jsx|mjs)$/, "");
|
|
260
|
+
if (stripped !== clean) {
|
|
261
|
+
for (const ext of [".ts", ".tsx", ".mts"])
|
|
262
|
+
if (files.has(stripped + ext))
|
|
263
|
+
return stripped + ext;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/* ---- the source of a file as TypeScript ---- */
|
|
269
|
+
/** Script blocks of framework files, so the same parser reads them. */
|
|
270
|
+
function scriptOf(file, text) {
|
|
271
|
+
const ext = path.extname(file);
|
|
272
|
+
if (ext === ".astro") {
|
|
273
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
274
|
+
return m ? m[1] : "";
|
|
275
|
+
}
|
|
276
|
+
if (ext === ".svelte" || ext === ".vue") {
|
|
277
|
+
const blocks = [];
|
|
278
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
|
279
|
+
let m;
|
|
280
|
+
while ((m = re.exec(text)))
|
|
281
|
+
blocks.push(m[1]);
|
|
282
|
+
return blocks.join("\n");
|
|
283
|
+
}
|
|
284
|
+
return text;
|
|
285
|
+
}
|
|
286
|
+
class Analyzer {
|
|
287
|
+
ts;
|
|
288
|
+
operational;
|
|
289
|
+
constructor(ts, schema) {
|
|
290
|
+
this.ts = ts;
|
|
291
|
+
this.operational = new Set(schema.collections.filter((c) => c.mode === "operational").map((c) => c.key));
|
|
292
|
+
}
|
|
293
|
+
analyze(file, text) {
|
|
294
|
+
const ts = this.ts;
|
|
295
|
+
const kind = /\.(tsx|jsx|astro|svelte|vue)$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
296
|
+
const source = ts.createSourceFile(file, scriptOf(file, text), ts.ScriptTarget.Latest, true, kind);
|
|
297
|
+
const a = {
|
|
298
|
+
file,
|
|
299
|
+
imports: new Map(),
|
|
300
|
+
specifiers: [],
|
|
301
|
+
reads: [],
|
|
302
|
+
wrappers: new Map(),
|
|
303
|
+
components: new Map(),
|
|
304
|
+
exports: new Map(),
|
|
305
|
+
source,
|
|
306
|
+
parseFailed: source.parseDiagnostics?.length ? true : false,
|
|
307
|
+
};
|
|
308
|
+
this.collectImports(a);
|
|
309
|
+
this.collectFunctions(a);
|
|
310
|
+
this.collectReads(a);
|
|
311
|
+
return a;
|
|
312
|
+
}
|
|
313
|
+
collectImports(a) {
|
|
314
|
+
const ts = this.ts;
|
|
315
|
+
for (const st of a.source.statements) {
|
|
316
|
+
if (ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
317
|
+
const from = st.moduleSpecifier.text;
|
|
318
|
+
a.specifiers.push(from);
|
|
319
|
+
const clause = st.importClause;
|
|
320
|
+
if (!clause)
|
|
321
|
+
continue;
|
|
322
|
+
if (clause.name)
|
|
323
|
+
a.imports.set(clause.name.text, { from, name: "default" });
|
|
324
|
+
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
325
|
+
for (const el of clause.namedBindings.elements) {
|
|
326
|
+
a.imports.set(el.name.text, { from, name: (el.propertyName ?? el.name).text });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else if (ts.isExportDeclaration(st) && st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
331
|
+
a.specifiers.push(st.moduleSpecifier.text);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const visit = (n) => {
|
|
335
|
+
if (ts.isCallExpression(n)) {
|
|
336
|
+
const callee = n.expression;
|
|
337
|
+
if ((callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === "require")) &&
|
|
338
|
+
n.arguments[0] &&
|
|
339
|
+
ts.isStringLiteralLike(n.arguments[0])) {
|
|
340
|
+
a.specifiers.push(n.arguments[0].text);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
ts.forEachChild(n, visit);
|
|
344
|
+
};
|
|
345
|
+
visit(a.source);
|
|
346
|
+
}
|
|
347
|
+
/** Top-level functions and arrow consts, with their export names. */
|
|
348
|
+
collectFunctions(a) {
|
|
349
|
+
const ts = this.ts;
|
|
350
|
+
const isExported = (n) => (ts.getCombinedModifierFlags(n) & ts.ModifierFlags.Export) !== 0;
|
|
351
|
+
for (const st of a.source.statements) {
|
|
352
|
+
if (ts.isFunctionDeclaration(st) && st.name) {
|
|
353
|
+
a.components.set(st.name.text, st);
|
|
354
|
+
if (isExported(st)) {
|
|
355
|
+
a.exports.set((ts.getCombinedModifierFlags(st) & ts.ModifierFlags.Default) !== 0 ? "default" : st.name.text, st.name.text);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else if (ts.isVariableStatement(st)) {
|
|
359
|
+
for (const d of st.declarationList.declarations) {
|
|
360
|
+
if (!ts.isIdentifier(d.name) || !d.initializer)
|
|
361
|
+
continue;
|
|
362
|
+
const init = unwrap(ts, d.initializer);
|
|
363
|
+
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
|
|
364
|
+
a.components.set(d.name.text, init);
|
|
365
|
+
if (isExported(st))
|
|
366
|
+
a.exports.set(d.name.text, d.name.text);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else if (ts.isExportAssignment(st) && !st.isExportEquals) {
|
|
371
|
+
const expr = unwrap(ts, st.expression);
|
|
372
|
+
if (ts.isIdentifier(expr))
|
|
373
|
+
a.exports.set("default", expr.text);
|
|
374
|
+
else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
|
375
|
+
a.components.set("default", expr);
|
|
376
|
+
a.exports.set("default", "default");
|
|
377
|
+
}
|
|
378
|
+
else if (ts.isCallExpression(expr)) {
|
|
379
|
+
// export default memo(Component) / dynamic(...) — take the first identifier argument
|
|
380
|
+
const inner = expr.arguments.find((x) => ts.isIdentifier(x));
|
|
381
|
+
if (inner && ts.isIdentifier(inner))
|
|
382
|
+
a.exports.set("default", inner.text);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (ts.isExportDeclaration(st) && st.exportClause && ts.isNamedExports(st.exportClause) && !st.moduleSpecifier) {
|
|
386
|
+
for (const el of st.exportClause.elements)
|
|
387
|
+
a.exports.set(el.name.text, (el.propertyName ?? el.name).text);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
collectReads(a) {
|
|
392
|
+
const ts = this.ts;
|
|
393
|
+
const visit = (n) => {
|
|
394
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
|
|
395
|
+
const method = n.expression.name.text;
|
|
396
|
+
const arg = n.arguments[0];
|
|
397
|
+
const literal = arg && ts.isStringLiteralLike(arg) ? arg.text : null;
|
|
398
|
+
const isSdk = SDK_METHODS.has(method);
|
|
399
|
+
const isApp = APP_METHODS.has(method) && literal !== null && this.operational.has(literal);
|
|
400
|
+
if (isSdk || isApp) {
|
|
401
|
+
if (literal === null) {
|
|
402
|
+
a.reads.push(this.dynamicRead(a, method, n));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
a.reads.push(this.traceRead(a, method, literal, n));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
ts.forEachChild(n, visit);
|
|
410
|
+
};
|
|
411
|
+
visit(a.source);
|
|
412
|
+
}
|
|
413
|
+
dynamicRead(a, method, call) {
|
|
414
|
+
const outer = outermostFunction(this.ts, call);
|
|
415
|
+
return {
|
|
416
|
+
method,
|
|
417
|
+
collection: "",
|
|
418
|
+
fields: new Set(),
|
|
419
|
+
fieldSites: new Map(),
|
|
420
|
+
component: functionName(this.ts, outer),
|
|
421
|
+
file: a.file,
|
|
422
|
+
bySlug: false,
|
|
423
|
+
wrapper: null,
|
|
424
|
+
handoffs: [],
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
traceRead(a, method, collection, call) {
|
|
428
|
+
const outer = outermostFunction(this.ts, call);
|
|
429
|
+
const read = {
|
|
430
|
+
method,
|
|
431
|
+
collection,
|
|
432
|
+
fields: new Set(),
|
|
433
|
+
fieldSites: new Map(),
|
|
434
|
+
component: functionName(this.ts, outer),
|
|
435
|
+
file: a.file,
|
|
436
|
+
bySlug: method === "getRecordBySlug",
|
|
437
|
+
wrapper: null,
|
|
438
|
+
handoffs: [],
|
|
439
|
+
};
|
|
440
|
+
const shape = method === "getRecords" || method === "list" ? "list-result" : "record";
|
|
441
|
+
const tracer = new Tracer(this.ts, a, read);
|
|
442
|
+
tracer.traceValue(call, shape);
|
|
443
|
+
// a helper that returns the read hands it to its callers
|
|
444
|
+
const outerName = functionName(this.ts, outer);
|
|
445
|
+
if (outer && outerName && tracer.returned) {
|
|
446
|
+
a.wrappers.set(outerName, { read, shape: tracer.returned });
|
|
447
|
+
read.wrapper = outerName;
|
|
448
|
+
}
|
|
449
|
+
return read;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function enclosingFunction(ts, n) {
|
|
453
|
+
let cur = n.parent;
|
|
454
|
+
while (cur) {
|
|
455
|
+
if (ts.isFunctionDeclaration(cur) || ts.isArrowFunction(cur) || ts.isFunctionExpression(cur) || ts.isMethodDeclaration(cur)) {
|
|
456
|
+
return cur;
|
|
457
|
+
}
|
|
458
|
+
cur = cur.parent;
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
/** The name a function is known by: its own, or the variable it is assigned to. */
|
|
463
|
+
function functionName(ts, fn) {
|
|
464
|
+
if (!fn)
|
|
465
|
+
return null;
|
|
466
|
+
if ((ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isMethodDeclaration(fn)) && fn.name && ts.isIdentifier(fn.name)) {
|
|
467
|
+
return fn.name.text;
|
|
468
|
+
}
|
|
469
|
+
let cur = fn.parent;
|
|
470
|
+
while (cur && (ts.isParenthesizedExpression(cur) || ts.isCallExpression(cur) || ts.isAsExpression(cur) || ts.isSatisfiesExpression(cur))) {
|
|
471
|
+
cur = cur.parent;
|
|
472
|
+
}
|
|
473
|
+
if (cur && ts.isVariableDeclaration(cur) && ts.isIdentifier(cur.name))
|
|
474
|
+
return cur.name.text;
|
|
475
|
+
if (cur && ts.isPropertyAssignment(cur) && ts.isIdentifier(cur.name))
|
|
476
|
+
return cur.name.text;
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
/** The top-level function a node sits in (a page component, a helper). */
|
|
480
|
+
function outermostFunction(ts, n) {
|
|
481
|
+
let fn = enclosingFunction(ts, n);
|
|
482
|
+
let outer = fn;
|
|
483
|
+
while (fn) {
|
|
484
|
+
outer = fn;
|
|
485
|
+
fn = enclosingFunction(ts, fn);
|
|
486
|
+
}
|
|
487
|
+
return outer;
|
|
488
|
+
}
|
|
489
|
+
function unwrap(ts, n) {
|
|
490
|
+
let cur = n;
|
|
491
|
+
for (;;) {
|
|
492
|
+
if (ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur) || ts.isSatisfiesExpression(cur) || ts.isTypeAssertionExpression(cur)) {
|
|
493
|
+
cur = cur.expression;
|
|
494
|
+
}
|
|
495
|
+
else if (ts.isAwaitExpression(cur)) {
|
|
496
|
+
cur = cur.expression;
|
|
497
|
+
}
|
|
498
|
+
else
|
|
499
|
+
return cur;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Follows a value of a known shape through the function it lives in:
|
|
503
|
+
variables, destructuring, `.data.field`, list callbacks, JSX props. */
|
|
504
|
+
class Tracer {
|
|
505
|
+
ts;
|
|
506
|
+
a;
|
|
507
|
+
read;
|
|
508
|
+
returned = null;
|
|
509
|
+
budget = 400;
|
|
510
|
+
constructor(ts, a, read) {
|
|
511
|
+
this.ts = ts;
|
|
512
|
+
this.a = a;
|
|
513
|
+
this.read = read;
|
|
514
|
+
}
|
|
515
|
+
/** `expr` produces a value of `shape`: look at how it is used. */
|
|
516
|
+
traceValue(expr, shape, depth = 0) {
|
|
517
|
+
if (depth > 12 || this.budget-- <= 0)
|
|
518
|
+
return;
|
|
519
|
+
const ts = this.ts;
|
|
520
|
+
let node = expr;
|
|
521
|
+
// climb through await/parens/casts to the expression that uses it
|
|
522
|
+
while (node.parent &&
|
|
523
|
+
(ts.isAwaitExpression(node.parent) ||
|
|
524
|
+
ts.isParenthesizedExpression(node.parent) ||
|
|
525
|
+
ts.isAsExpression(node.parent) ||
|
|
526
|
+
ts.isNonNullExpression(node.parent) ||
|
|
527
|
+
ts.isSatisfiesExpression(node.parent) ||
|
|
528
|
+
ts.isTypeAssertionExpression(node.parent))) {
|
|
529
|
+
node = node.parent;
|
|
530
|
+
}
|
|
531
|
+
const parent = node.parent;
|
|
532
|
+
if (!parent)
|
|
533
|
+
return;
|
|
534
|
+
if (ts.isVariableDeclaration(parent) && parent.initializer && unwrapIs(ts, parent.initializer, node)) {
|
|
535
|
+
this.traceBinding(parent.name, shape, depth + 1);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (ts.isReturnStatement(parent) || (ts.isArrowFunction(parent) && parent.body === node)) {
|
|
539
|
+
this.returned = this.returned ?? shape;
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
|
543
|
+
this.traceAccess(parent, parent.name.text, shape, depth + 1);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (ts.isElementAccessExpression(parent) && parent.expression === node && ts.isStringLiteralLike(parent.argumentExpression)) {
|
|
547
|
+
this.traceAccess(parent, parent.argumentExpression.text, shape, depth + 1);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (ts.isElementAccessExpression(parent) && parent.expression === node && shape === "records") {
|
|
551
|
+
this.traceValue(parent, "record", depth + 1);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (ts.isJsxExpression(parent) && parent.parent && ts.isJsxAttribute(parent.parent)) {
|
|
555
|
+
this.handoff(parent.parent, shape);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (ts.isJsxSpreadAttribute(parent)) {
|
|
559
|
+
this.handoff(parent, shape);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (ts.isSpreadAssignment(parent) && shape === "data") {
|
|
563
|
+
// { ...post.data } — the fields flow into an object we don't follow
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (ts.isBinaryExpression(parent) && (parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) && parent.left === node) {
|
|
567
|
+
this.traceValue(parent, shape, depth + 1);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (ts.isConditionalExpression(parent) && parent.condition !== node) {
|
|
571
|
+
this.traceValue(parent, shape, depth + 1);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (ts.isForOfStatement(parent) && parent.expression === node && shape === "records") {
|
|
575
|
+
if (ts.isVariableDeclarationList(parent.initializer) && parent.initializer.declarations[0]) {
|
|
576
|
+
this.traceBinding(parent.initializer.declarations[0].name, "record", depth + 1);
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (ts.isCallExpression(parent) && parent.expression === node)
|
|
581
|
+
return;
|
|
582
|
+
if (ts.isCallExpression(parent) && parent.arguments.includes(node)) {
|
|
583
|
+
// passed to a local helper: follow its parameter
|
|
584
|
+
const callee = unwrap(ts, parent.expression);
|
|
585
|
+
if (ts.isIdentifier(callee)) {
|
|
586
|
+
const fn = this.a.components.get(callee.text);
|
|
587
|
+
const index = parent.arguments.indexOf(node);
|
|
588
|
+
const param = fn?.parameters[index];
|
|
589
|
+
if (fn && param)
|
|
590
|
+
this.traceBinding(param.name, shape, depth + 1);
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
traceAccess(access, name, shape, depth) {
|
|
596
|
+
switch (shape) {
|
|
597
|
+
case "list-result":
|
|
598
|
+
if (name === "records")
|
|
599
|
+
this.traceValue(access, "records", depth);
|
|
600
|
+
return;
|
|
601
|
+
case "records":
|
|
602
|
+
if (name === "map" || name === "forEach" || name === "filter" || name === "flatMap" || name === "some" || name === "every" || name === "find" || name === "findLast") {
|
|
603
|
+
const call = access.parent;
|
|
604
|
+
if (this.ts.isCallExpression(call) && call.expression === access) {
|
|
605
|
+
const cb = call.arguments[0] ? unwrap(this.ts, call.arguments[0]) : undefined;
|
|
606
|
+
if (cb && (this.ts.isArrowFunction(cb) || this.ts.isFunctionExpression(cb)) && cb.parameters[0]) {
|
|
607
|
+
this.traceBinding(cb.parameters[0].name, "record", depth);
|
|
608
|
+
}
|
|
609
|
+
if ((name === "find" || name === "findLast") && call)
|
|
610
|
+
this.traceValue(call, "record", depth);
|
|
611
|
+
if (name === "filter")
|
|
612
|
+
this.traceValue(call, "records", depth);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (name === "at") {
|
|
616
|
+
const call = access.parent;
|
|
617
|
+
if (this.ts.isCallExpression(call))
|
|
618
|
+
this.traceValue(call, "record", depth);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
case "record":
|
|
622
|
+
if (name === "data")
|
|
623
|
+
this.traceValue(access, "data", depth);
|
|
624
|
+
return;
|
|
625
|
+
case "data":
|
|
626
|
+
this.field(name);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
field(name) {
|
|
631
|
+
this.read.fields.add(name);
|
|
632
|
+
if (!this.read.fieldSites.has(name))
|
|
633
|
+
this.read.fieldSites.set(name, { component: this.read.component, file: this.read.file });
|
|
634
|
+
}
|
|
635
|
+
/** A variable or parameter now holds a value of `shape`. */
|
|
636
|
+
traceBinding(name, shape, depth) {
|
|
637
|
+
const ts = this.ts;
|
|
638
|
+
if (depth > 12 || this.budget-- <= 0)
|
|
639
|
+
return;
|
|
640
|
+
if (ts.isIdentifier(name)) {
|
|
641
|
+
this.traceIdentifier(name, shape, depth);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (ts.isObjectBindingPattern(name)) {
|
|
645
|
+
for (const el of name.elements) {
|
|
646
|
+
if (el.dotDotDotToken)
|
|
647
|
+
continue;
|
|
648
|
+
const prop = el.propertyName ? propName(ts, el.propertyName) : ts.isIdentifier(el.name) ? el.name.text : null;
|
|
649
|
+
if (prop === null)
|
|
650
|
+
continue;
|
|
651
|
+
switch (shape) {
|
|
652
|
+
case "list-result":
|
|
653
|
+
if (prop === "records")
|
|
654
|
+
this.traceBinding(el.name, "records", depth + 1);
|
|
655
|
+
break;
|
|
656
|
+
case "record":
|
|
657
|
+
if (prop === "data")
|
|
658
|
+
this.traceBinding(el.name, "data", depth + 1);
|
|
659
|
+
break;
|
|
660
|
+
case "data":
|
|
661
|
+
this.field(prop);
|
|
662
|
+
break;
|
|
663
|
+
case "records":
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (ts.isArrayBindingPattern(name) && shape === "records") {
|
|
670
|
+
for (const el of name.elements)
|
|
671
|
+
if (ts.isBindingElement(el))
|
|
672
|
+
this.traceBinding(el.name, "record", depth + 1);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
traceIdentifier(id, shape, depth) {
|
|
676
|
+
const ts = this.ts;
|
|
677
|
+
const scope = scopeOf(ts, id);
|
|
678
|
+
const visit = (n) => {
|
|
679
|
+
if (this.budget <= 0)
|
|
680
|
+
return;
|
|
681
|
+
if (ts.isIdentifier(n) && n !== id && n.text === id.text && !isDeclarationName(ts, n) && !isPropertyName(ts, n)) {
|
|
682
|
+
this.traceValue(n, shape, depth + 1);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
ts.forEachChild(n, visit);
|
|
686
|
+
};
|
|
687
|
+
visit(scope);
|
|
688
|
+
}
|
|
689
|
+
handoff(attr, shape) {
|
|
690
|
+
const ts = this.ts;
|
|
691
|
+
const element = attr.parent.parent;
|
|
692
|
+
let tagName = null;
|
|
693
|
+
if (ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element))
|
|
694
|
+
tagName = element.tagName;
|
|
695
|
+
if (!tagName || !ts.isIdentifier(tagName))
|
|
696
|
+
return;
|
|
697
|
+
const first = tagName.text.charAt(0);
|
|
698
|
+
if (first === first.toLowerCase())
|
|
699
|
+
return; // an HTML element: nothing to trace into
|
|
700
|
+
const prop = ts.isJsxAttribute(attr) ? propName(ts, attr.name) : null;
|
|
701
|
+
this.read.handoffs.push({ component: tagName.text, prop, shape });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function unwrapIs(ts, expr, node) {
|
|
705
|
+
let cur = expr;
|
|
706
|
+
for (;;) {
|
|
707
|
+
if (cur === node)
|
|
708
|
+
return true;
|
|
709
|
+
if (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur) || ts.isSatisfiesExpression(cur) || ts.isTypeAssertionExpression(cur)) {
|
|
710
|
+
cur = cur.expression;
|
|
711
|
+
}
|
|
712
|
+
else
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function propName(ts, n) {
|
|
717
|
+
if (ts.isIdentifier(n) || ts.isPrivateIdentifier(n))
|
|
718
|
+
return n.text;
|
|
719
|
+
if (ts.isStringLiteralLike(n) || ts.isNumericLiteral(n))
|
|
720
|
+
return n.text;
|
|
721
|
+
if (ts.isJsxNamespacedName(n))
|
|
722
|
+
return n.name.text;
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
function isDeclarationName(ts, id) {
|
|
726
|
+
const p = id.parent;
|
|
727
|
+
return ((ts.isVariableDeclaration(p) && p.name === id) ||
|
|
728
|
+
(ts.isParameter(p) && p.name === id) ||
|
|
729
|
+
(ts.isBindingElement(p) && p.name === id) ||
|
|
730
|
+
(ts.isFunctionDeclaration(p) && p.name === id) ||
|
|
731
|
+
ts.isImportSpecifier(p) ||
|
|
732
|
+
ts.isImportClause(p));
|
|
733
|
+
}
|
|
734
|
+
function isPropertyName(ts, id) {
|
|
735
|
+
const p = id.parent;
|
|
736
|
+
return ((ts.isPropertyAccessExpression(p) && p.name === id) ||
|
|
737
|
+
(ts.isPropertyAssignment(p) && p.name === id) ||
|
|
738
|
+
(ts.isBindingElement(p) && p.propertyName === id) ||
|
|
739
|
+
(ts.isJsxAttribute(p) && p.name === id) ||
|
|
740
|
+
(ts.isPropertySignature(p) && p.name === id));
|
|
741
|
+
}
|
|
742
|
+
/** The function body (or the file) an identifier is visible in. */
|
|
743
|
+
function scopeOf(ts, id) {
|
|
744
|
+
let cur = id.parent;
|
|
745
|
+
while (cur) {
|
|
746
|
+
if (ts.isFunctionDeclaration(cur) || ts.isArrowFunction(cur) || ts.isFunctionExpression(cur) || ts.isMethodDeclaration(cur)) {
|
|
747
|
+
// a parameter or a variable of this function is visible in its body
|
|
748
|
+
return cur.body ?? cur;
|
|
749
|
+
}
|
|
750
|
+
if (ts.isSourceFile(cur))
|
|
751
|
+
return cur;
|
|
752
|
+
cur = cur.parent;
|
|
753
|
+
}
|
|
754
|
+
return id.getSourceFile();
|
|
755
|
+
}
|
|
756
|
+
/** JavaScript tokens: identifiers, string literals and punctuation, with
|
|
757
|
+
comments and template bodies dropped. A regex literal after an
|
|
758
|
+
operator is skipped; JSX text is read as code, so an apostrophe in
|
|
759
|
+
prose can swallow a stretch — a false negative, never a crash. */
|
|
760
|
+
export function tokenize(text) {
|
|
761
|
+
const out = [];
|
|
762
|
+
let i = 0;
|
|
763
|
+
const n = text.length;
|
|
764
|
+
const REGEX_BEFORE = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "return", "typeof", "=>"]);
|
|
765
|
+
while (i < n) {
|
|
766
|
+
const c = text[i];
|
|
767
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
768
|
+
i++;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
772
|
+
while (i < n && text[i] !== "\n")
|
|
773
|
+
i++;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
777
|
+
const end = text.indexOf("*/", i + 2);
|
|
778
|
+
i = end === -1 ? n : end + 2;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
782
|
+
let j = i + 1;
|
|
783
|
+
let value = "";
|
|
784
|
+
while (j < n && text[j] !== c) {
|
|
785
|
+
if (text[j] === "\\") {
|
|
786
|
+
value += text[j + 1] ?? "";
|
|
787
|
+
j += 2;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (c === "`" && text[j] === "$" && text[j + 1] === "{") {
|
|
791
|
+
// a substitution: skip to its closing brace (one level)
|
|
792
|
+
let depth = 1;
|
|
793
|
+
j += 2;
|
|
794
|
+
while (j < n && depth > 0) {
|
|
795
|
+
if (text[j] === "{")
|
|
796
|
+
depth++;
|
|
797
|
+
else if (text[j] === "}")
|
|
798
|
+
depth--;
|
|
799
|
+
j++;
|
|
800
|
+
}
|
|
801
|
+
value += "${}";
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (c !== "`" && text[j] === "\n")
|
|
805
|
+
break;
|
|
806
|
+
value += text[j];
|
|
807
|
+
j++;
|
|
808
|
+
}
|
|
809
|
+
out.push({ kind: "string", value: c === "`" && value.includes("${}") ? "${}" : value });
|
|
810
|
+
i = j + 1;
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
814
|
+
let j = i + 1;
|
|
815
|
+
while (j < n && /[A-Za-z0-9_$]/.test(text[j]))
|
|
816
|
+
j++;
|
|
817
|
+
out.push({ kind: "ident", value: text.slice(i, j) });
|
|
818
|
+
i = j;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (c === "/") {
|
|
822
|
+
const prev = out[out.length - 1];
|
|
823
|
+
if (!prev || (prev.kind === "punct" && REGEX_BEFORE.has(prev.value)) || (prev.kind === "ident" && REGEX_BEFORE.has(prev.value))) {
|
|
824
|
+
let j = i + 1;
|
|
825
|
+
let inClass = false;
|
|
826
|
+
while (j < n && (inClass || text[j] !== "/") && text[j] !== "\n") {
|
|
827
|
+
if (text[j] === "\\")
|
|
828
|
+
j++;
|
|
829
|
+
else if (text[j] === "[")
|
|
830
|
+
inClass = true;
|
|
831
|
+
else if (text[j] === "]")
|
|
832
|
+
inClass = false;
|
|
833
|
+
j++;
|
|
834
|
+
}
|
|
835
|
+
j++;
|
|
836
|
+
while (j < n && /[a-z]/.test(text[j]))
|
|
837
|
+
j++; // flags
|
|
838
|
+
i = j;
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (c === "=" && text[i + 1] === ">") {
|
|
843
|
+
out.push({ kind: "punct", value: "=>" });
|
|
844
|
+
i += 2;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
out.push({ kind: "punct", value: c });
|
|
848
|
+
i++;
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
function analyzeTokens(file, text, operational) {
|
|
853
|
+
const tokens = tokenize(scriptOf(file, text));
|
|
854
|
+
const specifiers = [];
|
|
855
|
+
const reads = [];
|
|
856
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
857
|
+
const t = tokens[i];
|
|
858
|
+
if (t.kind === "ident" && (t.value === "from" || t.value === "import" || t.value === "require")) {
|
|
859
|
+
const next = tokens[i + 1];
|
|
860
|
+
const after = tokens[i + 2];
|
|
861
|
+
if (next?.kind === "string")
|
|
862
|
+
specifiers.push(next.value);
|
|
863
|
+
else if (next?.kind === "punct" && next.value === "(" && after?.kind === "string")
|
|
864
|
+
specifiers.push(after.value);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (t.kind === "punct" && t.value === "." && tokens[i + 1]?.kind === "ident" && tokens[i + 2]?.kind === "punct" && tokens[i + 2].value === "(") {
|
|
868
|
+
const method = tokens[i + 1].value;
|
|
869
|
+
const arg = tokens[i + 3];
|
|
870
|
+
const isSdk = SDK_METHODS.has(method);
|
|
871
|
+
const literal = arg?.kind === "string" && arg.value !== "${}" ? arg.value : null;
|
|
872
|
+
const isApp = APP_METHODS.has(method) && literal !== null && operational.has(literal);
|
|
873
|
+
if (!isSdk && !isApp)
|
|
874
|
+
continue;
|
|
875
|
+
reads.push({
|
|
876
|
+
method,
|
|
877
|
+
collection: literal ?? "",
|
|
878
|
+
fields: new Set(),
|
|
879
|
+
fieldSites: new Map(),
|
|
880
|
+
component: null,
|
|
881
|
+
file,
|
|
882
|
+
bySlug: method === "getRecordBySlug",
|
|
883
|
+
wrapper: null,
|
|
884
|
+
handoffs: [],
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return { file, specifiers, reads };
|
|
889
|
+
}
|
|
890
|
+
/* ---- putting it together ---- */
|
|
891
|
+
export function routeKey(route) {
|
|
892
|
+
const stripped = route.replace(/^\/+/, "");
|
|
893
|
+
const key = stripped === "" ? "index" : stripped;
|
|
894
|
+
return /^[A-Za-z0-9]/.test(key) ? key : `p${key}`;
|
|
895
|
+
}
|
|
896
|
+
const isSitePage = (route) => route.startsWith("/");
|
|
897
|
+
export async function scanRepository(dir, opts) {
|
|
898
|
+
const notes = [];
|
|
899
|
+
const max = opts.maxFiles ?? 4000;
|
|
900
|
+
const { files, truncated } = await listFiles(dir, max);
|
|
901
|
+
if (truncated)
|
|
902
|
+
notes.push(`Only the first ${max} source files were read.`);
|
|
903
|
+
const fileSet = new Set(files);
|
|
904
|
+
const aliases = await readAliases(dir);
|
|
905
|
+
const framework = opts.framework ?? null;
|
|
906
|
+
const routes = detectRoutes(files, framework);
|
|
907
|
+
const schemaIndex = new Map(opts.schema.collections.map((c) => [c.key, c]));
|
|
908
|
+
const operational = new Set(opts.schema.collections.filter((c) => c.mode === "operational").map((c) => c.key));
|
|
909
|
+
// parse
|
|
910
|
+
const analyses = new Map();
|
|
911
|
+
const tokenAnalyses = new Map();
|
|
912
|
+
const importsOf = new Map();
|
|
913
|
+
let parseFailures = 0;
|
|
914
|
+
const analyzer = opts.ts ? new Analyzer(opts.ts, opts.schema) : null;
|
|
915
|
+
for (const file of files) {
|
|
916
|
+
let text;
|
|
917
|
+
try {
|
|
918
|
+
text = await readFile(path.join(dir, file), "utf8");
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (text.length > 2_000_000)
|
|
924
|
+
continue;
|
|
925
|
+
let specifiers;
|
|
926
|
+
if (analyzer) {
|
|
927
|
+
const a = analyzer.analyze(file, text);
|
|
928
|
+
if (a.parseFailed)
|
|
929
|
+
parseFailures++;
|
|
930
|
+
analyses.set(file, a);
|
|
931
|
+
specifiers = a.specifiers;
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
const a = analyzeTokens(file, text, operational);
|
|
935
|
+
tokenAnalyses.set(file, a);
|
|
936
|
+
specifiers = a.specifiers;
|
|
937
|
+
}
|
|
938
|
+
const resolved = [];
|
|
939
|
+
for (const spec of specifiers) {
|
|
940
|
+
const target = resolveImport(spec, file, fileSet, aliases);
|
|
941
|
+
if (target && target !== file)
|
|
942
|
+
resolved.push(target);
|
|
943
|
+
}
|
|
944
|
+
importsOf.set(file, [...new Set(resolved)]);
|
|
945
|
+
}
|
|
946
|
+
if (parseFailures > 0)
|
|
947
|
+
notes.push(`${parseFailures} file${parseFailures === 1 ? "" : "s"} had syntax errors and may be incomplete.`);
|
|
948
|
+
// pages that reach a file through imports
|
|
949
|
+
const importers = new Map();
|
|
950
|
+
for (const [file, targets] of importsOf) {
|
|
951
|
+
for (const t of targets) {
|
|
952
|
+
if (!importers.has(t))
|
|
953
|
+
importers.set(t, []);
|
|
954
|
+
importers.get(t).push(file);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const pagesOfCache = new Map();
|
|
958
|
+
const routesUnder = (prefix) => {
|
|
959
|
+
const all = [...routes.pages.values()].filter((r) => r === prefix || r.startsWith(prefix === "/" ? "/" : `${prefix}/`));
|
|
960
|
+
return all.length > 0 ? all : [prefix];
|
|
961
|
+
};
|
|
962
|
+
const pagesOf = (file) => {
|
|
963
|
+
const cached = pagesOfCache.get(file);
|
|
964
|
+
if (cached)
|
|
965
|
+
return cached;
|
|
966
|
+
const out = new Set();
|
|
967
|
+
const seen = new Set();
|
|
968
|
+
const queue = [[file, 0]];
|
|
969
|
+
while (queue.length > 0) {
|
|
970
|
+
const [f, depth] = queue.shift();
|
|
971
|
+
if (seen.has(f))
|
|
972
|
+
continue;
|
|
973
|
+
seen.add(f);
|
|
974
|
+
const page = routes.pages.get(f);
|
|
975
|
+
if (page)
|
|
976
|
+
out.add(page);
|
|
977
|
+
const layout = routes.layouts.get(f);
|
|
978
|
+
if (layout)
|
|
979
|
+
for (const r of routesUnder(layout))
|
|
980
|
+
out.add(r);
|
|
981
|
+
if (page || layout || depth >= 8)
|
|
982
|
+
continue;
|
|
983
|
+
for (const importer of importers.get(f) ?? [])
|
|
984
|
+
queue.push([importer, depth + 1]);
|
|
985
|
+
}
|
|
986
|
+
pagesOfCache.set(file, out);
|
|
987
|
+
return out;
|
|
988
|
+
};
|
|
989
|
+
// resolve JSX handoffs into components (one file hop at a time)
|
|
990
|
+
const resolveComponent = (a, name) => {
|
|
991
|
+
const imp = a.imports.get(name);
|
|
992
|
+
if (!imp) {
|
|
993
|
+
const local = a.components.get(name);
|
|
994
|
+
return local ? { file: a.file, analysis: a, fn: local } : null;
|
|
995
|
+
}
|
|
996
|
+
const target = resolveImport(imp.from, a.file, fileSet, aliases);
|
|
997
|
+
const analysis = target ? analyses.get(target) : undefined;
|
|
998
|
+
if (!analysis)
|
|
999
|
+
return null;
|
|
1000
|
+
const localName = analysis.exports.get(imp.name);
|
|
1001
|
+
const fn = localName ? analysis.components.get(localName) : undefined;
|
|
1002
|
+
return fn ? { file: analysis.file, analysis, fn } : null;
|
|
1003
|
+
};
|
|
1004
|
+
const unresolved = [];
|
|
1005
|
+
const looksLocal = (spec) => spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("@/") || spec.startsWith("~/") || aliases.some((al) => spec.startsWith(al.prefix));
|
|
1006
|
+
const followHandoffs = (read, a, depth, visited) => {
|
|
1007
|
+
if (!opts.ts || depth > 4)
|
|
1008
|
+
return;
|
|
1009
|
+
for (const h of read.handoffs.splice(0)) {
|
|
1010
|
+
const target = resolveComponent(a, h.component);
|
|
1011
|
+
if (!target) {
|
|
1012
|
+
const imp = a.imports.get(h.component);
|
|
1013
|
+
if (imp && looksLocal(imp.from))
|
|
1014
|
+
unresolved.push(`${h.component} in ${a.file} (import "${imp.from}" not found)`);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const stamp = `${target.file}#${h.component}#${h.prop ?? "*"}`;
|
|
1018
|
+
if (visited.has(stamp))
|
|
1019
|
+
continue;
|
|
1020
|
+
visited.add(stamp);
|
|
1021
|
+
const param = target.fn.parameters[0];
|
|
1022
|
+
if (!param)
|
|
1023
|
+
continue;
|
|
1024
|
+
const sub = { ...read, component: h.component, file: target.file, handoffs: [] };
|
|
1025
|
+
const tracer = new Tracer(opts.ts, target.analysis, sub);
|
|
1026
|
+
const ts = opts.ts;
|
|
1027
|
+
if (h.prop === null) {
|
|
1028
|
+
// {...record} or {...record.data}: the props are the record (or its fields)
|
|
1029
|
+
tracer.traceBinding(param.name, h.shape, 0);
|
|
1030
|
+
}
|
|
1031
|
+
else if (ts.isObjectBindingPattern(param.name)) {
|
|
1032
|
+
for (const el of param.name.elements) {
|
|
1033
|
+
const prop = el.propertyName ? propName(ts, el.propertyName) : ts.isIdentifier(el.name) ? el.name.text : null;
|
|
1034
|
+
if (prop === h.prop)
|
|
1035
|
+
tracer.traceBinding(el.name, h.shape, 0);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
else if (ts.isIdentifier(param.name)) {
|
|
1039
|
+
// props.post.data.title
|
|
1040
|
+
const propsName = param.name.text;
|
|
1041
|
+
const body = target.fn.body;
|
|
1042
|
+
if (body) {
|
|
1043
|
+
const visit = (n) => {
|
|
1044
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === propsName && n.name.text === h.prop) {
|
|
1045
|
+
tracer.traceValue(n, h.shape, 0);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
ts.forEachChild(n, visit);
|
|
1049
|
+
};
|
|
1050
|
+
visit(body);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
for (const f of sub.fields) {
|
|
1054
|
+
read.fields.add(f);
|
|
1055
|
+
if (!read.fieldSites.has(f))
|
|
1056
|
+
read.fieldSites.set(f, sub.fieldSites.get(f) ?? { component: h.component, file: target.file });
|
|
1057
|
+
}
|
|
1058
|
+
followHandoffs(sub, target.analysis, depth + 1, visited);
|
|
1059
|
+
for (const f of sub.fields) {
|
|
1060
|
+
read.fields.add(f);
|
|
1061
|
+
if (!read.fieldSites.has(f))
|
|
1062
|
+
read.fieldSites.set(f, sub.fieldSites.get(f) ?? { component: h.component, file: target.file });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
// wrapper calls: a call to an imported (or local) helper that returns a read is a read here
|
|
1067
|
+
const wrapperReads = [];
|
|
1068
|
+
const usedWrappers = new Set();
|
|
1069
|
+
if (opts.ts) {
|
|
1070
|
+
const ts = opts.ts;
|
|
1071
|
+
for (const a of analyses.values()) {
|
|
1072
|
+
const visit = (n) => {
|
|
1073
|
+
if (ts.isCallExpression(n)) {
|
|
1074
|
+
const callee = unwrap(ts, n.expression);
|
|
1075
|
+
if (ts.isIdentifier(callee)) {
|
|
1076
|
+
let info;
|
|
1077
|
+
let origin;
|
|
1078
|
+
const imp = a.imports.get(callee.text);
|
|
1079
|
+
let wrapperId = null;
|
|
1080
|
+
if (imp) {
|
|
1081
|
+
const target = resolveImport(imp.from, a.file, fileSet, aliases);
|
|
1082
|
+
origin = target ? analyses.get(target) : undefined;
|
|
1083
|
+
const localName = origin?.exports.get(imp.name);
|
|
1084
|
+
info = localName ? origin?.wrappers.get(localName) : undefined;
|
|
1085
|
+
if (info && origin && localName)
|
|
1086
|
+
wrapperId = `${origin.file}#${localName}`;
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
const local = a.wrappers.get(callee.text);
|
|
1090
|
+
if (local && local.read.wrapper !== null) {
|
|
1091
|
+
// a call to the helper from another function of the same file
|
|
1092
|
+
const outerName = functionName(ts, outermostFunction(ts, n));
|
|
1093
|
+
if (outerName !== callee.text) {
|
|
1094
|
+
info = local;
|
|
1095
|
+
origin = a;
|
|
1096
|
+
wrapperId = `${a.file}#${callee.text}`;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (info && origin) {
|
|
1101
|
+
if (wrapperId)
|
|
1102
|
+
usedWrappers.add(wrapperId);
|
|
1103
|
+
const outer = outermostFunction(ts, n);
|
|
1104
|
+
const read = {
|
|
1105
|
+
method: info.read.method,
|
|
1106
|
+
collection: info.read.collection,
|
|
1107
|
+
fields: new Set(info.read.fields),
|
|
1108
|
+
fieldSites: new Map(info.read.fieldSites),
|
|
1109
|
+
component: functionName(ts, outer),
|
|
1110
|
+
file: a.file,
|
|
1111
|
+
bySlug: info.read.bySlug,
|
|
1112
|
+
wrapper: null,
|
|
1113
|
+
handoffs: [],
|
|
1114
|
+
};
|
|
1115
|
+
const tracer = new Tracer(ts, a, read);
|
|
1116
|
+
tracer.traceValue(n, info.shape, 0);
|
|
1117
|
+
if (tracer.returned && outer) {
|
|
1118
|
+
const name = functionName(ts, outer);
|
|
1119
|
+
if (name) {
|
|
1120
|
+
a.wrappers.set(name, { read, shape: tracer.returned });
|
|
1121
|
+
read.wrapper = name;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
followHandoffs(read, a, 0, new Set());
|
|
1125
|
+
wrapperReads.push(read);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
ts.forEachChild(n, visit);
|
|
1130
|
+
};
|
|
1131
|
+
visit(a.source);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
const placed = [];
|
|
1135
|
+
let dynamic = 0;
|
|
1136
|
+
let unreached = [];
|
|
1137
|
+
const allReads = [];
|
|
1138
|
+
for (const a of analyses.values()) {
|
|
1139
|
+
for (const r of a.reads) {
|
|
1140
|
+
if (r.collection === "") {
|
|
1141
|
+
dynamic++;
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
followHandoffs(r, a, 0, new Set());
|
|
1145
|
+
allReads.push(r);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
for (const a of tokenAnalyses.values()) {
|
|
1149
|
+
for (const r of a.reads) {
|
|
1150
|
+
if (r.collection === "") {
|
|
1151
|
+
dynamic++;
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
allReads.push(r);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
allReads.push(...wrapperReads);
|
|
1158
|
+
for (const r of allReads) {
|
|
1159
|
+
if (r.wrapper !== null) {
|
|
1160
|
+
// reached through its callers; a helper nobody calls is noted
|
|
1161
|
+
if (!usedWrappers.has(`${r.file}#${r.wrapper}`))
|
|
1162
|
+
unreached.push(`${r.wrapper} (${r.file})`);
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
const pages = [...pagesOf(r.file)].filter(isSitePage).sort();
|
|
1166
|
+
if (pages.length === 0) {
|
|
1167
|
+
unreached.push(r.component ? `${r.component} (${r.file})` : r.file);
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
placed.push({ read: r, pages });
|
|
1171
|
+
}
|
|
1172
|
+
if (dynamic > 0)
|
|
1173
|
+
notes.push(`${dynamic} read${dynamic === 1 ? "" : "s"} with a computed collection name ${dynamic === 1 ? "was" : "were"} skipped.`);
|
|
1174
|
+
const missing = [...new Set(unresolved)];
|
|
1175
|
+
if (missing.length > 0) {
|
|
1176
|
+
notes.push(`Couldn't follow ${missing.length === 1 ? "a component prop" : `${missing.length} component props`}: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ", …" : ""}.`);
|
|
1177
|
+
}
|
|
1178
|
+
unreached = [...new Set(unreached)];
|
|
1179
|
+
if (unreached.length > 0) {
|
|
1180
|
+
notes.push(`No page reaches ${unreached.length === 1 ? "this read" : "these reads"}: ${unreached.slice(0, 5).join(", ")}${unreached.length > 5 ? ", …" : ""}.`);
|
|
1181
|
+
}
|
|
1182
|
+
// bindings
|
|
1183
|
+
const bindings = new Map();
|
|
1184
|
+
const add = (b) => {
|
|
1185
|
+
if (!bindings.has(b.key))
|
|
1186
|
+
bindings.set(b.key, b);
|
|
1187
|
+
};
|
|
1188
|
+
const relFile = (f) => f;
|
|
1189
|
+
for (const { read, pages } of placed) {
|
|
1190
|
+
const collection = schemaIndex.get(read.collection);
|
|
1191
|
+
const fieldTypes = new Map((collection?.fields ?? []).map((f) => [f.key, f.type]));
|
|
1192
|
+
const slugField = collection?.fields.find((f) => f.type === "slug")?.key;
|
|
1193
|
+
for (const page of pages) {
|
|
1194
|
+
const rk = routeKey(page);
|
|
1195
|
+
const isList = read.method === "getRecords" || read.method === "list";
|
|
1196
|
+
if (isList || read.fields.size === 0) {
|
|
1197
|
+
add({
|
|
1198
|
+
key: `${rk}:${read.collection}`,
|
|
1199
|
+
page,
|
|
1200
|
+
...(read.component ? { component: read.component } : {}),
|
|
1201
|
+
file: relFile(read.file),
|
|
1202
|
+
collection: read.collection,
|
|
1203
|
+
target: "collection",
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
if (read.bySlug && /\[[^\]]+\]/.test(page)) {
|
|
1207
|
+
add({
|
|
1208
|
+
key: `${rk}:${read.collection}[route]`,
|
|
1209
|
+
page,
|
|
1210
|
+
...(read.component ? { component: read.component } : {}),
|
|
1211
|
+
file: relFile(read.file),
|
|
1212
|
+
collection: read.collection,
|
|
1213
|
+
...(slugField ? { field: slugField } : {}),
|
|
1214
|
+
target: "route_param",
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
for (const field of [...read.fields].sort()) {
|
|
1218
|
+
const site = read.fieldSites.get(field) ?? { component: read.component, file: read.file };
|
|
1219
|
+
add({
|
|
1220
|
+
key: `${rk}:${read.collection}.${field}`,
|
|
1221
|
+
page,
|
|
1222
|
+
...(site.component ? { component: site.component } : {}),
|
|
1223
|
+
file: relFile(site.file),
|
|
1224
|
+
collection: read.collection,
|
|
1225
|
+
field,
|
|
1226
|
+
target: targetFor(fieldTypes.get(field)),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const pages = [...routes.pages.entries()]
|
|
1232
|
+
.map(([file, route]) => ({ route, file }))
|
|
1233
|
+
.sort((a, b) => (a.route < b.route ? -1 : a.route > b.route ? 1 : a.file < b.file ? -1 : 1));
|
|
1234
|
+
// one entry per route: the first file wins (route groups can collide)
|
|
1235
|
+
const seenRoutes = new Set();
|
|
1236
|
+
const uniquePages = pages.filter((p) => (seenRoutes.has(p.route) ? false : (seenRoutes.add(p.route), true)));
|
|
1237
|
+
const sortedBindings = [...bindings.values()].sort((a, b) => a.page < b.page ? -1 : a.page > b.page ? 1 : a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
1238
|
+
if (!opts.ts) {
|
|
1239
|
+
notes.push("TypeScript isn't installed in this repository, so fields weren't traced (collections only) — npm install -D typescript and scan again.");
|
|
1240
|
+
}
|
|
1241
|
+
return {
|
|
1242
|
+
framework,
|
|
1243
|
+
parser: opts.ts ? "typescript" : "tokens",
|
|
1244
|
+
files: files.length,
|
|
1245
|
+
pages: uniquePages,
|
|
1246
|
+
bindings: sortedBindings,
|
|
1247
|
+
notes,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
/** The scan's bindings replace the ones with the same key; bindings the
|
|
1251
|
+
scanner can't see (an agent's, hand-written) stay unless pruned. */
|
|
1252
|
+
export function mergeManifest(existing, scan, opts) {
|
|
1253
|
+
const scanned = new Map(scan.bindings.map((b) => [b.key, b]));
|
|
1254
|
+
const added = [];
|
|
1255
|
+
const changed = [];
|
|
1256
|
+
const unchanged = [];
|
|
1257
|
+
const kept = [];
|
|
1258
|
+
const dropped = [];
|
|
1259
|
+
const out = [];
|
|
1260
|
+
const existingByKey = new Map((existing?.bindings ?? []).map((b) => [b.key, b]));
|
|
1261
|
+
for (const b of existing?.bindings ?? []) {
|
|
1262
|
+
const s = scanned.get(b.key);
|
|
1263
|
+
if (s) {
|
|
1264
|
+
const same = s.page === b.page &&
|
|
1265
|
+
s.collection === b.collection &&
|
|
1266
|
+
(s.field ?? null) === (b.field ?? null) &&
|
|
1267
|
+
s.target === b.target &&
|
|
1268
|
+
(s.component ?? null) === (b.component ?? null) &&
|
|
1269
|
+
(s.file ?? null) === (b.file ?? null);
|
|
1270
|
+
(same ? unchanged : changed).push(b.key);
|
|
1271
|
+
out.push({ ...s, ...(b.required !== undefined ? { required: b.required } : {}) });
|
|
1272
|
+
}
|
|
1273
|
+
else if (opts.prune)
|
|
1274
|
+
dropped.push(b.key);
|
|
1275
|
+
else {
|
|
1276
|
+
kept.push(b.key);
|
|
1277
|
+
out.push(b);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
for (const s of scan.bindings) {
|
|
1281
|
+
if (!existingByKey.has(s.key)) {
|
|
1282
|
+
added.push(s.key);
|
|
1283
|
+
out.push(s);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
out.sort((a, b) => (a.page < b.page ? -1 : a.page > b.page ? 1 : a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
1287
|
+
const manifest = {
|
|
1288
|
+
...(scan.framework ? { framework: scan.framework } : existing?.framework ? { framework: existing.framework } : {}),
|
|
1289
|
+
...(existing?.integrationMode ? { integrationMode: existing.integrationMode } : {}),
|
|
1290
|
+
pages: scan.pages,
|
|
1291
|
+
bindings: out,
|
|
1292
|
+
...(existing?.notes && existing.notes.length > 0 ? { notes: existing.notes } : {}),
|
|
1293
|
+
};
|
|
1294
|
+
return { manifest, added, changed, unchanged, kept, dropped };
|
|
1295
|
+
}
|