@decocms/blocks-cli 7.0.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/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-migration cleanup audit — rule implementations.
|
|
3
|
+
*
|
|
4
|
+
* Each rule mirrors a section in
|
|
5
|
+
* `.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md`.
|
|
6
|
+
* The intent is to take the human checklist and make it programmatically
|
|
7
|
+
* detectable so future migrations get the same scrubbing automatically.
|
|
8
|
+
*
|
|
9
|
+
* Rules are intentionally read-only here — `--fix` is a follow-up.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { analyzeFile as analyzeHtmxFile } from "../analyzers/htmx-analyze";
|
|
13
|
+
import { classifyShimExports, type ExportClass } from "./shim-classify";
|
|
14
|
+
import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
|
|
15
|
+
|
|
16
|
+
const SRC_GLOB_EXCLUDES = ["node_modules", "dist", ".wrangler", ".vite", ".tanstack", "build"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rewrite all `from "<oldSpec>"` (or `from '<oldSpec>'`) imports in
|
|
20
|
+
* `src/**` to `from "<newSpec>"`. Returns the list of site-relative
|
|
21
|
+
* paths actually changed so fix-action summaries can quote a count.
|
|
22
|
+
* Uses the write side of the FS adapter — never touches disk in unit
|
|
23
|
+
* tests.
|
|
24
|
+
*
|
|
25
|
+
* Intentionally string-anchored on the exact spec; will not pick up
|
|
26
|
+
* partial-prefix matches like `~/types/widgets-extra`.
|
|
27
|
+
*/
|
|
28
|
+
function rewriteImportSpec(
|
|
29
|
+
ctx: RuleContext,
|
|
30
|
+
writer: FsWriter,
|
|
31
|
+
oldSpec: string,
|
|
32
|
+
newSpec: string,
|
|
33
|
+
): string[] {
|
|
34
|
+
const { siteDir, fs } = ctx;
|
|
35
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
36
|
+
const escaped = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
const re = new RegExp(`from\\s+(['"])${escaped}\\1`, "g");
|
|
38
|
+
const updated: string[] = [];
|
|
39
|
+
for (const abs of tsFiles) {
|
|
40
|
+
const content = fs.readText(abs);
|
|
41
|
+
if (!re.test(content)) {
|
|
42
|
+
re.lastIndex = 0;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
re.lastIndex = 0;
|
|
46
|
+
const next = content.replace(re, (_m, q) => `from ${q}${newSpec}${q}`);
|
|
47
|
+
if (next !== content) {
|
|
48
|
+
writer.writeText(abs, next);
|
|
49
|
+
updated.push(abs.slice(siteDir.length + 1));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return updated;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* Rule 1 — dead `src/lib/*` shims */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
|
|
59
|
+
const EXPORT_RE = /^export\s+(?:function|const|interface|type|class)\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
|
|
60
|
+
|
|
61
|
+
function extractExports(content: string): string[] {
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
for (const m of content.matchAll(EXPORT_RE)) {
|
|
64
|
+
out.push(m[1]);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function symbolUsedOutsideLib(siteDir: string, fs: RuleContext["fs"], symbol: string): boolean {
|
|
70
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
71
|
+
const re = new RegExp(`\\b${symbol}\\b`);
|
|
72
|
+
for (const file of tsFiles) {
|
|
73
|
+
if (file.includes("/src/lib/")) continue;
|
|
74
|
+
const content = fs.readText(file);
|
|
75
|
+
if (re.test(content)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ruleDeadLibShims: Rule = {
|
|
81
|
+
id: "dead-lib-shims",
|
|
82
|
+
title: "Dead src/lib/* shims",
|
|
83
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
84
|
+
const libFiles = fs.glob(siteDir, "src/lib/*.ts", SRC_GLOB_EXCLUDES);
|
|
85
|
+
if (libFiles.length === 0) return [];
|
|
86
|
+
|
|
87
|
+
const findings: Finding[] = [];
|
|
88
|
+
for (const abs of libFiles) {
|
|
89
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
90
|
+
const content = fs.readText(abs);
|
|
91
|
+
const exports = extractExports(content);
|
|
92
|
+
if (exports.length === 0) continue;
|
|
93
|
+
const allDead = exports.every((s) => !symbolUsedOutsideLib(siteDir, fs, s));
|
|
94
|
+
if (!allDead) continue;
|
|
95
|
+
findings.push({
|
|
96
|
+
rule: "dead-lib-shims",
|
|
97
|
+
severity: "info",
|
|
98
|
+
file: rel,
|
|
99
|
+
message: `${exports.length} export(s), 0 external imports`,
|
|
100
|
+
fix: `rm ${rel}`,
|
|
101
|
+
meta: { exports },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
},
|
|
106
|
+
applyFix({ siteDir }, findings, writer): FixAction[] {
|
|
107
|
+
const actions: FixAction[] = [];
|
|
108
|
+
for (const f of findings) {
|
|
109
|
+
writer.deleteFile(`${siteDir}/${f.file}`);
|
|
110
|
+
actions.push({
|
|
111
|
+
file: f.file,
|
|
112
|
+
kind: "delete",
|
|
113
|
+
detail: "deleted (all exports verified unused)",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return actions;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------------ */
|
|
121
|
+
/* Rule 2 — obsolete inline vite plugins */
|
|
122
|
+
/* ------------------------------------------------------------------ */
|
|
123
|
+
|
|
124
|
+
const OBSOLETE_VITE_PLUGINS: { name: string; reason: string }[] = [
|
|
125
|
+
{
|
|
126
|
+
name: "site-manual-chunks",
|
|
127
|
+
reason: "framework's decoVitePlugin() now owns chunking",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "deco-stub-meta-gen",
|
|
131
|
+
reason: "framework now stubs meta.gen.{json,ts} on the client by default",
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const ruleObsoleteVitePlugins: Rule = {
|
|
136
|
+
id: "obsolete-vite-plugins",
|
|
137
|
+
title: "Obsolete inline Vite plugins",
|
|
138
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
139
|
+
const findings: Finding[] = [];
|
|
140
|
+
const candidates = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
|
|
141
|
+
for (const rel of candidates) {
|
|
142
|
+
const abs = `${siteDir}/${rel}`;
|
|
143
|
+
if (!fs.exists(abs)) continue;
|
|
144
|
+
const content = fs.readText(abs);
|
|
145
|
+
for (const plugin of OBSOLETE_VITE_PLUGINS) {
|
|
146
|
+
const re = new RegExp(`name:\\s*["']${plugin.name}["']`);
|
|
147
|
+
if (!re.test(content)) continue;
|
|
148
|
+
findings.push({
|
|
149
|
+
rule: "obsolete-vite-plugins",
|
|
150
|
+
severity: "warning",
|
|
151
|
+
file: rel,
|
|
152
|
+
message: `'${plugin.name}' plugin is obsolete — ${plugin.reason}`,
|
|
153
|
+
fix: `delete the inline '${plugin.name}' plugin from ${rel}`,
|
|
154
|
+
meta: { plugin: plugin.name },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return findings;
|
|
159
|
+
},
|
|
160
|
+
applyFix({ siteDir, fs }, findings, writer): FixAction[] {
|
|
161
|
+
// Group findings by file so we rewrite each vite.config in one pass.
|
|
162
|
+
const byFile = new Map<string, string[]>();
|
|
163
|
+
for (const f of findings) {
|
|
164
|
+
const plugin = (f.meta?.plugin as string | undefined) ?? "";
|
|
165
|
+
if (!plugin) continue;
|
|
166
|
+
const arr = byFile.get(f.file) ?? [];
|
|
167
|
+
arr.push(plugin);
|
|
168
|
+
byFile.set(f.file, arr);
|
|
169
|
+
}
|
|
170
|
+
const actions: FixAction[] = [];
|
|
171
|
+
for (const [rel, pluginNames] of byFile) {
|
|
172
|
+
const abs = `${siteDir}/${rel}`;
|
|
173
|
+
if (!fs.exists(abs)) continue;
|
|
174
|
+
const before = fs.readText(abs);
|
|
175
|
+
const removed: string[] = [];
|
|
176
|
+
let next = before;
|
|
177
|
+
// Process plugins right-to-left in document order so each removal
|
|
178
|
+
// does not invalidate the indices of the next one.
|
|
179
|
+
const ordered = pluginNames
|
|
180
|
+
.map((name) => ({ name, span: findInlineVitePluginSpan(next, name) }))
|
|
181
|
+
.filter((p): p is { name: string; span: PluginSpan } => p.span !== null)
|
|
182
|
+
.sort((a, b) => b.span.startIdx - a.span.startIdx);
|
|
183
|
+
for (const p of ordered) {
|
|
184
|
+
next = next.slice(0, p.span.startIdx) + next.slice(p.span.endIdx);
|
|
185
|
+
removed.push(p.name);
|
|
186
|
+
}
|
|
187
|
+
if (next !== before) {
|
|
188
|
+
writer.writeText(abs, next);
|
|
189
|
+
actions.push({
|
|
190
|
+
file: rel,
|
|
191
|
+
kind: "rewrite-vite-config",
|
|
192
|
+
detail: `removed obsolete plugin(s): ${removed
|
|
193
|
+
.reverse()
|
|
194
|
+
.join(", ")}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return actions;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Span of an inline plugin object literal inside vite.config.ts that
|
|
204
|
+
* the auto-fixer should strip. Includes:
|
|
205
|
+
*
|
|
206
|
+
* - `startIdx`: position of the first attached `// ...` line above the
|
|
207
|
+
* `{`, or the `{` itself if no leading comment is attached. Leading
|
|
208
|
+
* indentation is included.
|
|
209
|
+
* - `endIdx`: exclusive — points just past the trailing `,\n` (or `\n`
|
|
210
|
+
* if there's no comma). The next character is the start of the next
|
|
211
|
+
* plugin (or the closing `]`).
|
|
212
|
+
*
|
|
213
|
+
* Removing `[startIdx, endIdx)` produces a clean diff: comment + literal
|
|
214
|
+
* gone, no orphan separator, surrounding plugins still paired with their
|
|
215
|
+
* own commas / comments.
|
|
216
|
+
*/
|
|
217
|
+
type PluginSpan = { startIdx: number; endIdx: number };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Brace-balanced search for an inline `{ name: "<plugin>", ... }`
|
|
221
|
+
* object literal inside a vite config. Properly skips over strings,
|
|
222
|
+
* template literals, line comments and block comments so it doesn't
|
|
223
|
+
* miscount braces inside e.g. a `config()` body that contains
|
|
224
|
+
* `{ build: { rollupOptions: ... } }` or template-string interpolation.
|
|
225
|
+
*
|
|
226
|
+
* Returns null when the plugin name isn't present, or when we can't
|
|
227
|
+
* walk the braces unambiguously (defensive — the rule's `run()` will
|
|
228
|
+
* still flag the finding for a manual fix).
|
|
229
|
+
*/
|
|
230
|
+
export function findInlineVitePluginSpan(
|
|
231
|
+
content: string,
|
|
232
|
+
pluginName: string,
|
|
233
|
+
): PluginSpan | null {
|
|
234
|
+
const re = new RegExp(`name:\\s*(['"\`])${escapeRegex(pluginName)}\\1`);
|
|
235
|
+
const m = re.exec(content);
|
|
236
|
+
if (!m) return null;
|
|
237
|
+
const namePropIdx = m.index;
|
|
238
|
+
|
|
239
|
+
// Walk backwards from the name property to find the enclosing `{`.
|
|
240
|
+
// Track string / comment state so we don't false-match on `{` inside
|
|
241
|
+
// a string literal somewhere earlier in the file.
|
|
242
|
+
const openIdx = findEnclosingObjectOpen(content, namePropIdx);
|
|
243
|
+
if (openIdx < 0) return null;
|
|
244
|
+
|
|
245
|
+
// Walk forward from the open brace counting matching braces, again
|
|
246
|
+
// skipping strings / comments. Returns the index of the matching `}`.
|
|
247
|
+
const closeIdx = findMatchingClose(content, openIdx);
|
|
248
|
+
if (closeIdx < 0) return null;
|
|
249
|
+
|
|
250
|
+
// Compute trailingEnd: consume `,` (optional) then whitespace up to
|
|
251
|
+
// and including the first `\n` after the closing brace. If we never
|
|
252
|
+
// hit `\n`, just stop at the comma / next non-whitespace.
|
|
253
|
+
let trailingEnd = closeIdx + 1;
|
|
254
|
+
while (
|
|
255
|
+
trailingEnd < content.length &&
|
|
256
|
+
(content[trailingEnd] === " " || content[trailingEnd] === "\t")
|
|
257
|
+
) {
|
|
258
|
+
trailingEnd++;
|
|
259
|
+
}
|
|
260
|
+
if (content[trailingEnd] === ",") trailingEnd++;
|
|
261
|
+
while (
|
|
262
|
+
trailingEnd < content.length &&
|
|
263
|
+
(content[trailingEnd] === " " || content[trailingEnd] === "\t")
|
|
264
|
+
) {
|
|
265
|
+
trailingEnd++;
|
|
266
|
+
}
|
|
267
|
+
if (content[trailingEnd] === "\n") trailingEnd++;
|
|
268
|
+
|
|
269
|
+
// Compute leadingStart: walk backwards over consecutive `//`-only
|
|
270
|
+
// lines that are immediately attached to the `{` (no blank line
|
|
271
|
+
// between them and the literal). Block comments are left alone.
|
|
272
|
+
const leadingStart = findAttachedLeadingComments(content, openIdx);
|
|
273
|
+
|
|
274
|
+
return { startIdx: leadingStart, endIdx: trailingEnd };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function escapeRegex(s: string): string {
|
|
278
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Walk backwards from `fromIdx` to find the index of the `{` that
|
|
283
|
+
* opens the object literal currently containing `fromIdx`. Returns
|
|
284
|
+
* -1 if no such `{` is found before the start of the file or if
|
|
285
|
+
* the walk is too ambiguous (mismatched balance).
|
|
286
|
+
*
|
|
287
|
+
* Skips over string, template-literal and comment regions so braces
|
|
288
|
+
* inside those don't affect the count.
|
|
289
|
+
*/
|
|
290
|
+
function findEnclosingObjectOpen(content: string, fromIdx: number): number {
|
|
291
|
+
// Strategy: scan the file from the start to fromIdx, maintaining a
|
|
292
|
+
// stack of open `{` positions, skipping inside strings/comments.
|
|
293
|
+
// The top of the stack at fromIdx is our enclosing open.
|
|
294
|
+
const stack: number[] = [];
|
|
295
|
+
let i = 0;
|
|
296
|
+
const n = Math.min(content.length, fromIdx);
|
|
297
|
+
while (i < n) {
|
|
298
|
+
const ch = content[i];
|
|
299
|
+
const next = content[i + 1];
|
|
300
|
+
if (ch === "/" && next === "/") {
|
|
301
|
+
i += 2;
|
|
302
|
+
while (i < n && content[i] !== "\n") i++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (ch === "/" && next === "*") {
|
|
306
|
+
i += 2;
|
|
307
|
+
while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
308
|
+
i += 2;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (ch === '"' || ch === "'") {
|
|
312
|
+
const quote = ch;
|
|
313
|
+
i++;
|
|
314
|
+
while (i < n && content[i] !== quote) {
|
|
315
|
+
if (content[i] === "\\") i++;
|
|
316
|
+
i++;
|
|
317
|
+
}
|
|
318
|
+
i++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (ch === "`") {
|
|
322
|
+
i++;
|
|
323
|
+
while (i < n && content[i] !== "`") {
|
|
324
|
+
if (content[i] === "\\") {
|
|
325
|
+
i += 2;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (content[i] === "$" && content[i + 1] === "{") {
|
|
329
|
+
i += 2;
|
|
330
|
+
// Recursively skip until matching `}` of the interpolation.
|
|
331
|
+
let depth = 1;
|
|
332
|
+
while (i < n && depth > 0) {
|
|
333
|
+
if (content[i] === "{") depth++;
|
|
334
|
+
else if (content[i] === "}") depth--;
|
|
335
|
+
if (depth === 0) break;
|
|
336
|
+
i++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
i++;
|
|
340
|
+
}
|
|
341
|
+
i++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (ch === "{") {
|
|
345
|
+
stack.push(i);
|
|
346
|
+
i++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (ch === "}") {
|
|
350
|
+
stack.pop();
|
|
351
|
+
i++;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
i++;
|
|
355
|
+
}
|
|
356
|
+
return stack.length > 0 ? stack[stack.length - 1] : -1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* From the `{` at `openIdx`, walk forward to find the matching `}`.
|
|
361
|
+
* Skips strings / template literals / comments.
|
|
362
|
+
*/
|
|
363
|
+
function findMatchingClose(content: string, openIdx: number): number {
|
|
364
|
+
let i = openIdx + 1;
|
|
365
|
+
let depth = 1;
|
|
366
|
+
const n = content.length;
|
|
367
|
+
while (i < n) {
|
|
368
|
+
const ch = content[i];
|
|
369
|
+
const next = content[i + 1];
|
|
370
|
+
if (ch === "/" && next === "/") {
|
|
371
|
+
i += 2;
|
|
372
|
+
while (i < n && content[i] !== "\n") i++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (ch === "/" && next === "*") {
|
|
376
|
+
i += 2;
|
|
377
|
+
while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
378
|
+
i += 2;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (ch === '"' || ch === "'") {
|
|
382
|
+
const quote = ch;
|
|
383
|
+
i++;
|
|
384
|
+
while (i < n && content[i] !== quote) {
|
|
385
|
+
if (content[i] === "\\") i++;
|
|
386
|
+
i++;
|
|
387
|
+
}
|
|
388
|
+
i++;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (ch === "`") {
|
|
392
|
+
i++;
|
|
393
|
+
while (i < n && content[i] !== "`") {
|
|
394
|
+
if (content[i] === "\\") {
|
|
395
|
+
i += 2;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (content[i] === "$" && content[i + 1] === "{") {
|
|
399
|
+
i += 2;
|
|
400
|
+
let d = 1;
|
|
401
|
+
while (i < n && d > 0) {
|
|
402
|
+
if (content[i] === "{") d++;
|
|
403
|
+
else if (content[i] === "}") d--;
|
|
404
|
+
if (d === 0) break;
|
|
405
|
+
i++;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
i++;
|
|
409
|
+
}
|
|
410
|
+
i++;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (ch === "{") {
|
|
414
|
+
depth++;
|
|
415
|
+
i++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (ch === "}") {
|
|
419
|
+
depth--;
|
|
420
|
+
if (depth === 0) return i;
|
|
421
|
+
i++;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
return -1;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Walk backwards from `openIdx` consuming the contiguous block of
|
|
431
|
+
* `//`-only lines immediately preceding the `{`. Stops at the first
|
|
432
|
+
* blank line, the first non-comment line, or block-comment territory.
|
|
433
|
+
* Returns the absolute index where the leading comment block (plus
|
|
434
|
+
* its indentation) starts — equal to `openIdx` minus its own line's
|
|
435
|
+
* indentation when no comment is attached.
|
|
436
|
+
*/
|
|
437
|
+
function findAttachedLeadingComments(content: string, openIdx: number): number {
|
|
438
|
+
// Walk back to start of the line containing `{`.
|
|
439
|
+
let lineStart = openIdx;
|
|
440
|
+
while (lineStart > 0 && content[lineStart - 1] !== "\n") lineStart--;
|
|
441
|
+
// Now climb up: each iteration considers the line ending at
|
|
442
|
+
// `lineStart - 1`. If it is a `//`-only line, include it; else stop.
|
|
443
|
+
let cursor = lineStart;
|
|
444
|
+
while (cursor > 0) {
|
|
445
|
+
const prevLineEnd = cursor - 1; // index of the `\n` separating
|
|
446
|
+
if (prevLineEnd <= 0) break;
|
|
447
|
+
let prevLineStart = prevLineEnd;
|
|
448
|
+
while (prevLineStart > 0 && content[prevLineStart - 1] !== "\n") {
|
|
449
|
+
prevLineStart--;
|
|
450
|
+
}
|
|
451
|
+
const line = content.slice(prevLineStart, prevLineEnd);
|
|
452
|
+
if (/^\s*\/\/.*$/.test(line)) {
|
|
453
|
+
cursor = prevLineStart;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
return cursor;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ------------------------------------------------------------------ */
|
|
462
|
+
/* Rule 3 — dead `src/runtime.ts` invoke shim */
|
|
463
|
+
/* ------------------------------------------------------------------ */
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Detection covers two shapes of `src/runtime.ts`:
|
|
467
|
+
*
|
|
468
|
+
* 1. Legacy inline proxy (pre-Wave 15-A migration template) — defines
|
|
469
|
+
* `createNestedInvokeProxy` plus `invoke` and `Runtime` constants.
|
|
470
|
+
* The whole 40-50 LOC body duplicates `@decocms/start/sdk`'s `invoke`.
|
|
471
|
+
*
|
|
472
|
+
* 2. Simple re-export shim — the file only re-exports `invoke` /
|
|
473
|
+
* `createNestedInvokeProxy` (no inline proxy body, but also not yet
|
|
474
|
+
* pointing at `@decocms/start/sdk`).
|
|
475
|
+
*
|
|
476
|
+
* Both should be replaced with `import { invoke } from "@decocms/start/sdk"`
|
|
477
|
+
* at every callsite, and the file deleted. The Wave 15-A migration template
|
|
478
|
+
* scaffolds a thin re-export form that's also acceptable (re-exports
|
|
479
|
+
* `invoke` from `@decocms/start/sdk` and rebuilds `Runtime = { invoke }`);
|
|
480
|
+
* we explicitly skip it via the "imports invoke from @decocms/start/sdk
|
|
481
|
+
* AND no inline proxy" check below.
|
|
482
|
+
*/
|
|
483
|
+
const INLINE_PROXY_RE =
|
|
484
|
+
/(?:function|const)\s+createNestedInvokeProxy\b|new\s+Proxy\s*\(\s*Object\.assign\s*\(\s*async\s*\(\s*props/;
|
|
485
|
+
const FRAMEWORK_INVOKE_IMPORT_RE =
|
|
486
|
+
/import\s+\{[^}]*\binvoke\b[^}]*\}\s+from\s+['"]@decocms\/start(?:\/sdk)?['"]/;
|
|
487
|
+
|
|
488
|
+
const ALLOWED_RUNTIME_EXPORTS = new Set(["invoke", "createNestedInvokeProxy", "Runtime"]);
|
|
489
|
+
|
|
490
|
+
const ruleDeadRuntimeShim: Rule = {
|
|
491
|
+
id: "dead-runtime-shim",
|
|
492
|
+
title: "Dead src/runtime.ts invoke shim",
|
|
493
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
494
|
+
const abs = `${siteDir}/src/runtime.ts`;
|
|
495
|
+
if (!fs.exists(abs)) return [];
|
|
496
|
+
const content = fs.readText(abs);
|
|
497
|
+
|
|
498
|
+
const hasInlineProxy = INLINE_PROXY_RE.test(content);
|
|
499
|
+
const reExportsFromFramework = FRAMEWORK_INVOKE_IMPORT_RE.test(content);
|
|
500
|
+
const exports = extractExports(content);
|
|
501
|
+
const onlyKnownInvokeExports =
|
|
502
|
+
exports.length > 0 && exports.every((e) => ALLOWED_RUNTIME_EXPORTS.has(e));
|
|
503
|
+
|
|
504
|
+
// Wave 15-A canonical template: re-exports invoke from @decocms/start/sdk
|
|
505
|
+
// and exposes `Runtime = { invoke }` for legacy callers. No inline proxy
|
|
506
|
+
// body. This is the desired shape — skip.
|
|
507
|
+
if (reExportsFromFramework && !hasInlineProxy) return [];
|
|
508
|
+
|
|
509
|
+
// Site-specific helpers alongside invoke: don't flag — the file has its
|
|
510
|
+
// own purpose beyond shimming. (Old behavior preserved.)
|
|
511
|
+
if (!hasInlineProxy && !onlyKnownInvokeExports) return [];
|
|
512
|
+
|
|
513
|
+
const exportSummary = exports.length > 0 ? exports.join(", ") : "(re-exports only)";
|
|
514
|
+
const flavor = hasInlineProxy ? "inline createNestedInvokeProxy body" : "shim re-exports";
|
|
515
|
+
// Only safe to auto-delete when exports are pure invoke surface; if a
|
|
516
|
+
// legacy file mixes the inline proxy with custom helpers, we still flag
|
|
517
|
+
// it but skip the destructive fix.
|
|
518
|
+
const safeToAutoFix = onlyKnownInvokeExports;
|
|
519
|
+
|
|
520
|
+
return [
|
|
521
|
+
{
|
|
522
|
+
rule: "dead-runtime-shim",
|
|
523
|
+
severity: "info",
|
|
524
|
+
file: "src/runtime.ts",
|
|
525
|
+
message: safeToAutoFix
|
|
526
|
+
? `${flavor} [${exportSummary}] — replace with @decocms/start/sdk`
|
|
527
|
+
: `${flavor} [${exportSummary}] — manual review: file mixes the runtime proxy with site-specific exports`,
|
|
528
|
+
fix: safeToAutoFix
|
|
529
|
+
? 'rg -l "from \\"~/runtime\\"" src/ | xargs sed -i \'\' \'s|from "~/runtime"|from "@decocms/start/sdk"|g\' && rm src/runtime.ts'
|
|
530
|
+
: "Move the inline `createNestedInvokeProxy` body to call @decocms/start/sdk's `invoke`; relocate site-specific helpers to a dedicated module before deleting src/runtime.ts",
|
|
531
|
+
meta: {
|
|
532
|
+
hasInlineProxy,
|
|
533
|
+
exports,
|
|
534
|
+
safeToAutoFix,
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
},
|
|
539
|
+
applyFix(ctx, findings, writer): FixAction[] {
|
|
540
|
+
if (findings.length === 0) return [];
|
|
541
|
+
// Honor the per-finding safety gate emitted by run() — never auto-delete
|
|
542
|
+
// a runtime.ts that mixes the proxy with site-specific helpers.
|
|
543
|
+
const safe = findings.every((f) => f.meta?.safeToAutoFix !== false);
|
|
544
|
+
if (!safe) return [];
|
|
545
|
+
const updated = rewriteImportSpec(ctx, writer, "~/runtime", "@decocms/start/sdk");
|
|
546
|
+
writer.deleteFile(`${ctx.siteDir}/src/runtime.ts`);
|
|
547
|
+
return [
|
|
548
|
+
{
|
|
549
|
+
file: "src/runtime.ts",
|
|
550
|
+
kind: "rewrite-imports+delete",
|
|
551
|
+
detail: `rewrote ${updated.length} import(s) → @decocms/start/sdk and deleted src/runtime.ts`,
|
|
552
|
+
},
|
|
553
|
+
];
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/* ------------------------------------------------------------------ */
|
|
558
|
+
/* Rule 4 — site-local `withSiteGlobals` workaround */
|
|
559
|
+
/* ------------------------------------------------------------------ */
|
|
560
|
+
|
|
561
|
+
const ruleSiteLocalGlobals: Rule = {
|
|
562
|
+
id: "site-local-with-globals",
|
|
563
|
+
title: "Site-local withSiteGlobals wrapper",
|
|
564
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
565
|
+
const findings: Finding[] = [];
|
|
566
|
+
const candidates = fs.glob(siteDir, "src/**/withSiteGlobals.ts", SRC_GLOB_EXCLUDES);
|
|
567
|
+
for (const abs of candidates) {
|
|
568
|
+
const content = fs.readText(abs);
|
|
569
|
+
// Heuristic: any local definition (function/const) of withSiteGlobals or
|
|
570
|
+
// cmsRouteWithGlobals indicates a local wrapper, not a re-export from
|
|
571
|
+
// the framework. The framework version would just re-export.
|
|
572
|
+
const definesWrapper =
|
|
573
|
+
/(?:export\s+)?(?:function|const)\s+(?:withSiteGlobals|cmsRouteWithGlobals)\b/.test(
|
|
574
|
+
content,
|
|
575
|
+
);
|
|
576
|
+
const reExportsFromFramework = /from\s+['"]@decocms\/start\/routes['"]/.test(content);
|
|
577
|
+
if (!definesWrapper || reExportsFromFramework) continue;
|
|
578
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
579
|
+
const lineCount = content.split("\n").length;
|
|
580
|
+
findings.push({
|
|
581
|
+
rule: "site-local-with-globals",
|
|
582
|
+
severity: "warning",
|
|
583
|
+
file: rel,
|
|
584
|
+
message: `Local wrapper (~${lineCount} LOC) — framework now exports withSiteGlobals from @decocms/start/routes`,
|
|
585
|
+
fix: "delete the local wrapper and import { withSiteGlobals } from '@decocms/start/routes'",
|
|
586
|
+
meta: { lineCount },
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return findings;
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/* ------------------------------------------------------------------ */
|
|
594
|
+
/* Rule 5 — `~/lib/vtex-*` shim regression */
|
|
595
|
+
/* ------------------------------------------------------------------ */
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Per-symbol guidance for the canonical replacement of each known
|
|
599
|
+
* shim stub. Used by the `vtex-shim-regression` rule to compose
|
|
600
|
+
* actionable `fix:` messages instead of the generic "Repoint imports"
|
|
601
|
+
* fallback.
|
|
602
|
+
*
|
|
603
|
+
* Kept as data (not code) so the JSON output of the audit can carry
|
|
604
|
+
* structured fix metadata for downstream tooling (CI dashboards,
|
|
605
|
+
* follow-up auto-fix rules, etc.).
|
|
606
|
+
*
|
|
607
|
+
* Categories:
|
|
608
|
+
* - `swap`: 1:1 import swap is safe — caller imports the symbol from
|
|
609
|
+
* `canonical` instead of the local shim. Note may flag a signature
|
|
610
|
+
* gotcha that the caller has to address at the call site.
|
|
611
|
+
* - `refactor`: a call-site rewrite is required (typically because the
|
|
612
|
+
* stub's "bag-based" API has no analog on TanStack Start; the request
|
|
613
|
+
* headers are the new source of truth). The note explains the pattern.
|
|
614
|
+
*
|
|
615
|
+
* Symbols absent from this table fall back to the generic guidance.
|
|
616
|
+
* The rule still flags them — only the `fix:` prose changes.
|
|
617
|
+
*/
|
|
618
|
+
export type FixHint =
|
|
619
|
+
| { kind: "swap"; canonical: string; note?: string }
|
|
620
|
+
| { kind: "refactor"; note: string };
|
|
621
|
+
|
|
622
|
+
export const STUB_FIX_HINTS: Record<string, FixHint> = {
|
|
623
|
+
// src/lib/vtex-transform
|
|
624
|
+
toProduct: {
|
|
625
|
+
kind: "swap",
|
|
626
|
+
canonical: "@decocms/apps/vtex/utils/transform",
|
|
627
|
+
note:
|
|
628
|
+
"canonical signature is `toProduct(product, sku, level, options)`; " +
|
|
629
|
+
"1-arg call sites need to expand args first — see skill § 5",
|
|
630
|
+
},
|
|
631
|
+
// src/lib/vtex-segment
|
|
632
|
+
getSegmentFromBag: {
|
|
633
|
+
kind: "refactor",
|
|
634
|
+
note:
|
|
635
|
+
"read cookies via `request.headers.get('cookie')` then call " +
|
|
636
|
+
"`buildSegmentFromCookies()` from '@decocms/apps/vtex/utils/segment'. " +
|
|
637
|
+
"The bag-based lookup mechanism does not exist on TanStack Start.",
|
|
638
|
+
},
|
|
639
|
+
withSegmentCookie: {
|
|
640
|
+
kind: "swap",
|
|
641
|
+
canonical: "@decocms/apps/vtex/utils/segment",
|
|
642
|
+
note:
|
|
643
|
+
"canonical signature is `withSegmentCookie(segment, headers?)`; " +
|
|
644
|
+
"if you currently pass only headers, also pass a segment object",
|
|
645
|
+
},
|
|
646
|
+
// src/lib/vtex-intelligent-search
|
|
647
|
+
getISCookiesFromBag: {
|
|
648
|
+
kind: "refactor",
|
|
649
|
+
note:
|
|
650
|
+
"extract IS cookies from `request.headers.get('cookie')` directly. " +
|
|
651
|
+
"The bag-based lookup mechanism does not exist on TanStack Start.",
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Format a single symbol's fix guidance as a one-liner suitable for
|
|
657
|
+
* the audit's `fix:` field. Returns undefined when the symbol has no
|
|
658
|
+
* specific entry in `STUB_FIX_HINTS`.
|
|
659
|
+
*/
|
|
660
|
+
export function formatFixHint(symbol: string): string | undefined {
|
|
661
|
+
const hint = STUB_FIX_HINTS[symbol];
|
|
662
|
+
if (!hint) return undefined;
|
|
663
|
+
if (hint.kind === "swap") {
|
|
664
|
+
const head = `${symbol} → ${hint.canonical} (1:1 import swap)`;
|
|
665
|
+
return hint.note ? `${head} — ${hint.note}` : head;
|
|
666
|
+
}
|
|
667
|
+
return `${symbol} → call-site refactor: ${hint.note}`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Compose the `fix:` message for a finding from the per-shim stub map.
|
|
672
|
+
* Splits symbols into "have specific guidance" vs "fall back to generic".
|
|
673
|
+
* Output joins each piece with ` | ` so the message stays one logical
|
|
674
|
+
* line even when there are several stubs.
|
|
675
|
+
*/
|
|
676
|
+
export function buildVtexShimFixMessage(stubsBySim: Map<string, string[]>): string {
|
|
677
|
+
const known: string[] = [];
|
|
678
|
+
const unknown: string[] = [];
|
|
679
|
+
for (const syms of stubsBySim.values()) {
|
|
680
|
+
for (const s of syms) {
|
|
681
|
+
const hint = formatFixHint(s);
|
|
682
|
+
if (hint) known.push(hint);
|
|
683
|
+
else unknown.push(s);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const parts: string[] = [...known];
|
|
687
|
+
if (unknown.length > 0) {
|
|
688
|
+
parts.push(
|
|
689
|
+
`${unknown.join(", ")} → repoint to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
return parts.length > 0
|
|
693
|
+
? parts.join(" | ")
|
|
694
|
+
: "Repoint imports to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Build the structured `fixHints` payload for `meta` so JSON consumers
|
|
699
|
+
* (CI dashboards, follow-up tooling) can render their own UI. Each
|
|
700
|
+
* entry is keyed by symbol; symbols without specific guidance are
|
|
701
|
+
* omitted (the prose fallback covers them).
|
|
702
|
+
*/
|
|
703
|
+
function fixHintsToMeta(stubsBySim: Map<string, string[]>): Record<string, FixHint> {
|
|
704
|
+
const out: Record<string, FixHint> = {};
|
|
705
|
+
for (const syms of stubsBySim.values()) {
|
|
706
|
+
for (const s of syms) {
|
|
707
|
+
const hint = STUB_FIX_HINTS[s];
|
|
708
|
+
if (hint) out[s] = hint;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return out;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Parse one or more ES `import { a, b as c, type d } from "spec"` blocks
|
|
716
|
+
* targeting a specific source spec out of a file. Returns the list of
|
|
717
|
+
* imported names (resolved to their original symbol, ignoring `as`
|
|
718
|
+
* rebinds), with `import type {…}` and inline `type` modifiers stripped
|
|
719
|
+
* — those carry no runtime, so the rule treats them as out-of-scope.
|
|
720
|
+
*/
|
|
721
|
+
function namedRuntimeImportsFrom(content: string, spec: string): string[] {
|
|
722
|
+
const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
723
|
+
// `(type\s+)?` captures the entire-import `import type { … }` form.
|
|
724
|
+
// Per-symbol `type` modifiers inside the braces are stripped below.
|
|
725
|
+
const re = new RegExp(
|
|
726
|
+
`import\\s+(type\\s+)?\\{([^}]+)\\}\\s+from\\s+['\"]${escaped}['\"]`,
|
|
727
|
+
"g",
|
|
728
|
+
);
|
|
729
|
+
const out: string[] = [];
|
|
730
|
+
for (const m of content.matchAll(re)) {
|
|
731
|
+
if (m[1]) continue; // entire import is type-only
|
|
732
|
+
for (const raw of m[2].split(",")) {
|
|
733
|
+
const trimmed = raw.trim();
|
|
734
|
+
if (!trimmed || trimmed.startsWith("type ")) continue;
|
|
735
|
+
// `foo as bar` → `foo` (we want the source symbol, not the local alias).
|
|
736
|
+
const sourceName = trimmed.split(/\s+as\s+/)[0].trim();
|
|
737
|
+
if (sourceName) out.push(sourceName);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const ruleVtexShimRegression: Rule = {
|
|
744
|
+
id: "vtex-shim-regression",
|
|
745
|
+
title: "Imports from ~/lib/vtex-* (silent stub regression)",
|
|
746
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
747
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
748
|
+
const findings: Finding[] = [];
|
|
749
|
+
|
|
750
|
+
// Per-shim classification cache. Each shim file is read at most once
|
|
751
|
+
// per audit run, even when imported by dozens of consumers.
|
|
752
|
+
const shimClasses = new Map<string, Map<string, ExportClass>>();
|
|
753
|
+
function classOf(shim: string, symbol: string): ExportClass {
|
|
754
|
+
let map = shimClasses.get(shim);
|
|
755
|
+
if (!map) {
|
|
756
|
+
const abs = `${siteDir}/src/lib/${shim}.ts`;
|
|
757
|
+
map = new Map<string, ExportClass>();
|
|
758
|
+
if (fs.exists(abs)) {
|
|
759
|
+
for (const ce of classifyShimExports(fs.readText(abs))) {
|
|
760
|
+
map.set(ce.name, ce.class);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
shimClasses.set(shim, map);
|
|
764
|
+
}
|
|
765
|
+
// Unknown symbols (file missing or not exported) default to "stub" —
|
|
766
|
+
// pessimistic on purpose. If the symbol can't be found locally, the
|
|
767
|
+
// import is at best dead code, at worst a TS error; either way the
|
|
768
|
+
// user wants visibility into it. Compile phase catches the TS side.
|
|
769
|
+
return map.get(symbol) ?? "stub";
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Match the bare `from "~/lib/vtex-X"` to know which shims are touched.
|
|
773
|
+
const fromRe = /from\s+['"]~\/lib\/vtex-([A-Za-z0-9-]+)['"]/g;
|
|
774
|
+
for (const abs of tsFiles) {
|
|
775
|
+
if (abs.includes("/src/lib/")) continue;
|
|
776
|
+
const content = fs.readText(abs);
|
|
777
|
+
const usedShims = new Set<string>(
|
|
778
|
+
[...content.matchAll(fromRe)].map((m) => `vtex-${m[1]}`),
|
|
779
|
+
);
|
|
780
|
+
if (usedShims.size === 0) continue;
|
|
781
|
+
|
|
782
|
+
// Per-file: which shim → which stub symbols are imported.
|
|
783
|
+
const stubsBySim = new Map<string, string[]>();
|
|
784
|
+
for (const shim of usedShims) {
|
|
785
|
+
const symbols = namedRuntimeImportsFrom(content, `~/lib/${shim}`);
|
|
786
|
+
const stubs = symbols.filter((s) => classOf(shim, s) === "stub");
|
|
787
|
+
if (stubs.length > 0) stubsBySim.set(shim, stubs);
|
|
788
|
+
}
|
|
789
|
+
if (stubsBySim.size === 0) continue;
|
|
790
|
+
|
|
791
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
792
|
+
const detail = [...stubsBySim.entries()]
|
|
793
|
+
.map(([s, syms]) => `${s} (${syms.join(", ")})`)
|
|
794
|
+
.join("; ");
|
|
795
|
+
const fixHintsMeta = fixHintsToMeta(stubsBySim);
|
|
796
|
+
findings.push({
|
|
797
|
+
rule: "vtex-shim-regression",
|
|
798
|
+
severity: "warning",
|
|
799
|
+
file: rel,
|
|
800
|
+
message: `Imports stub-only symbols from ${detail} — runtime is silently stubbed`,
|
|
801
|
+
fix: buildVtexShimFixMessage(stubsBySim),
|
|
802
|
+
meta: {
|
|
803
|
+
stubsBySim: Object.fromEntries(stubsBySim),
|
|
804
|
+
...(Object.keys(fixHintsMeta).length > 0 ? { fixHints: fixHintsMeta } : {}),
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
return findings;
|
|
809
|
+
},
|
|
810
|
+
applyFix({ siteDir, fs }, findings, writer): FixAction[] {
|
|
811
|
+
if (findings.length === 0) return [];
|
|
812
|
+
const actions: FixAction[] = [];
|
|
813
|
+
|
|
814
|
+
// Per-file rewrite. Conservative: only swap the import path when EVERY
|
|
815
|
+
// imported symbol from the shim is a `kind: "swap"` hint pointing at
|
|
816
|
+
// the same canonical module. Mixed surfaces (some swap + some
|
|
817
|
+
// refactor, or a real impl + a stub) stay untouched — those need a
|
|
818
|
+
// human looking at call-site signatures.
|
|
819
|
+
for (const finding of findings) {
|
|
820
|
+
const stubsBySim = (finding.meta?.stubsBySim ?? {}) as Record<string, string[]>;
|
|
821
|
+
const abs = `${siteDir}/${finding.file}`;
|
|
822
|
+
if (!fs.exists(abs)) continue;
|
|
823
|
+
|
|
824
|
+
let content = fs.readText(abs);
|
|
825
|
+
let modified = false;
|
|
826
|
+
|
|
827
|
+
for (const [shim, _stubSyms] of Object.entries(stubsBySim)) {
|
|
828
|
+
const oldSpec = `~/lib/${shim}`;
|
|
829
|
+
const importedSymbols = namedRuntimeImportsFrom(content, oldSpec);
|
|
830
|
+
if (importedSymbols.length === 0) continue;
|
|
831
|
+
|
|
832
|
+
// Every imported symbol must be a swap-kind hint AND every hint
|
|
833
|
+
// must point at the same canonical module — otherwise we'd
|
|
834
|
+
// either drop a real impl or split the import across two paths,
|
|
835
|
+
// both of which are unsafe to do mechanically here.
|
|
836
|
+
const hints = importedSymbols.map((s) => STUB_FIX_HINTS[s]);
|
|
837
|
+
const allSwap = hints.every((h) => h && h.kind === "swap");
|
|
838
|
+
if (!allSwap) continue;
|
|
839
|
+
const targets = new Set(
|
|
840
|
+
hints.map((h) => (h as { kind: "swap"; canonical: string }).canonical),
|
|
841
|
+
);
|
|
842
|
+
if (targets.size !== 1) continue;
|
|
843
|
+
const target = [...targets][0];
|
|
844
|
+
|
|
845
|
+
const escaped = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
846
|
+
const importLineRe = new RegExp(`from\\s+(['"])${escaped}\\1`, "g");
|
|
847
|
+
const next = content.replace(importLineRe, (_m, q) => `from ${q}${target}${q}`);
|
|
848
|
+
if (next !== content) {
|
|
849
|
+
content = next;
|
|
850
|
+
modified = true;
|
|
851
|
+
actions.push({
|
|
852
|
+
file: finding.file,
|
|
853
|
+
kind: "rewrite-imports",
|
|
854
|
+
detail: `${oldSpec} → ${target} (${importedSymbols.join(", ")})`,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (modified) writer.writeText(abs, content);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return actions;
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
/* ------------------------------------------------------------------ */
|
|
867
|
+
/* Rule 6 — local `src/types/widgets.ts` shadowing framework */
|
|
868
|
+
/* ------------------------------------------------------------------ */
|
|
869
|
+
|
|
870
|
+
const ruleLocalWidgetsTypes: Rule = {
|
|
871
|
+
id: "local-widgets-types",
|
|
872
|
+
title: "Local src/types/widgets.ts shadowing framework",
|
|
873
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
874
|
+
const abs = `${siteDir}/src/types/widgets.ts`;
|
|
875
|
+
if (!fs.exists(abs)) return [];
|
|
876
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
877
|
+
const re = /from\s+['"]~\/types\/widgets['"]/;
|
|
878
|
+
let importCount = 0;
|
|
879
|
+
for (const f of tsFiles) {
|
|
880
|
+
if (f === abs) continue;
|
|
881
|
+
if (re.test(fs.readText(f))) importCount++;
|
|
882
|
+
}
|
|
883
|
+
return [
|
|
884
|
+
{
|
|
885
|
+
rule: "local-widgets-types",
|
|
886
|
+
severity: "info",
|
|
887
|
+
file: "src/types/widgets.ts",
|
|
888
|
+
message: `Local file shadows @decocms/start/types/widgets (used in ${importCount} place(s))`,
|
|
889
|
+
fix: 'rewrite imports to "@decocms/start/types/widgets" and rm src/types/widgets.ts',
|
|
890
|
+
meta: { importCount },
|
|
891
|
+
},
|
|
892
|
+
];
|
|
893
|
+
},
|
|
894
|
+
applyFix(ctx, findings, writer): FixAction[] {
|
|
895
|
+
if (findings.length === 0) return [];
|
|
896
|
+
const updated = rewriteImportSpec(
|
|
897
|
+
ctx,
|
|
898
|
+
writer,
|
|
899
|
+
"~/types/widgets",
|
|
900
|
+
"@decocms/start/types/widgets",
|
|
901
|
+
);
|
|
902
|
+
writer.deleteFile(`${ctx.siteDir}/src/types/widgets.ts`);
|
|
903
|
+
return [
|
|
904
|
+
{
|
|
905
|
+
file: "src/types/widgets.ts",
|
|
906
|
+
kind: "rewrite-imports+delete",
|
|
907
|
+
detail: `rewrote ${updated.length} import(s) → @decocms/start/types/widgets and deleted src/types/widgets.ts`,
|
|
908
|
+
},
|
|
909
|
+
];
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
/* ------------------------------------------------------------------ */
|
|
914
|
+
/* Rule 7 — orphan "TODO: framework" comments */
|
|
915
|
+
/* ------------------------------------------------------------------ */
|
|
916
|
+
|
|
917
|
+
const ruleFrameworkTodos: Rule = {
|
|
918
|
+
id: "framework-todos",
|
|
919
|
+
title: "Orphan TODOs deferring to the framework",
|
|
920
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
921
|
+
const tsFiles = [
|
|
922
|
+
...fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES),
|
|
923
|
+
...fs.glob(siteDir, "vite.config.ts", SRC_GLOB_EXCLUDES),
|
|
924
|
+
];
|
|
925
|
+
const findings: Finding[] = [];
|
|
926
|
+
const re = /TODO[^\n]*?(?:deco|framework|move into)/i;
|
|
927
|
+
for (const abs of tsFiles) {
|
|
928
|
+
const content = fs.readText(abs);
|
|
929
|
+
const lines = content.split("\n");
|
|
930
|
+
for (let i = 0; i < lines.length; i++) {
|
|
931
|
+
if (!re.test(lines[i])) continue;
|
|
932
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
933
|
+
findings.push({
|
|
934
|
+
rule: "framework-todos",
|
|
935
|
+
severity: "info",
|
|
936
|
+
file: `${rel}:${i + 1}`,
|
|
937
|
+
message: lines[i].trim().slice(0, 120),
|
|
938
|
+
fix: "Triage: shipped → adopt; deferred → file issue; obsolete → delete",
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return findings;
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
/* ------------------------------------------------------------------ */
|
|
947
|
+
/* Rule — `local-framework-duplicate` — site-local copy of fwk code */
|
|
948
|
+
/* ------------------------------------------------------------------ */
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Registry of files we expect sites to NOT carry locally because the
|
|
952
|
+
* canonical implementation already lives in `@decocms/start` (or a
|
|
953
|
+
* sibling apps package).
|
|
954
|
+
*
|
|
955
|
+
* Two flavours:
|
|
956
|
+
* - `safeToAutoFix: true` — site file is a behaviour-equivalent dup
|
|
957
|
+
* of the framework export. `--fix` rewrites every `from "~/<path>"`
|
|
958
|
+
* importer to `from "<canonicalImport>"` and deletes the file.
|
|
959
|
+
* - `safeToAutoFix: false` — site file *overlaps* with framework code
|
|
960
|
+
* but isn't a clean drop-in (different typing, partial coverage,
|
|
961
|
+
* stricter behaviour, etc.). The rule still flags it so the entry
|
|
962
|
+
* surfaces in audits, but never deletes — the `reason` explains why
|
|
963
|
+
* a human has to make the call.
|
|
964
|
+
*
|
|
965
|
+
* `contentSignature` regexes ALL must match the site file's contents
|
|
966
|
+
* before the rule fires. They are deliberately specific enough to
|
|
967
|
+
* avoid catching forks that happen to share a filename but have
|
|
968
|
+
* diverged.
|
|
969
|
+
*/
|
|
970
|
+
interface FrameworkDuplicate {
|
|
971
|
+
/** Stable id surfaced in finding meta and CLI/JSON output. */
|
|
972
|
+
id: string;
|
|
973
|
+
/** Site-relative path of the duplicated file (e.g. "src/sdk/clx.ts"). */
|
|
974
|
+
sitePath: string;
|
|
975
|
+
/** Canonical import to rewrite to. */
|
|
976
|
+
canonicalImport: string;
|
|
977
|
+
/**
|
|
978
|
+
* Heuristic content fingerprint. The site file must match every
|
|
979
|
+
* regex for the rule to consider it the framework dup.
|
|
980
|
+
*/
|
|
981
|
+
contentSignature: RegExp[];
|
|
982
|
+
/**
|
|
983
|
+
* When true, the rule's `applyFix` will rewrite all importers and
|
|
984
|
+
* delete the file. When false, the rule emits a warning only —
|
|
985
|
+
* `reason` explains the manual judgement required.
|
|
986
|
+
*/
|
|
987
|
+
safeToAutoFix: boolean;
|
|
988
|
+
/**
|
|
989
|
+
* Required when `safeToAutoFix: false`. Surfaces in the finding's
|
|
990
|
+
* `fix:` field so users see *why* the auto-fix is gated.
|
|
991
|
+
*/
|
|
992
|
+
reason?: string;
|
|
993
|
+
/**
|
|
994
|
+
* Human-readable one-liner shown in the finding message and used
|
|
995
|
+
* to compose the `fix:` hint when auto-fixable.
|
|
996
|
+
*/
|
|
997
|
+
description: string;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Add an entry here when:
|
|
1002
|
+
* - 1+ migrated sites carry their own copy of code that already
|
|
1003
|
+
* exists in `@decocms/start` (or a sibling apps package), AND
|
|
1004
|
+
* - the canonical version is at least feature-equivalent.
|
|
1005
|
+
*
|
|
1006
|
+
* Per D4 in the migration tooling policy, the framework promotion
|
|
1007
|
+
* itself happens at 3+ sites — but once promoted, this registry is
|
|
1008
|
+
* how we *enforce* convergence on the remaining sites.
|
|
1009
|
+
*/
|
|
1010
|
+
export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
|
|
1011
|
+
{
|
|
1012
|
+
id: "clx",
|
|
1013
|
+
sitePath: "src/sdk/clx.ts",
|
|
1014
|
+
canonicalImport: "@decocms/start/sdk/clx",
|
|
1015
|
+
contentSignature: [
|
|
1016
|
+
/export\s+const\s+clx\s*=/,
|
|
1017
|
+
/args\.filter\(Boolean\)\.join/,
|
|
1018
|
+
],
|
|
1019
|
+
safeToAutoFix: true,
|
|
1020
|
+
description: "src/sdk/clx.ts duplicates @decocms/start/sdk/clx",
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
id: "use-send-event",
|
|
1024
|
+
sitePath: "src/sdk/useSendEvent.ts",
|
|
1025
|
+
canonicalImport: "@decocms/start/sdk/analytics",
|
|
1026
|
+
contentSignature: [
|
|
1027
|
+
/export\s+(?:const|function)\s+useSendEvent/,
|
|
1028
|
+
/data-event/,
|
|
1029
|
+
/encodeURIComponent/,
|
|
1030
|
+
],
|
|
1031
|
+
safeToAutoFix: false,
|
|
1032
|
+
reason:
|
|
1033
|
+
"site copy uses a typed AnalyticsEvent generic; the framework export is permissive. " +
|
|
1034
|
+
"Replacing 1:1 weakens type-safety. Either widen the framework export (preferred), or " +
|
|
1035
|
+
"rewrite call sites to drop the generic. Manual review required.",
|
|
1036
|
+
description:
|
|
1037
|
+
"src/sdk/useSendEvent.ts overlaps with @decocms/start/sdk/analytics → useSendEvent",
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
id: "location-matcher",
|
|
1041
|
+
sitePath: "src/matchers/location.ts",
|
|
1042
|
+
canonicalImport: "@decocms/start/matchers/builtins",
|
|
1043
|
+
contentSignature: [
|
|
1044
|
+
/registerMatcher\(\s*['"]website\/matchers\/location\.ts['"]/,
|
|
1045
|
+
/__cf_geo/,
|
|
1046
|
+
],
|
|
1047
|
+
safeToAutoFix: false,
|
|
1048
|
+
reason:
|
|
1049
|
+
"framework's registerBuiltinMatchers() ships a richer location matcher (request.cf + " +
|
|
1050
|
+
"geo cookies + headers + 10 sibling matchers). Adopting it changes behaviour: " +
|
|
1051
|
+
"verify country-name lookup parity (resolveCountryCode vs site's inline table) and " +
|
|
1052
|
+
"swap setup.ts's customMatchers entry to call registerBuiltinMatchers().",
|
|
1053
|
+
description:
|
|
1054
|
+
"src/matchers/location.ts overlaps with @decocms/start/matchers/builtins → registerBuiltinMatchers()",
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
id: "url-relative",
|
|
1058
|
+
sitePath: "src/sdk/url.ts",
|
|
1059
|
+
canonicalImport: "@decocms/apps/commerce/sdk/url",
|
|
1060
|
+
// Fingerprint: site fork carries a positional `removeIdSku?: boolean`
|
|
1061
|
+
// flag + hardcoded VTEX-specific keys (`idsku`, `skuId`). Canonical
|
|
1062
|
+
// apps export uses an options object — `{ stripSearchParams: string[] }`
|
|
1063
|
+
// — which is generic and platform-agnostic.
|
|
1064
|
+
contentSignature: [
|
|
1065
|
+
/export\s+const\s+relative\s*=/,
|
|
1066
|
+
/removeIdSku\s*\?\s*:\s*boolean/,
|
|
1067
|
+
/['"](idsku|skuId)['"]/,
|
|
1068
|
+
],
|
|
1069
|
+
safeToAutoFix: false,
|
|
1070
|
+
reason:
|
|
1071
|
+
"rewrite imports to '@decocms/apps/commerce/sdk/url'. " +
|
|
1072
|
+
"Each call site using the boolean form `relative(url, true)` becomes " +
|
|
1073
|
+
"`relative(url, { stripSearchParams: [\"idsku\", \"skuId\"] })`. " +
|
|
1074
|
+
"1-arg calls are unchanged. Then delete src/sdk/url.ts. " +
|
|
1075
|
+
"Auto-fix is gated because the call-site rewrite needs JSX/TS-aware " +
|
|
1076
|
+
"transformation (positional bool → options object), not pure import " +
|
|
1077
|
+
"rewrite.",
|
|
1078
|
+
description:
|
|
1079
|
+
"src/sdk/url.ts overlaps with @decocms/apps/commerce/sdk/url → relative() (extended in @decocms/apps@1.9+)",
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
id: "use-suggestions",
|
|
1083
|
+
sitePath: "src/sdk/useSuggestions.ts",
|
|
1084
|
+
canonicalImport: "@decocms/start/sdk/useSuggestions",
|
|
1085
|
+
// Fingerprint: hand-rolled hook with the module-level signal +
|
|
1086
|
+
// serial-queue + latestQuery cancel pattern. Both casaevideo and
|
|
1087
|
+
// baggagio independently invented this exact shape. Sites that
|
|
1088
|
+
// already adopted `createUseSuggestions(…)` factory calls won't
|
|
1089
|
+
// match this signature.
|
|
1090
|
+
contentSignature: [
|
|
1091
|
+
/export\s+const\s+useSuggestions\s*=/,
|
|
1092
|
+
/\/deco\/invoke\//,
|
|
1093
|
+
/latestQuery/,
|
|
1094
|
+
],
|
|
1095
|
+
safeToAutoFix: false,
|
|
1096
|
+
reason:
|
|
1097
|
+
"rewrite to a 5-line factory shim: " +
|
|
1098
|
+
"`export const { useSuggestions } = createUseSuggestions<MySuggestion>({ onError });` " +
|
|
1099
|
+
"where MySuggestion is the site's payload type. The call sites " +
|
|
1100
|
+
"(`const { setQuery, payload, loading } = useSuggestions(loader)`) are unchanged. " +
|
|
1101
|
+
"Then delete src/sdk/useSuggestions.ts. Auto-fix is gated because " +
|
|
1102
|
+
"the per-site type parameter and onError wiring need site-specific " +
|
|
1103
|
+
"decisions. See references/platform-hooks-factories.md § useSuggestions.",
|
|
1104
|
+
description:
|
|
1105
|
+
"src/sdk/useSuggestions.ts duplicates @decocms/start/sdk/useSuggestions → createUseSuggestions() (added in @decocms/start@2.25+)",
|
|
1106
|
+
},
|
|
1107
|
+
];
|
|
1108
|
+
|
|
1109
|
+
const ruleLocalFrameworkDuplicate: Rule = {
|
|
1110
|
+
id: "local-framework-duplicate",
|
|
1111
|
+
title: "Site-local copy of framework code",
|
|
1112
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1113
|
+
const findings: Finding[] = [];
|
|
1114
|
+
for (const dup of FRAMEWORK_DUPLICATES) {
|
|
1115
|
+
const abs = `${siteDir}/${dup.sitePath}`;
|
|
1116
|
+
if (!fs.exists(abs)) continue;
|
|
1117
|
+
const content = fs.readText(abs);
|
|
1118
|
+
const matchesAll = dup.contentSignature.every((re) => re.test(content));
|
|
1119
|
+
if (!matchesAll) continue;
|
|
1120
|
+
|
|
1121
|
+
const fixMessage = dup.safeToAutoFix
|
|
1122
|
+
? `Auto-fixable: rewrite \`from "~/${stripExt(dup.sitePath.replace(/^src\//, ""))}"\` → \`from "${dup.canonicalImport}"\` and delete ${dup.sitePath}.`
|
|
1123
|
+
: dup.reason ?? "Manual review required.";
|
|
1124
|
+
|
|
1125
|
+
findings.push({
|
|
1126
|
+
rule: "local-framework-duplicate",
|
|
1127
|
+
severity: "warning",
|
|
1128
|
+
file: dup.sitePath,
|
|
1129
|
+
message: `${dup.description}${dup.safeToAutoFix ? " (pure dup)" : " (partial overlap)"}`,
|
|
1130
|
+
fix: fixMessage,
|
|
1131
|
+
meta: {
|
|
1132
|
+
id: dup.id,
|
|
1133
|
+
canonicalImport: dup.canonicalImport,
|
|
1134
|
+
safeToAutoFix: dup.safeToAutoFix,
|
|
1135
|
+
...(dup.reason ? { reason: dup.reason } : {}),
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return findings;
|
|
1140
|
+
},
|
|
1141
|
+
applyFix(ctx, findings, writer): FixAction[] {
|
|
1142
|
+
const actions: FixAction[] = [];
|
|
1143
|
+
for (const f of findings) {
|
|
1144
|
+
const id = f.meta?.id as string | undefined;
|
|
1145
|
+
const safe = f.meta?.safeToAutoFix === true;
|
|
1146
|
+
if (!safe || !id) continue;
|
|
1147
|
+
const dup = FRAMEWORK_DUPLICATES.find((d) => d.id === id);
|
|
1148
|
+
if (!dup) continue;
|
|
1149
|
+
|
|
1150
|
+
const siteImportSpec = `~/${stripExt(dup.sitePath.replace(/^src\//, ""))}`;
|
|
1151
|
+
const updated = rewriteImportSpec(
|
|
1152
|
+
ctx,
|
|
1153
|
+
writer,
|
|
1154
|
+
siteImportSpec,
|
|
1155
|
+
dup.canonicalImport,
|
|
1156
|
+
);
|
|
1157
|
+
writer.deleteFile(`${ctx.siteDir}/${dup.sitePath}`);
|
|
1158
|
+
actions.push({
|
|
1159
|
+
file: dup.sitePath,
|
|
1160
|
+
kind: "rewrite-imports+delete",
|
|
1161
|
+
detail: `rewrote ${updated.length} import(s) "${siteImportSpec}" → "${dup.canonicalImport}" and deleted ${dup.sitePath}`,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return actions;
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
function stripExt(path: string): string {
|
|
1169
|
+
return path.replace(/\.(ts|tsx|js|jsx|mjs)$/, "");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/* ------------------------------------------------------------------ */
|
|
1173
|
+
/* Rule 8 — `htmx-residue` — leftover hx-* attrs in migrated src/ */
|
|
1174
|
+
/* ------------------------------------------------------------------ */
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Per D2 in the migration tooling policy, every `hx-*` attribute is
|
|
1178
|
+
* rewritten on migration; nothing in `@decocms/start` ships an htmx
|
|
1179
|
+
* runtime. This rule is the verification gate: a migrated site is
|
|
1180
|
+
* "rewrite-complete" when there are zero `hx-*` attributes left in
|
|
1181
|
+
* `src/`.
|
|
1182
|
+
*
|
|
1183
|
+
* Implementation reuses the htmx analyzer (`analyzeFile` from
|
|
1184
|
+
* `analyzers/htmx-analyze.ts`) so categorisation and the JSX walker
|
|
1185
|
+
* stay consistent with the standalone `deco-htmx-analyze` CLI. The
|
|
1186
|
+
* rule restricts to `src/**` (the migrated React tree) and excludes
|
|
1187
|
+
* test files — tests are allowed to mention `hx-*` for fixtures or
|
|
1188
|
+
* regression checks.
|
|
1189
|
+
*
|
|
1190
|
+
* Severity is `warning`, so `--strict` exits 2 on any finding. The
|
|
1191
|
+
* rule is intentionally detect-only: rewrites are non-mechanical
|
|
1192
|
+
* (state machine + sub-route + mutation choices vary per call site)
|
|
1193
|
+
* — the
|
|
1194
|
+
* `references/htmx-rewrite.md` skill is the playbook.
|
|
1195
|
+
*/
|
|
1196
|
+
const ruleHtmxResidue: Rule = {
|
|
1197
|
+
id: "htmx-residue",
|
|
1198
|
+
title: "HTMX residue in migrated src/",
|
|
1199
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1200
|
+
const findings: Finding[] = [];
|
|
1201
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
1202
|
+
for (const abs of tsFiles) {
|
|
1203
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
1204
|
+
// Skip test files — tests legitimately reference hx-* in fixtures
|
|
1205
|
+
// or regression checks. Same exclusion shape as vitest's default.
|
|
1206
|
+
if (/\.(test|spec)\.(ts|tsx)$/.test(rel)) continue;
|
|
1207
|
+
if (rel.startsWith("src/__tests__/") || rel.includes("/__tests__/")) {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
const content = fs.readText(abs);
|
|
1211
|
+
const occurrences = analyzeHtmxFile(rel, content);
|
|
1212
|
+
if (occurrences.length === 0) continue;
|
|
1213
|
+
|
|
1214
|
+
// Aggregate per-file: total + categories present.
|
|
1215
|
+
const byCat = new Map<string, number>();
|
|
1216
|
+
for (const occ of occurrences) {
|
|
1217
|
+
byCat.set(occ.category, (byCat.get(occ.category) ?? 0) + 1);
|
|
1218
|
+
}
|
|
1219
|
+
const catSummary = [...byCat.entries()]
|
|
1220
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1221
|
+
.map(([cat, n]) => `${cat}=${n}`)
|
|
1222
|
+
.join(", ");
|
|
1223
|
+
const firstLine = occurrences[0].line;
|
|
1224
|
+
|
|
1225
|
+
findings.push({
|
|
1226
|
+
rule: "htmx-residue",
|
|
1227
|
+
severity: "warning",
|
|
1228
|
+
file: `${rel}:${firstLine}`,
|
|
1229
|
+
message: `${occurrences.length} hx-* element(s) — ${catSummary}`,
|
|
1230
|
+
fix: `Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md (run \`deco-htmx-analyze\` for the per-category breakdown)`,
|
|
1231
|
+
meta: {
|
|
1232
|
+
total: occurrences.length,
|
|
1233
|
+
byCategory: Object.fromEntries(byCat),
|
|
1234
|
+
firstLine,
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
return findings;
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
/* ------------------------------------------------------------------ */
|
|
1243
|
+
/* Rule 9 — `lockfile-multiple` — multiple lockfiles tracked */
|
|
1244
|
+
/* ------------------------------------------------------------------ */
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Per the fleet-wide bun-canonical decision, every storefront commits
|
|
1248
|
+
* exactly one lockfile: `bun.lock`. This rule fires when any of the
|
|
1249
|
+
* non-bun lockfiles co-exist with bun.lock — that's the exact pattern
|
|
1250
|
+
* that broke Cloudflare Workers Builds with `lockfile had changes, but
|
|
1251
|
+
* lockfile is frozen` (the dual-lockfile drift).
|
|
1252
|
+
*
|
|
1253
|
+
* The `--fix` deletes the offending non-bun lockfiles. Adding the
|
|
1254
|
+
* `.gitignore` bans is a separate concern handled by `packageManager-missing`
|
|
1255
|
+
* (which also nudges the site to add the bans), so this rule stays
|
|
1256
|
+
* focused.
|
|
1257
|
+
*/
|
|
1258
|
+
const NON_BUN_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
|
1259
|
+
|
|
1260
|
+
const ruleLockfileMultiple: Rule = {
|
|
1261
|
+
id: "lockfile-multiple",
|
|
1262
|
+
title: "Multiple lockfiles tracked alongside bun.lock",
|
|
1263
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1264
|
+
const bunLock = `${siteDir}/bun.lock`;
|
|
1265
|
+
if (!fs.exists(bunLock)) return [];
|
|
1266
|
+
const findings: Finding[] = [];
|
|
1267
|
+
for (const name of NON_BUN_LOCKFILES) {
|
|
1268
|
+
const abs = `${siteDir}/${name}`;
|
|
1269
|
+
if (!fs.exists(abs)) continue;
|
|
1270
|
+
findings.push({
|
|
1271
|
+
rule: "lockfile-multiple",
|
|
1272
|
+
severity: "warning",
|
|
1273
|
+
file: name,
|
|
1274
|
+
message: `${name} co-exists with bun.lock — Cloudflare Workers Builds picks bun and the other will silently drift`,
|
|
1275
|
+
fix: `rm ${name} (bun.lock is the canonical lockfile)`,
|
|
1276
|
+
meta: { lockfile: name },
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return findings;
|
|
1280
|
+
},
|
|
1281
|
+
applyFix({ siteDir }, findings, writer): FixAction[] {
|
|
1282
|
+
const actions: FixAction[] = [];
|
|
1283
|
+
for (const f of findings) {
|
|
1284
|
+
writer.deleteFile(`${siteDir}/${f.file}`);
|
|
1285
|
+
actions.push({
|
|
1286
|
+
file: f.file,
|
|
1287
|
+
kind: "delete",
|
|
1288
|
+
detail: `deleted (bun.lock is canonical)`,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
return actions;
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
/* ------------------------------------------------------------------ */
|
|
1296
|
+
/* Rule 10 — `lockfile-missing` — package.json without bun.lock */
|
|
1297
|
+
/* ------------------------------------------------------------------ */
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* A storefront with `package.json` but no committed lockfile cannot
|
|
1301
|
+
* run `bun install --frozen-lockfile` in CI — Cloudflare Workers
|
|
1302
|
+
* Builds either falls back to a non-reproducible install or fails
|
|
1303
|
+
* outright depending on the build image. Detect-only because the
|
|
1304
|
+
* fix (`bun install`) requires network access and a working bun
|
|
1305
|
+
* toolchain that the audit shouldn't shell out to from inside its
|
|
1306
|
+
* own runner.
|
|
1307
|
+
*/
|
|
1308
|
+
const ruleLockfileMissing: Rule = {
|
|
1309
|
+
id: "lockfile-missing",
|
|
1310
|
+
title: "Lockfile missing",
|
|
1311
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1312
|
+
const pkg = `${siteDir}/package.json`;
|
|
1313
|
+
if (!fs.exists(pkg)) return [];
|
|
1314
|
+
const bunLock = `${siteDir}/bun.lock`;
|
|
1315
|
+
if (fs.exists(bunLock)) return [];
|
|
1316
|
+
// If a non-bun lockfile is present, `lockfile-multiple` doesn't
|
|
1317
|
+
// apply but the site is still mid-migration to bun. We flag the
|
|
1318
|
+
// missing bun.lock here so the operator runs `bun install` to
|
|
1319
|
+
// produce the canonical one.
|
|
1320
|
+
return [
|
|
1321
|
+
{
|
|
1322
|
+
rule: "lockfile-missing",
|
|
1323
|
+
severity: "warning",
|
|
1324
|
+
file: "bun.lock",
|
|
1325
|
+
message: `No bun.lock committed — frozen installs cannot run on CF Workers Builds`,
|
|
1326
|
+
fix: `Run \`bun install\` and commit the resulting bun.lock`,
|
|
1327
|
+
meta: {},
|
|
1328
|
+
},
|
|
1329
|
+
];
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
/* ------------------------------------------------------------------ */
|
|
1334
|
+
/* Rule 11 — `lockfile-drift` — bun.lock out of sync with package.json */
|
|
1335
|
+
/* ------------------------------------------------------------------ */
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Detects the head-on case behind the `lockfile had changes, but
|
|
1339
|
+
* lockfile is frozen` Workers Builds error: a direct dependency in
|
|
1340
|
+
* `package.json` has no version in `bun.lock` that satisfies the
|
|
1341
|
+
* declared range.
|
|
1342
|
+
*
|
|
1343
|
+
* Coverage is intentionally pragmatic — we recognise the four most
|
|
1344
|
+
* common range shapes (`^a.b.c`, `~a.b.c`, plain `a.b.c`, and the
|
|
1345
|
+
* sentinels `*` / `latest` / `next` / git/github specs which we skip).
|
|
1346
|
+
* Everything else falls back to "present in lockfile?", treating
|
|
1347
|
+
* unknown ranges as satisfied as long as the name appears at all.
|
|
1348
|
+
* The goal is high signal on the storefront fleet's actual failure
|
|
1349
|
+
* mode; full npm-semver fidelity is out of scope for this rule.
|
|
1350
|
+
*
|
|
1351
|
+
* Detect-only: regenerating bun.lock requires running `bun install`,
|
|
1352
|
+
* which the audit script doesn't do (would need network + bun in
|
|
1353
|
+
* PATH). Operators run it manually and re-run the audit.
|
|
1354
|
+
*/
|
|
1355
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.+-]+)?$/;
|
|
1356
|
+
|
|
1357
|
+
function parseSemver(v: string): [number, number, number] | null {
|
|
1358
|
+
const m = SEMVER_RE.exec(v.trim());
|
|
1359
|
+
if (!m) return null;
|
|
1360
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function gte(a: [number, number, number], b: [number, number, number]): boolean {
|
|
1364
|
+
if (a[0] !== b[0]) return a[0] > b[0];
|
|
1365
|
+
if (a[1] !== b[1]) return a[1] > b[1];
|
|
1366
|
+
return a[2] >= b[2];
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function lt(a: [number, number, number], b: [number, number, number]): boolean {
|
|
1370
|
+
if (a[0] !== b[0]) return a[0] < b[0];
|
|
1371
|
+
if (a[1] !== b[1]) return a[1] < b[1];
|
|
1372
|
+
return a[2] < b[2];
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Minimal `range satisfies version` check. Returns:
|
|
1377
|
+
* - `true` when the range is satisfied,
|
|
1378
|
+
* - `false` when it is definitely violated,
|
|
1379
|
+
* - `null` when we don't recognise the range shape and the caller
|
|
1380
|
+
* should fall back to a presence check.
|
|
1381
|
+
*/
|
|
1382
|
+
function satisfiesRange(version: string, range: string): boolean | null {
|
|
1383
|
+
const trimmed = range.trim();
|
|
1384
|
+
// Sentinels and non-numeric specs we don't try to evaluate.
|
|
1385
|
+
if (
|
|
1386
|
+
trimmed === "*" ||
|
|
1387
|
+
trimmed === "latest" ||
|
|
1388
|
+
trimmed === "next" ||
|
|
1389
|
+
trimmed.startsWith("workspace:") ||
|
|
1390
|
+
trimmed.startsWith("file:") ||
|
|
1391
|
+
trimmed.startsWith("link:") ||
|
|
1392
|
+
trimmed.startsWith("git+") ||
|
|
1393
|
+
trimmed.startsWith("github:") ||
|
|
1394
|
+
trimmed.includes("://")
|
|
1395
|
+
) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
const ver = parseSemver(version);
|
|
1399
|
+
if (!ver) return null;
|
|
1400
|
+
// Caret: ^a.b.c → >=a.b.c <(a+1).0.0 when a > 0
|
|
1401
|
+
// ^0.b.c → >=0.b.c <0.(b+1).0 when a == 0 && b > 0
|
|
1402
|
+
// ^0.0.c → exactly 0.0.c
|
|
1403
|
+
if (trimmed.startsWith("^")) {
|
|
1404
|
+
const base = parseSemver(trimmed.slice(1));
|
|
1405
|
+
if (!base) return null;
|
|
1406
|
+
if (!gte(ver, base)) return false;
|
|
1407
|
+
let upper: [number, number, number];
|
|
1408
|
+
if (base[0] > 0) upper = [base[0] + 1, 0, 0];
|
|
1409
|
+
else if (base[1] > 0) upper = [0, base[1] + 1, 0];
|
|
1410
|
+
else upper = [0, 0, base[2] + 1];
|
|
1411
|
+
return lt(ver, upper);
|
|
1412
|
+
}
|
|
1413
|
+
// Tilde: ~a.b.c → >=a.b.c <a.(b+1).0
|
|
1414
|
+
if (trimmed.startsWith("~")) {
|
|
1415
|
+
const base = parseSemver(trimmed.slice(1));
|
|
1416
|
+
if (!base) return null;
|
|
1417
|
+
return gte(ver, base) && lt(ver, [base[0], base[1] + 1, 0]);
|
|
1418
|
+
}
|
|
1419
|
+
// Plain pin: a.b.c → exact match.
|
|
1420
|
+
const exact = parseSemver(trimmed);
|
|
1421
|
+
if (exact) return exact[0] === ver[0] && exact[1] === ver[1] && exact[2] === ver[2];
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Pull every `"<name>@<version>"` token out of bun.lock for a given
|
|
1427
|
+
* package name. Bun's lockfile format embeds the version inside the
|
|
1428
|
+
* package descriptor array, e.g.:
|
|
1429
|
+
*
|
|
1430
|
+
* "@decocms/start": ["@decocms/start@2.1.1", ...]
|
|
1431
|
+
*
|
|
1432
|
+
* We scan all such occurrences (a single package may appear multiple
|
|
1433
|
+
* times if the dep tree pulled different versions).
|
|
1434
|
+
*/
|
|
1435
|
+
function lockfileVersionsOf(lockfile: string, name: string): string[] {
|
|
1436
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1437
|
+
const re = new RegExp(`['"]${escaped}@([^'"\\s]+)['"]`, "g");
|
|
1438
|
+
const seen = new Set<string>();
|
|
1439
|
+
for (const m of lockfile.matchAll(re)) {
|
|
1440
|
+
seen.add(m[1]);
|
|
1441
|
+
}
|
|
1442
|
+
return [...seen];
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const ruleLockfileDrift: Rule = {
|
|
1446
|
+
id: "lockfile-drift",
|
|
1447
|
+
title: "bun.lock drifted vs package.json direct dependencies",
|
|
1448
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1449
|
+
const pkgPath = `${siteDir}/package.json`;
|
|
1450
|
+
const lockPath = `${siteDir}/bun.lock`;
|
|
1451
|
+
if (!fs.exists(pkgPath) || !fs.exists(lockPath)) return [];
|
|
1452
|
+
let parsed: unknown;
|
|
1453
|
+
try {
|
|
1454
|
+
parsed = JSON.parse(fs.readText(pkgPath));
|
|
1455
|
+
} catch {
|
|
1456
|
+
return [];
|
|
1457
|
+
}
|
|
1458
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
1459
|
+
const pkg = parsed as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
1460
|
+
const lockText = fs.readText(lockPath);
|
|
1461
|
+
const drifted: { name: string; range: string; locked: string[] }[] = [];
|
|
1462
|
+
const buckets: Record<string, string>[] = [pkg.dependencies ?? {}, pkg.devDependencies ?? {}];
|
|
1463
|
+
for (const bucket of buckets) {
|
|
1464
|
+
for (const [name, range] of Object.entries(bucket)) {
|
|
1465
|
+
const versions = lockfileVersionsOf(lockText, name);
|
|
1466
|
+
if (versions.length === 0) {
|
|
1467
|
+
drifted.push({ name, range, locked: [] });
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
// If at least one locked version satisfies the range, we're fine.
|
|
1471
|
+
// For unknown range shapes (return null), treat presence as
|
|
1472
|
+
// satisfaction — pragmatic, see rule docstring.
|
|
1473
|
+
let satisfied = false;
|
|
1474
|
+
for (const v of versions) {
|
|
1475
|
+
const result = satisfiesRange(v, range);
|
|
1476
|
+
if (result === true || result === null) {
|
|
1477
|
+
satisfied = true;
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (!satisfied) drifted.push({ name, range, locked: versions });
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (drifted.length === 0) return [];
|
|
1485
|
+
return [
|
|
1486
|
+
{
|
|
1487
|
+
rule: "lockfile-drift",
|
|
1488
|
+
severity: "warning",
|
|
1489
|
+
file: "bun.lock",
|
|
1490
|
+
message: `${drifted.length} direct dep(s) not satisfied by bun.lock — frozen install will fail`,
|
|
1491
|
+
fix: `Run \`bun install\` to refresh bun.lock, then commit. Drift: ${drifted
|
|
1492
|
+
.slice(0, 5)
|
|
1493
|
+
.map((d) => `${d.name} ${d.range} (locked: ${d.locked.length > 0 ? d.locked.join(", ") : "none"})`)
|
|
1494
|
+
.join("; ")}${drifted.length > 5 ? `; +${drifted.length - 5} more` : ""}`,
|
|
1495
|
+
meta: { drifted },
|
|
1496
|
+
},
|
|
1497
|
+
];
|
|
1498
|
+
},
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
/* ------------------------------------------------------------------ */
|
|
1502
|
+
/* Rule 12 — `package-manager-missing` — no packageManager field */
|
|
1503
|
+
/* ------------------------------------------------------------------ */
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Without a `packageManager` field in package.json, neither developers
|
|
1507
|
+
* nor CI are forced to agree on a PM. Anyone running `npm install`
|
|
1508
|
+
* or `yarn` produces an alternate lockfile that risks drifting from
|
|
1509
|
+
* `bun.lock` and breaking Workers Builds. The fleet-wide canonical
|
|
1510
|
+
* value is `bun@<CANONICAL_BUN_VERSION>`; bumping that constant here
|
|
1511
|
+
* propagates to all `--fix` runs.
|
|
1512
|
+
*/
|
|
1513
|
+
const CANONICAL_PACKAGE_MANAGER = "bun@1.3.5";
|
|
1514
|
+
|
|
1515
|
+
const rulePackageManagerMissing: Rule = {
|
|
1516
|
+
id: "package-manager-missing",
|
|
1517
|
+
title: "Missing packageManager field in package.json",
|
|
1518
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1519
|
+
const pkgPath = `${siteDir}/package.json`;
|
|
1520
|
+
if (!fs.exists(pkgPath)) return [];
|
|
1521
|
+
let parsed: unknown;
|
|
1522
|
+
try {
|
|
1523
|
+
parsed = JSON.parse(fs.readText(pkgPath));
|
|
1524
|
+
} catch {
|
|
1525
|
+
return [];
|
|
1526
|
+
}
|
|
1527
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
1528
|
+
const pkg = parsed as { packageManager?: string };
|
|
1529
|
+
if (typeof pkg.packageManager === "string" && pkg.packageManager.length > 0) {
|
|
1530
|
+
return [];
|
|
1531
|
+
}
|
|
1532
|
+
return [
|
|
1533
|
+
{
|
|
1534
|
+
rule: "package-manager-missing",
|
|
1535
|
+
severity: "info",
|
|
1536
|
+
file: "package.json",
|
|
1537
|
+
message: `Missing "packageManager" field — contributors and CF Workers Builds may pick different PMs`,
|
|
1538
|
+
fix: `Set "packageManager": "${CANONICAL_PACKAGE_MANAGER}" in package.json`,
|
|
1539
|
+
meta: { canonical: CANONICAL_PACKAGE_MANAGER },
|
|
1540
|
+
},
|
|
1541
|
+
];
|
|
1542
|
+
},
|
|
1543
|
+
applyFix({ siteDir, fs }, findings, writer): FixAction[] {
|
|
1544
|
+
if (findings.length === 0) return [];
|
|
1545
|
+
const pkgPath = `${siteDir}/package.json`;
|
|
1546
|
+
if (!fs.exists(pkgPath)) return [];
|
|
1547
|
+
const content = fs.readText(pkgPath);
|
|
1548
|
+
let parsed: Record<string, unknown>;
|
|
1549
|
+
try {
|
|
1550
|
+
parsed = JSON.parse(content) as Record<string, unknown>;
|
|
1551
|
+
} catch {
|
|
1552
|
+
return [];
|
|
1553
|
+
}
|
|
1554
|
+
if (typeof parsed.packageManager === "string" && parsed.packageManager.length > 0) {
|
|
1555
|
+
return [];
|
|
1556
|
+
}
|
|
1557
|
+
// Insert `packageManager` directly after `license` to match the
|
|
1558
|
+
// convention used across the storefront fleet. Falls back to the
|
|
1559
|
+
// end of the object when `license` is absent.
|
|
1560
|
+
const ordered: Record<string, unknown> = {};
|
|
1561
|
+
let inserted = false;
|
|
1562
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
1563
|
+
ordered[k] = v;
|
|
1564
|
+
if (!inserted && k === "license") {
|
|
1565
|
+
ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
|
|
1566
|
+
inserted = true;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (!inserted) ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
|
|
1570
|
+
writer.writeText(pkgPath, `${JSON.stringify(ordered, null, 2)}\n`);
|
|
1571
|
+
return [
|
|
1572
|
+
{
|
|
1573
|
+
file: "package.json",
|
|
1574
|
+
kind: "edit",
|
|
1575
|
+
detail: `set packageManager: "${CANONICAL_PACKAGE_MANAGER}"`,
|
|
1576
|
+
},
|
|
1577
|
+
];
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
/* ------------------------------------------------------------------ */
|
|
1582
|
+
/* Rule N — `vtex-proxy-handler-missing` — worker-entry without proxy */
|
|
1583
|
+
/* ------------------------------------------------------------------ */
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Every VTEX storefront on @decocms/start needs a reverse proxy for
|
|
1587
|
+
* `/checkout/*`, `/account/*`, `/api/*`, `/files/*`, etc. — those paths
|
|
1588
|
+
* must hit the VTEX origin (not TanStack Start) so the user lands on
|
|
1589
|
+
* the real checkout UI carrying their VTEX session cookies.
|
|
1590
|
+
*
|
|
1591
|
+
* The canonical wiring lives in `src/worker-entry.ts` via
|
|
1592
|
+
* `createDecoWorkerEntry(..., { proxyHandler })`, where the handler
|
|
1593
|
+
* calls `shouldProxyToVtex(url.pathname)` + a `createVtexCheckoutProxy`
|
|
1594
|
+
* instance. The migration scaffold (`scripts/migrate/templates/server-entry.ts`)
|
|
1595
|
+
* emits this by default for VTEX sites, but two regressions sneak it out:
|
|
1596
|
+
*
|
|
1597
|
+
* 1. A site migrated by a pre-scaffold version of the script (e.g.
|
|
1598
|
+
* before the VTEX worker-entry template existed).
|
|
1599
|
+
* 2. Someone refactors `worker-entry.ts` and drops the proxy block.
|
|
1600
|
+
*
|
|
1601
|
+
* Without the proxy, add-to-cart still "succeeds" (the action runs
|
|
1602
|
+
* server-side via TanStack RPC), but clicking "Finalizar" navigates
|
|
1603
|
+
* to `/checkout` on the storefront — which returns the SPA shell —
|
|
1604
|
+
* and the user never reaches VTEX checkout. The VTEX-side orderForm
|
|
1605
|
+
* lives at vtexcommercestable.com.br with no way to see it.
|
|
1606
|
+
*
|
|
1607
|
+
* Detection is cheap: VTEX sites should import `shouldProxyToVtex`
|
|
1608
|
+
* (or wire a `proxyHandler:` to `createDecoWorkerEntry`). We flag
|
|
1609
|
+
* absence as `info` (not warning) because old in-prod sites we
|
|
1610
|
+
* deliberately don't touch would otherwise stay noisy.
|
|
1611
|
+
*/
|
|
1612
|
+
const ruleVtexProxyHandlerMissing: Rule = {
|
|
1613
|
+
id: "vtex-proxy-handler-missing",
|
|
1614
|
+
title: "VTEX worker-entry missing the checkout/API proxy handler",
|
|
1615
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1616
|
+
// Only run when the site is actually VTEX. The cheapest signal is
|
|
1617
|
+
// any import from `@decocms/apps/vtex/...` in src/ — every VTEX
|
|
1618
|
+
// site has at least one (commerceLoaders, hooks, types, etc.).
|
|
1619
|
+
const srcFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
1620
|
+
const isVtex = srcFiles.some((abs) =>
|
|
1621
|
+
fs.readText(abs).includes("@decocms/apps/vtex"),
|
|
1622
|
+
);
|
|
1623
|
+
if (!isVtex) return [];
|
|
1624
|
+
|
|
1625
|
+
const workerEntryAbs = `${siteDir}/src/worker-entry.ts`;
|
|
1626
|
+
if (!fs.exists(workerEntryAbs)) {
|
|
1627
|
+
return [
|
|
1628
|
+
{
|
|
1629
|
+
rule: "vtex-proxy-handler-missing",
|
|
1630
|
+
severity: "info",
|
|
1631
|
+
file: "src/worker-entry.ts",
|
|
1632
|
+
message:
|
|
1633
|
+
"VTEX site has no src/worker-entry.ts — /checkout proxy can't run, the user will see the SPA shell instead of VTEX checkout",
|
|
1634
|
+
fix: "Re-run `deco-migrate` (the scaffold emits a worker-entry with createVtexCheckoutProxy), or copy from scripts/migrate/templates/server-entry.ts",
|
|
1635
|
+
},
|
|
1636
|
+
];
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const content = fs.readText(workerEntryAbs);
|
|
1640
|
+
// Match either symbol — sites use the factory function OR the
|
|
1641
|
+
// shouldProxyToVtex predicate as the entry point. Presence of
|
|
1642
|
+
// either is a strong signal the proxy block exists; absence of
|
|
1643
|
+
// both means it was dropped.
|
|
1644
|
+
const hasProxyImport =
|
|
1645
|
+
/from\s+["']@decocms\/apps\/vtex\/utils\/proxy["']/.test(content);
|
|
1646
|
+
// Match both long form (`proxyHandler: async (...) => ...`) and
|
|
1647
|
+
// object-shorthand wiring (`{ proxyHandler }`, `{ proxyHandler, admin }`).
|
|
1648
|
+
// The anchor `[{,]` requires the identifier to appear as a property —
|
|
1649
|
+
// not as a bare `const proxyHandler = ...` declaration, which is
|
|
1650
|
+
// followed by `=` and wouldn't match either branch.
|
|
1651
|
+
const hasProxyHandler = /[{,]\s*proxyHandler\s*[:,}]/.test(content);
|
|
1652
|
+
|
|
1653
|
+
if (hasProxyImport && hasProxyHandler) return [];
|
|
1654
|
+
|
|
1655
|
+
return [
|
|
1656
|
+
{
|
|
1657
|
+
rule: "vtex-proxy-handler-missing",
|
|
1658
|
+
severity: "info",
|
|
1659
|
+
file: "src/worker-entry.ts",
|
|
1660
|
+
message: hasProxyImport
|
|
1661
|
+
? "imports proxy helpers but no `proxyHandler:` is wired into createDecoWorkerEntry — /checkout requests will fall through to TanStack and render the SPA shell"
|
|
1662
|
+
: "no `@decocms/apps/vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin",
|
|
1663
|
+
fix: "Add `proxyHandler` to createDecoWorkerEntry; see scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry) for the canonical block",
|
|
1664
|
+
},
|
|
1665
|
+
];
|
|
1666
|
+
},
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
export const ALL_RULES: Rule[] = [
|
|
1670
|
+
ruleDeadLibShims,
|
|
1671
|
+
ruleObsoleteVitePlugins,
|
|
1672
|
+
ruleDeadRuntimeShim,
|
|
1673
|
+
ruleSiteLocalGlobals,
|
|
1674
|
+
ruleVtexShimRegression,
|
|
1675
|
+
ruleVtexProxyHandlerMissing,
|
|
1676
|
+
ruleLocalWidgetsTypes,
|
|
1677
|
+
ruleFrameworkTodos,
|
|
1678
|
+
ruleLocalFrameworkDuplicate,
|
|
1679
|
+
ruleHtmxResidue,
|
|
1680
|
+
ruleLockfileMultiple,
|
|
1681
|
+
ruleLockfileMissing,
|
|
1682
|
+
ruleLockfileDrift,
|
|
1683
|
+
rulePackageManagerMissing,
|
|
1684
|
+
];
|
|
1685
|
+
|
|
1686
|
+
/** Exported for direct unit tests. */
|
|
1687
|
+
export const _internals = {
|
|
1688
|
+
extractExports,
|
|
1689
|
+
symbolUsedOutsideLib,
|
|
1690
|
+
satisfiesRange,
|
|
1691
|
+
lockfileVersionsOf,
|
|
1692
|
+
rules: {
|
|
1693
|
+
ruleDeadLibShims,
|
|
1694
|
+
ruleObsoleteVitePlugins,
|
|
1695
|
+
ruleDeadRuntimeShim,
|
|
1696
|
+
ruleSiteLocalGlobals,
|
|
1697
|
+
ruleVtexShimRegression,
|
|
1698
|
+
ruleVtexProxyHandlerMissing,
|
|
1699
|
+
ruleHtmxResidue,
|
|
1700
|
+
ruleLocalWidgetsTypes,
|
|
1701
|
+
ruleFrameworkTodos,
|
|
1702
|
+
ruleLocalFrameworkDuplicate,
|
|
1703
|
+
ruleLockfileMultiple,
|
|
1704
|
+
ruleLockfileMissing,
|
|
1705
|
+
ruleLockfileDrift,
|
|
1706
|
+
rulePackageManagerMissing,
|
|
1707
|
+
},
|
|
1708
|
+
};
|