diff --git a/packages/core/src/loop/boringstack/reachability.ts b/packages/core/src/loop/boringstack/reachability.ts index bc756d65..bd8ef809 100644 --- a/packages/core/src/loop/boringstack/reachability.ts +++ b/packages/core/src/loop/boringstack/reachability.ts @@ -56,10 +56,7 @@ export function checkFeatureReachable( } // 2. API registration: the resource must be mounted in the API route table. - if ( - inputs.apiRoutes !== null && - !inputs.apiRoutes.includes(`${camel}Routes`) - ) { + if (inputs.apiRoutes !== null && !registersRoutes(inputs.apiRoutes, camel)) { problems.push( `API route missing: ${camel}Routes is not registered in ` + `config/routes/routes.ts — the endpoints would be unreachable.` @@ -80,6 +77,29 @@ export function checkFeatureReachable( return { ok: problems.length === 0, problems }; } +/** + * True when `apiRoutes` mounts `Routes` as a WHOLE identifier, not merely as a + * substring of a longer name. A bare `.includes("countRoutes")` matches an unrelated + * slice's `accountRoutes` (`account` ⊃ `count`), so a `count` feature whose own + * `countRoutes` is genuinely absent would be reported reachable — a false green in the + * one check meant to catch unregistered (hollow) features. The `\p{ID_Continue}` + * lookarounds (the canonical Unicode "identifier continue" set — same idiom as + * `fieldIsMentioned`) reject a match glued to an adjacent identifier char on either + * side, while still matching every real mount idiom (`count: countRoutes`, + * `.use(countRoutes)`, `[countRoutes]`), whose delimiter before the name is not an + * identifier char. `$` is added to the class because it is a JS IdentifierPart that + * `\p{ID_Continue}` omits — so `$countRoutes` is a distinct identifier, not a match. + * (`_`, ZWJ, and ZWNJ are already in `\p{ID_Continue}`, verified at runtime.) + */ +function registersRoutes(apiRoutes: string, camel: string): boolean { + const boundary = "[\\p{ID_Continue}$]"; + const ident = `${camel}Routes`.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + + return new RegExp(`(?.title` + `.empty`. */ function localeHasFeatureKeys(jsonSrc: string, lower: string): boolean { let data: unknown; diff --git a/packages/core/tests/boringstack-reachability.test.ts b/packages/core/tests/boringstack-reachability.test.ts index b3479ff6..fa3747c8 100644 --- a/packages/core/tests/boringstack-reachability.test.ts +++ b/packages/core/tests/boringstack-reachability.test.ts @@ -46,6 +46,61 @@ describe("checkFeatureReachable", () => { expect(r.problems.join("\n")).toMatch(/API route missing/u); }); + test("a substring-only API match is NOT accepted (no false green off a sibling slice)", () => { + // `count`'s own `countRoutes` is absent, but a prior slice registered + // `accountRoutes` — which CONTAINS "countRoutes". A bare `.includes` would wrongly + // pass `count` as reachable; the whole-identifier check must still flag it. + const r = checkFeatureReachable("Count", { + uiRoutes: + 'const CountPage = lazy(() => import("@/features/count/components/CountPage/CountPage"));', + apiRoutes: "export const routes = {\n account: accountRoutes,\n};", + localeJsons: [ + JSON.stringify({ + features: { count: { title: "Counts", empty: "None." } }, + }), + ], + }); + + expect(r.ok).toBe(false); + expect(r.problems.join("\n")).toMatch(/API route missing/u); + }); + + test("a `$`-prefixed sibling identifier is NOT accepted (JS `$` is an identifier char)", () => { + // `$` is a valid JS IdentifierPart that `\p{ID_Continue}` omits, so `$countRoutes` + // is a DISTINCT identifier — it must not satisfy `count`'s reachability. + const r = checkFeatureReachable("Count", { + uiRoutes: + 'const CountPage = lazy(() => import("@/features/count/components/CountPage/CountPage"));', + apiRoutes: "export const routes = {\n meta: $countRoutes,\n};", + localeJsons: [ + JSON.stringify({ + features: { count: { title: "Counts", empty: "None." } }, + }), + ], + }); + + expect(r.ok).toBe(false); + expect(r.problems.join("\n")).toMatch(/API route missing/u); + }); + + test("a whole-identifier API match is accepted even next to a superstring sibling", () => { + // Both `count` and `account` are registered: `count` must be seen as reachable + // (its own whole-word `countRoutes` is present), not masked by `accountRoutes`. + const r = checkFeatureReachable("Count", { + uiRoutes: + 'const CountPage = lazy(() => import("@/features/count/components/CountPage/CountPage"));', + apiRoutes: + "export const routes = {\n account: accountRoutes,\n count: countRoutes,\n};", + localeJsons: [ + JSON.stringify({ + features: { count: { title: "Counts", empty: "None." } }, + }), + ], + }); + + expect(r.ok).toBe(true); + }); + test("flags missing i18n keys (page would show raw keys)", () => { const r = checkFeatureReachable("Bookmark", { ...wiredInputs(),