test: decouple the feature-switch gates from the template's demo screens

The template's demonstration screens exist to explain the template. A product
replaces them with its domain, so deleting them is the expected end state — but
two gates were coupled to them, and the merge accommodated that coupling instead
of fixing it.

product-features.test.ts derives its own scope now: a registry must gate on the
manifest when it imports a module belonging to a manifest-declared feature. The
previous fix put installed-feature-runtimes.tsx in the exempt list, which
silenced the guard for that file permanently. Verified by removing the manifest
reference from installed-feature-adapters.ts and watching the guard fail; a
counter asserts the sweep still watches at least one file.

product-feature-switch.test.tsx exercises the kill switch end to end again. The
mechanism is the ownership lookup plus isFeatureActive, which has nothing to do
with which screens ship, so the ownership map is the fixture: one real
registered route attributed to a real installed feature, with the product's own
router, components and codecs.

That restoration exposed a real gap. The navigation-withdrawal half is not
implemented here: it lives in the template's PrimaryNavigation and this product
does not render the template's AppShell at all — the public header is a
hand-written list of paths. A disabled feature's route is refused by the router
but its link would still be advertised. Harmless only while no feature-owned
route is navigable, which is now asserted so the gap cannot ship silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-17 19:08:42 +09:00
co-authored by Claude Opus 5
parent cd1ef5cda2
commit 93ce86eef4
3 changed files with 213 additions and 44 deletions
+21 -7
View File
@@ -186,23 +186,37 @@ describe("every installed registry consults the manifest", () => {
"installed-product-manifest.ts",
// Capabilities have their own §3.5 selection file and override vocabulary.
"installed-runtime-capabilities.ts",
// Template merge. This product removed the reference feature's screens,
// so no installed feature contributes a route component and this file has
// nothing to gate. `tests/component/product-feature-switch.test.tsx`
// holds the mountability invariant meanwhile; restore this entry to the
// guarded set the day a feature contributes a route runtime again.
"installed-feature-runtimes.tsx",
// Message keys stay total on purpose; see the file for why.
"installed-feature-messages.ts",
]);
// A registry has to gate on the manifest when it composes something a
// manifest-declared feature owns. Deriving that from the file's own imports
// is the difference between a rule and a list: a product that replaces the
// template's demonstration feature with its own domain drops out of the
// rule honestly, and the day it imports a declared feature again the guard
// fires without anyone having to remember to re-add it.
//
// An earlier fix named `installed-feature-runtimes.tsx` in the exempt set
// once this product deleted the reference feature's screens. That silenced
// the guard for that file permanently — a worse outcome than the problem it
// solved.
let guarded = 0;
for (const registry of registries) {
if (exempt.has(registry)) continue;
const source = await readFile(nodePath.join(root, registry), "utf8");
const composesDeclaredFeature = COMPILED_PRODUCT_FEATURE_IDS.some(
(featureId) => source.includes(`./${featureId}/`),
);
if (!composesDeclaredFeature) continue;
guarded += 1;
expect(
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
`${registry} must compose from the product manifest`,
`${registry} composes a manifest-declared feature and must gate on the manifest`,
).toBe(true);
}
// The rule has to still be watching something; an empty sweep would pass
// no matter what happened to these files.
expect(guarded).toBeGreaterThan(0);
});
});