@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- package/scripts/lib/legacyArtifact.ts +0 -20
|
@@ -5,12 +5,15 @@ import type { MigrationContext, TransformResult, SectionMeta } from "./types";
|
|
|
5
5
|
import { log, logPhase } from "./types";
|
|
6
6
|
import { transformImports } from "./transforms/imports";
|
|
7
7
|
import { transformJsx } from "./transforms/jsx";
|
|
8
|
+
import { transformPicture } from "./transforms/picture";
|
|
9
|
+
import { transformTimerTypes } from "./transforms/timer-types";
|
|
8
10
|
import { transformFreshApis } from "./transforms/fresh-apis";
|
|
9
11
|
import { transformCtxCompat } from "./transforms/ctx-compat";
|
|
10
12
|
import { transformDenoIsms } from "./transforms/deno-isms";
|
|
11
13
|
import { transformTailwind } from "./transforms/tailwind";
|
|
12
14
|
import { transformDeadCode } from "./transforms/dead-code";
|
|
13
15
|
import { transformHtmxOnEvents } from "./transforms/htmx-on-events";
|
|
16
|
+
import { transformUseScriptHandlers } from "./transforms/use-script-handlers";
|
|
14
17
|
import { createSectionConventionsTransform } from "./transforms/section-conventions";
|
|
15
18
|
|
|
16
19
|
/** Map of section path → metadata, populated per-run */
|
|
@@ -28,7 +31,7 @@ function getSectionMeta(ctx: MigrationContext, relPath: string): SectionMeta | u
|
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Cached per-run section-conventions closure. Built once from the
|
|
31
|
-
* resolved config sets (`ctx.config.sectionConventions`), so
|
|
34
|
+
* resolved config sets (`ctx.config.sectionConventions`), so the built-in
|
|
32
35
|
* defaults still apply when no config file exists.
|
|
33
36
|
*/
|
|
34
37
|
let cachedSectionTransform:
|
|
@@ -67,10 +70,13 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
|
|
|
67
70
|
{ name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
|
|
68
71
|
{ name: "jsx", fn: transformJsx },
|
|
69
72
|
{ name: "htmx-on-events", fn: transformHtmxOnEvents },
|
|
73
|
+
{ name: "use-script-handlers", fn: transformUseScriptHandlers },
|
|
70
74
|
{ name: "fresh-apis", fn: transformFreshApis },
|
|
71
75
|
{ name: "ctx-compat", fn: transformCtxCompat },
|
|
72
76
|
{ name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
|
|
73
77
|
{ name: "deno-isms", fn: transformDenoIsms },
|
|
78
|
+
{ name: "timer-types", fn: transformTimerTypes },
|
|
79
|
+
{ name: "picture", fn: transformPicture },
|
|
74
80
|
{ name: "tailwind", fn: transformTailwind },
|
|
75
81
|
];
|
|
76
82
|
|
|
@@ -146,6 +152,37 @@ export function transform(ctx: MigrationContext): void {
|
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
// Flag usePartialSection (Fresh load-more / "Ver mais" pattern) with an
|
|
156
|
+
// actionable useLoadMore recipe instead of a generic HTMX warning.
|
|
157
|
+
if (/usePartialSection/.test(result.content)) {
|
|
158
|
+
const hasAppend = /mode\s*:\s*["']append["']/.test(result.content);
|
|
159
|
+
ctx.manualReviewItems.push({
|
|
160
|
+
file: targetPath,
|
|
161
|
+
reason:
|
|
162
|
+
`usePartialSection${hasAppend ? " (mode:append)" : ""} detected — ` +
|
|
163
|
+
"this is the Fresh load-more / \"Ver mais\" pagination pattern. " +
|
|
164
|
+
"In TanStack it is a no-op stub; the button navigates replacing all products instead of appending. " +
|
|
165
|
+
"Convert to useLoadMore from @decocms/blocks/hooks:\n\n" +
|
|
166
|
+
' import { useLoadMore } from "@decocms/blocks/hooks"\n\n' +
|
|
167
|
+
' // At the top of your component (must be "use client"):\n' +
|
|
168
|
+
" const { pages, loadMore, loading, hasMore } = useLoadMore(\n" +
|
|
169
|
+
" props.page ?? { products: [], pageInfo: {} },\n" +
|
|
170
|
+
' "LOADER_KEY" // replace with your loader path, e.g.\n' +
|
|
171
|
+
' // "apps/vtex.ts/loaders/intelligentSearch/productListingPage.ts"\n' +
|
|
172
|
+
" )\n" +
|
|
173
|
+
" const allProducts = pages.flatMap(p => p.products ?? [])\n\n" +
|
|
174
|
+
" // Replace the usePartialSection anchor/button with:\n" +
|
|
175
|
+
" {hasMore && (\n" +
|
|
176
|
+
' <button onClick={loadMore} disabled={loading}>\n' +
|
|
177
|
+
' {loading ? "Carregando..." : "Ver mais"}\n' +
|
|
178
|
+
" </button>\n" +
|
|
179
|
+
" )}\n\n" +
|
|
180
|
+
"Add \"use client\" to the top of the file. " +
|
|
181
|
+
"See deco-to-tanstack-migration skill, 'Ver mais / Load More' section.",
|
|
182
|
+
severity: "warning",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
149
186
|
// Flag files with hx-on:click that use useScript (simpler pattern)
|
|
150
187
|
if (/hx-on:click=\{useScript/.test(result.content)) {
|
|
151
188
|
ctx.manualReviewItems.push({
|
|
@@ -190,7 +227,7 @@ export function transform(ctx: MigrationContext): void {
|
|
|
190
227
|
"that ships HTML fragments and swaps them client-side. It does not work on TanStack Start. " +
|
|
191
228
|
"Recipes: " +
|
|
192
229
|
"(1) Self-contained UI toggles → keep state in React (`useState` + event handlers); " +
|
|
193
|
-
"(2) Form submissions / mutations → `createServerFn` + `useMutation` (see
|
|
230
|
+
"(2) Form submissions / mutations → `createServerFn` + `useMutation` (see a production storefront for canonical examples); " +
|
|
194
231
|
"(3) Ad-hoc data fetches → call the loader/action via `~/server/invoke` and store results in `useState`. " +
|
|
195
232
|
"Remove the import after refactoring, then delete `src/sections/Component.tsx`.",
|
|
196
233
|
severity: "error",
|
|
@@ -233,6 +270,74 @@ export function transform(ctx: MigrationContext): void {
|
|
|
233
270
|
// the actual file and rewrite the import.
|
|
234
271
|
if (!ctx.dryRun) {
|
|
235
272
|
fixIslandImports(ctx);
|
|
273
|
+
reconcileIconCasing(ctx);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `<Icon id="close">` → `<Icon id="Close">`. Deco-fresh (Deno) didn't strictly
|
|
279
|
+
* typecheck, so sites often used lowercase icon ids that don't match their own
|
|
280
|
+
* PascalCase `AvailableIcons` union — the stricter TanStack typecheck then fails
|
|
281
|
+
* ("Type '\"close\"' is not assignable to type 'AvailableIcons'"). This
|
|
282
|
+
* case-corrects any `<Icon id="X">` whose name matches an AvailableIcons entry
|
|
283
|
+
* case-insensitively. Names with NO match (a genuinely missing icon) are left
|
|
284
|
+
* as-is and flagged for manual review — adding them to the type + sprite is a
|
|
285
|
+
* design decision, not a rename.
|
|
286
|
+
*/
|
|
287
|
+
export function reconcileIconCasing(ctx: MigrationContext): void {
|
|
288
|
+
const srcDir = path.join(ctx.sourceDir, "src");
|
|
289
|
+
if (!fs.existsSync(srcDir)) return;
|
|
290
|
+
|
|
291
|
+
const walk = (dir: string, visit: (file: string, content: string) => void) => {
|
|
292
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
293
|
+
if (entry.isDirectory()) {
|
|
294
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
295
|
+
walk(path.join(dir, entry.name), visit);
|
|
296
|
+
} else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
|
|
297
|
+
visit(path.join(dir, entry.name), fs.readFileSync(path.join(dir, entry.name), "utf-8"));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// 1. Canonical icon names from the declared `AvailableIcons` union (if any).
|
|
303
|
+
const canonical = new Map<string, string>(); // lowercased → canonical casing
|
|
304
|
+
walk(srcDir, (_file, content) => {
|
|
305
|
+
const m = content.match(/export type AvailableIcons\s*=\s*([\s\S]*?);/);
|
|
306
|
+
if (m) for (const n of m[1].matchAll(/["']([A-Za-z0-9_-]+)["']/g)) canonical.set(n[1].toLowerCase(), n[1]);
|
|
307
|
+
});
|
|
308
|
+
if (canonical.size === 0) return;
|
|
309
|
+
|
|
310
|
+
// 2. Case-correct `<Icon id="X">` usages; flag unmatched names once per file.
|
|
311
|
+
const iconIdRe = /(<Icon\b[^>]*?\bid=["'])([A-Za-z0-9_-]+)(["'])/g;
|
|
312
|
+
let corrected = 0;
|
|
313
|
+
walk(srcDir, (file, content) => {
|
|
314
|
+
let modified = false;
|
|
315
|
+
const flagged = new Set<string>();
|
|
316
|
+
const next = content.replace(iconIdRe, (whole, pre, name, post) => {
|
|
317
|
+
const canon = canonical.get(name.toLowerCase());
|
|
318
|
+
if (canon) {
|
|
319
|
+
if (canon !== name) {
|
|
320
|
+
modified = true;
|
|
321
|
+
corrected++;
|
|
322
|
+
return `${pre}${canon}${post}`;
|
|
323
|
+
}
|
|
324
|
+
return whole;
|
|
325
|
+
}
|
|
326
|
+
if (!flagged.has(name)) {
|
|
327
|
+
flagged.add(name);
|
|
328
|
+
ctx.manualReviewItems.push({
|
|
329
|
+
file: path.relative(ctx.sourceDir, file).replace(/\\/g, "/"),
|
|
330
|
+
reason: `<Icon id="${name}"> is not in AvailableIcons — add it to the type in Icon.tsx and the sprite, or the icon renders blank.`,
|
|
331
|
+
severity: "warning",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return whole;
|
|
335
|
+
});
|
|
336
|
+
if (modified) fs.writeFileSync(file, next, "utf-8");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (corrected > 0) {
|
|
340
|
+
console.log(` Icon casing: corrected ${corrected} <Icon id> to match AvailableIcons`);
|
|
236
341
|
}
|
|
237
342
|
}
|
|
238
343
|
|
|
@@ -17,6 +17,7 @@ import type { MigrationContext } from "./types";
|
|
|
17
17
|
function makeCtx(sourceDir: string): MigrationContext {
|
|
18
18
|
return {
|
|
19
19
|
sourceDir,
|
|
20
|
+
layout: "classic",
|
|
20
21
|
siteName: "test-site",
|
|
21
22
|
platform: "custom",
|
|
22
23
|
vtexAccount: null,
|
|
@@ -25,6 +26,18 @@ function makeCtx(sourceDir: string): MigrationContext {
|
|
|
25
26
|
discoveredNpmDeps: {},
|
|
26
27
|
themeColors: {},
|
|
27
28
|
fontFamily: null,
|
|
29
|
+
googleFonts: { preconnects: [], stylesheets: [] },
|
|
30
|
+
tailwindConfig: {
|
|
31
|
+
colors: {},
|
|
32
|
+
fontFamily: {},
|
|
33
|
+
screens: {},
|
|
34
|
+
animations: {},
|
|
35
|
+
keyframes: {},
|
|
36
|
+
safelist: [],
|
|
37
|
+
safelistPatterns: [],
|
|
38
|
+
plugins: [],
|
|
39
|
+
reviewItems: [],
|
|
40
|
+
},
|
|
28
41
|
files: [],
|
|
29
42
|
sectionMetas: [],
|
|
30
43
|
islandClassifications: [],
|
|
@@ -28,7 +28,9 @@ function makeCtx(sourceDir: string): MigrationContext {
|
|
|
28
28
|
importMap: {},
|
|
29
29
|
discoveredNpmDeps: {},
|
|
30
30
|
themeColors: {},
|
|
31
|
-
tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
|
|
31
|
+
tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, animations: {}, keyframes: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
|
|
32
|
+
layout: "classic",
|
|
33
|
+
googleFonts: { preconnects: [], stylesheets: [] },
|
|
32
34
|
fontFamily: null,
|
|
33
35
|
files: [],
|
|
34
36
|
sectionMetas: [],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { parseJsonc } from "../lib/jsonc";
|
|
3
4
|
import type { MigrationContext } from "./types";
|
|
4
5
|
import { logPhase } from "./types";
|
|
5
6
|
|
|
@@ -20,8 +21,15 @@ const REQUIRED_FILES = [
|
|
|
20
21
|
// Deploy / preview / sync-secrets pipelines are owned by Cloudflare
|
|
21
22
|
// Workers Builds (D6.3) -- configured in the CF dashboard, not via
|
|
22
23
|
// GitHub workflow files in the site repo.
|
|
23
|
-
".github/workflows/regen-blocks.yml",
|
|
24
24
|
".github/workflows/lockfile-check.yml",
|
|
25
|
+
".github/workflows/ci.yml",
|
|
26
|
+
".github/workflows/main-push-guard.yml",
|
|
27
|
+
".github/workflows/playwright.yml",
|
|
28
|
+
".github/workflows/react-doctor.yml",
|
|
29
|
+
".github/workflows/parity.yml",
|
|
30
|
+
".github/workflows/sync-blocks-bot.yml",
|
|
31
|
+
"tools/gates/no-suppressions.sh",
|
|
32
|
+
"playwright.config.ts",
|
|
25
33
|
"knip.config.ts",
|
|
26
34
|
".prettierrc",
|
|
27
35
|
"src/server.ts",
|
|
@@ -69,9 +77,7 @@ export const checks: Check[] = [
|
|
|
69
77
|
name: "All scaffolded files exist",
|
|
70
78
|
severity: "error",
|
|
71
79
|
fn: (ctx) => {
|
|
72
|
-
const missing = REQUIRED_FILES.filter(
|
|
73
|
-
(f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
|
|
74
|
-
);
|
|
80
|
+
const missing = REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
|
|
75
81
|
if (missing.length > 0) {
|
|
76
82
|
console.log(` Missing: ${missing.join(", ")}`);
|
|
77
83
|
return false;
|
|
@@ -79,13 +85,50 @@ export const checks: Check[] = [
|
|
|
79
85
|
return true;
|
|
80
86
|
},
|
|
81
87
|
},
|
|
88
|
+
{
|
|
89
|
+
// Fast Deploy needs THREE things and is inert unless all three are present:
|
|
90
|
+
// the DECO_KV binding, DECO_FAST_DEPLOY=1, and setup.ts handing the KV
|
|
91
|
+
// resolver to blocks-admin. Two of three is the worst state — it looks
|
|
92
|
+
// configured, the Studio publish reports success, and nothing reaches KV.
|
|
93
|
+
name: "Fast Deploy wired end to end",
|
|
94
|
+
severity: "error",
|
|
95
|
+
fn: (ctx) => {
|
|
96
|
+
const missing: string[] = [];
|
|
97
|
+
const wranglerPath = path.join(ctx.sourceDir, "wrangler.jsonc");
|
|
98
|
+
try {
|
|
99
|
+
const cfg = parseJsonc(fs.readFileSync(wranglerPath, "utf-8")) as {
|
|
100
|
+
kv_namespaces?: Array<{ binding?: string }>;
|
|
101
|
+
vars?: Record<string, unknown>;
|
|
102
|
+
};
|
|
103
|
+
const flag = cfg.vars?.DECO_FAST_DEPLOY;
|
|
104
|
+
if (!(cfg.kv_namespaces ?? []).some((n) => n?.binding === "DECO_KV")) {
|
|
105
|
+
missing.push("wrangler.jsonc kv_namespaces is missing the DECO_KV binding");
|
|
106
|
+
}
|
|
107
|
+
if (flag !== "1" && flag !== "true") {
|
|
108
|
+
missing.push('wrangler.jsonc vars.DECO_FAST_DEPLOY is not "1"');
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
missing.push(`wrangler.jsonc could not be parsed: ${(e as Error).message}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const setupPath = path.join(ctx.sourceDir, "src", "setup.ts");
|
|
115
|
+
const setup = fs.existsSync(setupPath) ? fs.readFileSync(setupPath, "utf-8") : "";
|
|
116
|
+
if (!setup.includes("setupTanstackFastDeploy()")) {
|
|
117
|
+
missing.push("src/setup.ts does not call setupTanstackFastDeploy()");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (missing.length > 0) {
|
|
121
|
+
for (const m of missing) console.log(` ${m}`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
82
127
|
{
|
|
83
128
|
name: "Old artifacts removed",
|
|
84
129
|
severity: "error",
|
|
85
130
|
fn: (ctx) => {
|
|
86
|
-
const remaining = MUST_NOT_EXIST.filter(
|
|
87
|
-
(f) => fs.existsSync(path.join(ctx.sourceDir, f)),
|
|
88
|
-
);
|
|
131
|
+
const remaining = MUST_NOT_EXIST.filter((f) => fs.existsSync(path.join(ctx.sourceDir, f)));
|
|
89
132
|
if (remaining.length > 0) {
|
|
90
133
|
console.log(` Still exists: ${remaining.join(", ")}`);
|
|
91
134
|
return false;
|
|
@@ -136,14 +179,16 @@ export const checks: Check[] = [
|
|
|
136
179
|
},
|
|
137
180
|
},
|
|
138
181
|
{
|
|
139
|
-
name:
|
|
182
|
+
name: "No class= in JSX (should be className=)",
|
|
140
183
|
severity: "warning",
|
|
141
184
|
fn: (ctx) => {
|
|
142
185
|
const srcDir = path.join(ctx.sourceDir, "src");
|
|
143
186
|
if (!fs.existsSync(srcDir)) return true;
|
|
144
187
|
const bad = findFilesWithPattern(srcDir, /<[a-zA-Z][^>]*\sclass\s*=/);
|
|
145
188
|
if (bad.length > 0) {
|
|
146
|
-
console.log(
|
|
189
|
+
console.log(
|
|
190
|
+
` Still has class= in JSX: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ` (+${bad.length - 5} more)` : ""}`,
|
|
191
|
+
);
|
|
147
192
|
return false;
|
|
148
193
|
}
|
|
149
194
|
return true;
|
|
@@ -158,12 +203,8 @@ export const checks: Check[] = [
|
|
|
158
203
|
console.log(" public/ directory missing");
|
|
159
204
|
return false;
|
|
160
205
|
}
|
|
161
|
-
const hasSprites = fs.existsSync(
|
|
162
|
-
|
|
163
|
-
);
|
|
164
|
-
const hasFavicon = fs.existsSync(
|
|
165
|
-
path.join(publicDir, "favicon.ico"),
|
|
166
|
-
);
|
|
206
|
+
const hasSprites = fs.existsSync(path.join(publicDir, "sprites.svg"));
|
|
207
|
+
const hasFavicon = fs.existsSync(path.join(publicDir, "favicon.ico"));
|
|
167
208
|
if (!hasSprites) console.log(" Missing: public/sprites.svg");
|
|
168
209
|
if (!hasFavicon) console.log(" Missing: public/favicon.ico");
|
|
169
210
|
return hasSprites && hasFavicon;
|
|
@@ -272,7 +313,9 @@ export const checks: Check[] = [
|
|
|
272
313
|
const bad = findFilesWithPattern(srcDir, /(?<!<(?:img|Image)[^>]*)-z-\d+/);
|
|
273
314
|
if (bad.length > 0) {
|
|
274
315
|
console.log(` Negative z-index on non-image elements: ${bad.join(", ")}`);
|
|
275
|
-
console.log(
|
|
316
|
+
console.log(
|
|
317
|
+
` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`,
|
|
318
|
+
);
|
|
276
319
|
return false;
|
|
277
320
|
}
|
|
278
321
|
return true;
|
|
@@ -319,7 +362,9 @@ export const checks: Check[] = [
|
|
|
319
362
|
// Check that runtime.ts exists and has the invoke proxy
|
|
320
363
|
const runtimePath = path.join(srcDir, "runtime.ts");
|
|
321
364
|
if (!fs.existsSync(runtimePath)) {
|
|
322
|
-
console.log(
|
|
365
|
+
console.log(
|
|
366
|
+
` Files use invoke.* but src/runtime.ts is missing: ${hasInvoke.join(", ")}`,
|
|
367
|
+
);
|
|
323
368
|
return false;
|
|
324
369
|
}
|
|
325
370
|
}
|
|
@@ -374,7 +419,10 @@ export const checks: Check[] = [
|
|
|
374
419
|
fn: (ctx) => {
|
|
375
420
|
const srcDir = path.join(ctx.sourceDir, "src");
|
|
376
421
|
if (!fs.existsSync(srcDir)) return true;
|
|
377
|
-
const bad = findFilesWithPattern(
|
|
422
|
+
const bad = findFilesWithPattern(
|
|
423
|
+
srcDir,
|
|
424
|
+
/\bhx-(?:get|post|put|delete|patch|trigger|target|swap|on|indicator|sync|select)\b/,
|
|
425
|
+
);
|
|
378
426
|
if (bad.length > 0) {
|
|
379
427
|
console.log(` HTMX attributes found (needs manual React migration): ${bad.join(", ")}`);
|
|
380
428
|
return false;
|
|
@@ -428,7 +476,9 @@ export const checks: Check[] = [
|
|
|
428
476
|
];
|
|
429
477
|
const unique = [...new Set(bad)];
|
|
430
478
|
if (unique.length > 0) {
|
|
431
|
-
console.log(
|
|
479
|
+
console.log(
|
|
480
|
+
` Still references frozen @decocms/start or @decocms/apps/* specifiers: ${unique.join(", ")}`,
|
|
481
|
+
);
|
|
432
482
|
return false;
|
|
433
483
|
}
|
|
434
484
|
return true;
|
|
@@ -445,9 +495,7 @@ export const checks: Check[] = [
|
|
|
445
495
|
"src/setup/commerce-init.ts",
|
|
446
496
|
"src/setup/section-loaders.ts",
|
|
447
497
|
];
|
|
448
|
-
const missing = setupFiles.filter(
|
|
449
|
-
(f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
|
|
450
|
-
);
|
|
498
|
+
const missing = setupFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
|
|
451
499
|
if (missing.length > 0) {
|
|
452
500
|
console.log(` Missing setup infrastructure: ${missing.join(", ")}`);
|
|
453
501
|
return false;
|
|
@@ -463,9 +511,13 @@ export const checks: Check[] = [
|
|
|
463
511
|
if (fs.existsSync(islandsDir)) {
|
|
464
512
|
try {
|
|
465
513
|
const files = fs.readdirSync(islandsDir, { recursive: true });
|
|
466
|
-
const tsxFiles = (files as string[]).filter(
|
|
514
|
+
const tsxFiles = (files as string[]).filter(
|
|
515
|
+
(f: string) => f.endsWith(".tsx") || f.endsWith(".ts"),
|
|
516
|
+
);
|
|
467
517
|
if (tsxFiles.length > 0) {
|
|
468
|
-
console.log(
|
|
518
|
+
console.log(
|
|
519
|
+
` src/islands/ still has ${tsxFiles.length} files — should be moved to components/`,
|
|
520
|
+
);
|
|
469
521
|
return false;
|
|
470
522
|
}
|
|
471
523
|
} catch {}
|
|
@@ -482,9 +534,7 @@ export const checks: Check[] = [
|
|
|
482
534
|
"src/hooks/useUser.ts",
|
|
483
535
|
"src/hooks/useWishlist.ts",
|
|
484
536
|
];
|
|
485
|
-
const missing = hookFiles.filter(
|
|
486
|
-
(f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
|
|
487
|
-
);
|
|
537
|
+
const missing = hookFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
|
|
488
538
|
if (missing.length > 0) {
|
|
489
539
|
console.log(` Missing hooks: ${missing.join(", ")}`);
|
|
490
540
|
return false;
|
|
@@ -502,9 +552,7 @@ export const checks: Check[] = [
|
|
|
502
552
|
"src/types/deco.ts",
|
|
503
553
|
"src/types/commerce-app.ts",
|
|
504
554
|
];
|
|
505
|
-
const missing = typeFiles.filter(
|
|
506
|
-
(f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
|
|
507
|
-
);
|
|
555
|
+
const missing = typeFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
|
|
508
556
|
if (missing.length > 0) {
|
|
509
557
|
console.log(` Missing type files: ${missing.join(", ")}`);
|
|
510
558
|
return false;
|
|
@@ -525,7 +573,8 @@ function findFilesWithPattern(
|
|
|
525
573
|
for (const entry of entries) {
|
|
526
574
|
const fullPath = path.join(dir, entry.name);
|
|
527
575
|
if (entry.isDirectory()) {
|
|
528
|
-
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
|
|
576
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
|
|
577
|
+
continue;
|
|
529
578
|
findFilesWithPattern(fullPath, pattern, results, root);
|
|
530
579
|
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
531
580
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
@@ -558,7 +607,8 @@ function findMatchesWithPattern(
|
|
|
558
607
|
for (const entry of entries) {
|
|
559
608
|
const fullPath = path.join(dir, entry.name);
|
|
560
609
|
if (entry.isDirectory()) {
|
|
561
|
-
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
|
|
610
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
|
|
611
|
+
continue;
|
|
562
612
|
findMatchesWithPattern(fullPath, pattern, results, root);
|
|
563
613
|
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
564
614
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
@@ -586,7 +636,11 @@ export function verify(ctx: MigrationContext): boolean {
|
|
|
586
636
|
|
|
587
637
|
for (const check of checks) {
|
|
588
638
|
const pass = check.fn(ctx);
|
|
589
|
-
const icon = pass
|
|
639
|
+
const icon = pass
|
|
640
|
+
? "\x1b[32m✓\x1b[0m"
|
|
641
|
+
: check.severity === "error"
|
|
642
|
+
? "\x1b[31m✗\x1b[0m"
|
|
643
|
+
: "\x1b[33m⚠\x1b[0m";
|
|
590
644
|
console.log(` ${icon} ${check.name}`);
|
|
591
645
|
if (!pass) {
|
|
592
646
|
if (check.severity === "error") errors++;
|
|
@@ -1090,8 +1090,8 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
|
|
|
1090
1090
|
sitePath: "src/sdk/useSuggestions.ts",
|
|
1091
1091
|
canonicalImport: "@decocms/blocks/sdk/useSuggestions",
|
|
1092
1092
|
// Fingerprint: hand-rolled hook with the module-level signal +
|
|
1093
|
-
// serial-queue + latestQuery cancel pattern.
|
|
1094
|
-
//
|
|
1093
|
+
// serial-queue + latestQuery cancel pattern. Multiple production sites
|
|
1094
|
+
// independently invented this exact shape. Sites that
|
|
1095
1095
|
// already adopted `createUseSuggestions(…)` factory calls won't
|
|
1096
1096
|
// match this signature.
|
|
1097
1097
|
contentSignature: [
|
|
@@ -1702,9 +1702,21 @@ const ruleVtexProxyHandlerMissing: Rule = {
|
|
|
1702
1702
|
const MODULE_SIGNAL_RE =
|
|
1703
1703
|
/^(?:export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:\w+\.)?signal\s*\(/m;
|
|
1704
1704
|
|
|
1705
|
+
// Well-known signals returned by useUI(). These are never declared with
|
|
1706
|
+
// `const x = signal(...)` at module scope — they come from a hook — so
|
|
1707
|
+
// MODULE_SIGNAL_RE won't catch them. The list is stable: it's the public
|
|
1708
|
+
// API surface of useUI() across all deco storefronts.
|
|
1709
|
+
const WELL_KNOWN_UI_SIGNALS = new Set([
|
|
1710
|
+
"displayCart",
|
|
1711
|
+
"displayMenu",
|
|
1712
|
+
"displayNav",
|
|
1713
|
+
"displaySearchbar",
|
|
1714
|
+
"displaySearchPopup",
|
|
1715
|
+
]);
|
|
1716
|
+
|
|
1705
1717
|
const ruleSignalValueReads: Rule = {
|
|
1706
1718
|
id: "signal-value-reads",
|
|
1707
|
-
title: "
|
|
1719
|
+
title: "Signal .value reads without subscription (freezes UI)",
|
|
1708
1720
|
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1709
1721
|
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
1710
1722
|
|
|
@@ -1724,7 +1736,13 @@ const ruleSignalValueReads: Rule = {
|
|
|
1724
1736
|
}
|
|
1725
1737
|
}
|
|
1726
1738
|
|
|
1727
|
-
|
|
1739
|
+
// Also check well-known useUI() signals — they never appear as module-level
|
|
1740
|
+
// declarations, so MODULE_SIGNAL_RE never picks them up.
|
|
1741
|
+
for (const name of WELL_KNOWN_UI_SIGNALS) {
|
|
1742
|
+
if (!moduleSignals.has(name)) {
|
|
1743
|
+
moduleSignals.set(name, "useUI()");
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1728
1746
|
|
|
1729
1747
|
// Pre-compile per-signal regexes once — avoids N×M compilations in Pass 2.
|
|
1730
1748
|
const valueReMap = new Map<string, RegExp>(
|
|
@@ -1732,7 +1750,8 @@ const ruleSignalValueReads: Rule = {
|
|
|
1732
1750
|
);
|
|
1733
1751
|
|
|
1734
1752
|
// Pass 2: for each .tsx file, check per-signal whether .value is read
|
|
1735
|
-
// without a matching
|
|
1753
|
+
// without a matching subscription in the same file.
|
|
1754
|
+
// Accepts either useStore(name.store or useSignalValue(name as subscriptions.
|
|
1736
1755
|
const tsxFiles = tsFiles.filter((f) => f.endsWith(".tsx"));
|
|
1737
1756
|
const findings: Finding[] = [];
|
|
1738
1757
|
|
|
@@ -1743,18 +1762,17 @@ const ruleSignalValueReads: Rule = {
|
|
|
1743
1762
|
for (const [name, declaredIn] of moduleSignals) {
|
|
1744
1763
|
const valueRe = valueReMap.get(name)!;
|
|
1745
1764
|
if (!valueRe.test(content)) continue;
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const subscribedRe = new RegExp(`\\buseStore\\s*\\(\\s*${name}\\.store\\b`);
|
|
1765
|
+
const subscribedRe = new RegExp(
|
|
1766
|
+
`\\buseStore\\s*\\(\\s*${name}\\.store\\b|\\buseSignalValue\\s*\\(\\s*${name}\\b`,
|
|
1767
|
+
);
|
|
1750
1768
|
if (subscribedRe.test(content)) continue;
|
|
1751
1769
|
|
|
1752
1770
|
findings.push({
|
|
1753
1771
|
rule: "signal-value-reads",
|
|
1754
1772
|
severity: "warning",
|
|
1755
1773
|
file: rel,
|
|
1756
|
-
message: `'${name}.value' read without
|
|
1757
|
-
fix: `
|
|
1774
|
+
message: `'${name}.value' read without subscription — component will not re-render when the signal changes`,
|
|
1775
|
+
fix: `Replace with \`const ${name}Value = useSignalValue(${name})\` (from ~/sdk/signal) or \`useStore(${name}.store)\``,
|
|
1758
1776
|
meta: { signalName: name, declaredIn },
|
|
1759
1777
|
});
|
|
1760
1778
|
}
|
|
@@ -183,7 +183,7 @@ describe("classifyShimExports — type/interface declarations", () => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
-
describe("classifyShimExports — real
|
|
186
|
+
describe("classifyShimExports — real production storefront fixtures", () => {
|
|
187
187
|
it("vtex-segment.ts (mixed: stub + functional)", () => {
|
|
188
188
|
const code = `
|
|
189
189
|
export function getSegmentFromBag(_req?: any): Record<string, unknown> | null {
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
* would need real semantic analysis. False negatives are tolerable; the
|
|
27
27
|
* rule still warns when *any* imported symbol from the shim is a clear
|
|
28
28
|
* stub, which is enough to surface the real-world regressions we've
|
|
29
|
-
* actually seen on production sites (
|
|
29
|
+
* actually seen on production sites (e.g. `getSegmentFromBag`,
|
|
30
30
|
* `getISCookiesFromBag`, `toProduct`).
|
|
31
31
|
*
|
|
32
32
|
* Implementation note — string parsing, not a real TypeScript AST. The
|
|
33
|
-
* shim files are tiny by design (the
|
|
33
|
+
* shim files are tiny by design (the real ones are 1-39 lines).
|
|
34
34
|
* A balanced-brace body extractor + small set of stub patterns covers
|
|
35
35
|
* every case observed on real sites. If this ever needs to handle
|
|
36
36
|
* decorators, generics on consts, or weirder JSX forms, the right move
|
|
@@ -19,6 +19,26 @@ function makeFs(paths: string[]): FsLike {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* FsLike where each dir path is tagged as populated (has files) or empty.
|
|
24
|
+
* `dirsWithFiles` exist AND report `hasFiles: true`; `emptyDirs` exist but
|
|
25
|
+
* report `hasFiles: false` (leftover stubs).
|
|
26
|
+
*/
|
|
27
|
+
function makeFsWithFiles(dirsWithFiles: string[], emptyDirs: string[] = []): FsLike {
|
|
28
|
+
const norm = (p: string) => p.replace(/\\/g, "/");
|
|
29
|
+
const populated = new Set(dirsWithFiles.map(norm));
|
|
30
|
+
const empty = new Set(emptyDirs.map(norm));
|
|
31
|
+
return {
|
|
32
|
+
existsSync(p) {
|
|
33
|
+
const n = norm(p);
|
|
34
|
+
return populated.has(n) || empty.has(n);
|
|
35
|
+
},
|
|
36
|
+
hasFiles(p) {
|
|
37
|
+
return populated.has(norm(p));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
const SITE = "/site";
|
|
23
43
|
|
|
24
44
|
describe("detectSourceLayout — classic layout", () => {
|
|
@@ -64,6 +84,29 @@ describe("detectSourceLayout — mixed layout", () => {
|
|
|
64
84
|
});
|
|
65
85
|
});
|
|
66
86
|
|
|
87
|
+
describe("detectSourceLayout — empty leftover dirs (hasFiles)", () => {
|
|
88
|
+
it("ignores an EMPTY root sections/ next to a populated src/sections/ → modern", () => {
|
|
89
|
+
// The real portal-davinci case: leftover empty `sections/` + real `src/sections/`.
|
|
90
|
+
const fs = makeFsWithFiles(["/site/src/sections"], ["/site/sections"]);
|
|
91
|
+
expect(detectSourceLayout(SITE, fs)).toBe("modern");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores an EMPTY src/sections/ next to a populated root sections/ → classic", () => {
|
|
95
|
+
const fs = makeFsWithFiles(["/site/sections"], ["/site/src/sections"]);
|
|
96
|
+
expect(detectSourceLayout(SITE, fs)).toBe("classic");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("still flags genuinely mixed (both populated) as mixed", () => {
|
|
100
|
+
const fs = makeFsWithFiles(["/site/sections", "/site/src/sections"]);
|
|
101
|
+
expect(detectSourceLayout(SITE, fs)).toBe("mixed");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("empty dirs on both sides → empty", () => {
|
|
105
|
+
const fs = makeFsWithFiles([], ["/site/sections", "/site/src/sections"]);
|
|
106
|
+
expect(detectSourceLayout(SITE, fs)).toBe("empty");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
67
110
|
describe("detectSourceLayout — empty layout", () => {
|
|
68
111
|
it("returns empty when neither root nor src/ has recognised dirs", () => {
|
|
69
112
|
const fs = makeFs(["/site/package.json", "/site/README.md"]);
|
|
@@ -31,19 +31,46 @@ const RECOGNISED_DIRS = ["sections", "islands", "components", "loaders", "action
|
|
|
31
31
|
|
|
32
32
|
export interface FsLike {
|
|
33
33
|
existsSync(p: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* True when `p` is a directory containing at least one FILE (recursively).
|
|
36
|
+
* An empty leftover dir (e.g. a `sections/` stub next to a populated
|
|
37
|
+
* `src/sections/`) must NOT count as a layout, or it triggers a false
|
|
38
|
+
* "mixed layout" abort. Optional so existing `{ existsSync }` stubs keep
|
|
39
|
+
* working — when absent, mere existence counts (previous behaviour).
|
|
40
|
+
*/
|
|
41
|
+
hasFiles?(p: string): boolean;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
/** Recursively: does this dir hold at least one file? Empty dirs → false. */
|
|
45
|
+
function realHasFiles(p: string): boolean {
|
|
46
|
+
let entries: fs.Dirent[];
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(p, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
if (e.isFile()) return true;
|
|
54
|
+
if (e.isDirectory() && realHasFiles(path.join(p, e.name))) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const realFs: FsLike = { existsSync: fs.existsSync, hasFiles: realHasFiles };
|
|
37
60
|
|
|
38
61
|
/**
|
|
39
62
|
* Classify the source directory's layout. Pure function — accepts
|
|
40
63
|
* a `FsLike` so unit tests can stub the disk without mocking node:fs.
|
|
41
64
|
*/
|
|
42
65
|
export function detectSourceLayout(sourceDir: string, fsAdapter: FsLike = realFs): SourceLayout {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
66
|
+
// A recognised dir only "counts" if it exists AND holds files — an empty
|
|
67
|
+
// leftover `sections/` next to a populated `src/sections/` is not a real
|
|
68
|
+
// second layout and must not trigger a "mixed" abort. Falls back to mere
|
|
69
|
+
// existence when the adapter doesn't implement hasFiles (old test stubs).
|
|
70
|
+
const counts = (p: string): boolean =>
|
|
71
|
+
fsAdapter.existsSync(p) && (fsAdapter.hasFiles ? fsAdapter.hasFiles(p) : true);
|
|
72
|
+
const hasRootDir = RECOGNISED_DIRS.some((d) => counts(path.join(sourceDir, d)));
|
|
73
|
+
const hasSrcDir = RECOGNISED_DIRS.some((d) => counts(path.join(sourceDir, "src", d)));
|
|
47
74
|
|
|
48
75
|
if (hasRootDir && hasSrcDir) return "mixed";
|
|
49
76
|
if (hasSrcDir) return "modern";
|