diff --git a/packages/core-eslint/rules/_manifest-ast.js b/packages/core-eslint/rules/_manifest-ast.js index eef68ab..a058519 100644 --- a/packages/core-eslint/rules/_manifest-ast.js +++ b/packages/core-eslint/rules/_manifest-ast.js @@ -1,6 +1,17 @@ import fs from "node:fs"; import { parse } from "@typescript-eslint/parser"; +/** + * ONE parser for feature.manifest.ts, shared by every conformance rule and + * by scripts/conformance.mjs. Walks the AST to the `defineFeature({...})` + * call (unwrapping `as const` / `satisfies` / both) and reads literal + * values from its argument object. + * + * The file used to carry a verbatim second copy of this walk for + * `parseManifestFully` — both public entry points now share the single + * implementation below. + */ + function extractStringLiterals(arrayExpr) { return arrayExpr.elements .filter((el) => el && el.type === "Literal" && typeof el.value === "string") @@ -32,55 +43,6 @@ function extractRateLimitNames(arrayExpr) { return names; } -/** - * Parse a feature.manifest.ts file and extract per-use-case attributes. - * Walks the AST to find the `defineFeature({...} as const)` call expression - * and reads literal values from its argument object. - * - * Returns: { [useCaseName]: { mutates, audits[], publishes[], consumes[] } } - * Returns null if the file is missing or doesn't match the expected shape. - */ -export function parseManifestUseCases(manifestPath) { - let src; - try { - src = fs.readFileSync(manifestPath, "utf8"); - } catch { - return null; - } - let ast; - try { - ast = parse(src, { - sourceType: "module", - ecmaVersion: "latest", - loc: false, - range: false, - }); - } catch { - return null; - } - const defineCall = findDefineFeatureCall(ast); - if (!defineCall) return null; - const arg = unwrapAsConst(defineCall.arguments[0]); - if (!arg || arg.type !== "ObjectExpression") return null; - const useCasesProp = arg.properties.find( - (p) => - p.type === "Property" && - p.key.type === "Identifier" && - p.key.name === "useCases", - ); - if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression") - return {}; - const result = {}; - for (const entry of useCasesProp.value.properties) { - if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") - continue; - const name = - entry.key.type === "Identifier" ? entry.key.name : entry.key.value; - result[name] = extractUseCaseEntry(entry.value); - } - return result; -} - function findDefineFeatureCall(ast) { for (const node of ast.body) { if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue; @@ -101,7 +63,15 @@ function findDefineFeatureCall(ast) { } function unwrapAsConst(node) { - if (node && node.type === "TSAsExpression") return node.expression; + // Unwrap BOTH wrapper kinds, including the combined + // `{...} as const satisfies FeatureManifest` idiom — a `satisfies`-shaped + // manifest must never silently no-op the error-level conformance rules. + while ( + node && + (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") + ) { + node = node.expression; + } return node; } @@ -134,17 +104,23 @@ function extractUseCaseEntry(objExpr) { return entry; } +function extractUseCasesMap(useCasesObjExpr) { + const result = {}; + for (const entry of useCasesObjExpr.properties) { + if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") + continue; + const name = + entry.key.type === "Identifier" ? entry.key.name : entry.key.value; + result[name] = extractUseCaseEntry(entry.value); + } + return result; +} + /** - * Parse a feature.manifest.ts and return the full manifest shape: - * { name, requiredCores: string[], useCases: { [name]: {...} } } - * - * Same AST walking as parseManifestUseCases but additionally extracts - * the top-level name + requiredCores fields. Returns null on parse failure. + * Read + parse the manifest and return the `defineFeature` argument object, + * or null when the file is missing / unparseable / not the expected shape. */ -export function parseManifestFully(manifestPath) { - // Reuse the AST parser used by parseManifestUseCases by inlining the - // file-read + AST walk. We need access to the manifest's top-level - // argument object beyond just useCases. +function parseManifestArg(manifestPath) { let src; try { src = fs.readFileSync(manifestPath, "utf8"); @@ -162,10 +138,21 @@ export function parseManifestFully(manifestPath) { } catch { return null; } - const defineCall = findDefineFeatureCallFromBody(ast); + const defineCall = findDefineFeatureCall(ast); if (!defineCall) return null; - const arg = unwrapAsConstNode(defineCall.arguments[0]); + const arg = unwrapAsConst(defineCall.arguments[0]); if (!arg || arg.type !== "ObjectExpression") return null; + return arg; +} + +/** + * Parse a feature.manifest.ts and return the full manifest shape: + * { name, requiredCores: string[], requiresConsent: string[], useCases: { [name]: {...} } } + * Returns null on parse failure or when the manifest has no `name`. + */ +export function parseManifestFully(manifestPath) { + const arg = parseManifestArg(manifestPath); + if (arg === null) return null; let name = null; let requiredCores = []; @@ -202,69 +189,22 @@ export function parseManifestFully(manifestPath) { return { name, requiredCores, requiresConsent, useCases }; } -function extractUseCasesMap(useCasesObjExpr) { - const result = {}; - for (const entry of useCasesObjExpr.properties) { - if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") - continue; - const ucName = - entry.key.type === "Identifier" ? entry.key.name : entry.key.value; - result[ucName] = extractUseCaseEntryFromObj(entry.value); - } - return result; -} - -// Helper aliases for the existing private functions — exposed under -// different names to avoid touching existing code paths. -function findDefineFeatureCallFromBody(ast) { - for (const node of ast.body) { - if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue; - if (node.declaration.type !== "VariableDeclaration") continue; - for (const decl of node.declaration.declarations) { - const init = decl.init; - if (!init) continue; - if ( - init.type === "CallExpression" && - init.callee.type === "Identifier" && - init.callee.name === "defineFeature" - ) { - return init; - } - } - } - return null; -} - -function unwrapAsConstNode(node) { - if (node && node.type === "TSAsExpression") return node.expression; - return node; -} - -function extractUseCaseEntryFromObj(objExpr) { - const entry = { - mutates: false, - audits: [], - publishes: [], - consumes: [], - analyticsEvents: [], - rateLimit: [], - }; - for (const prop of objExpr.properties) { - if (prop.type !== "Property" || prop.key.type !== "Identifier") continue; - const key = prop.key.name; - if (key === "mutates" && prop.value.type === "Literal") { - entry.mutates = prop.value.value === true; - } else if ( - (key === "audits" || - key === "publishes" || - key === "consumes" || - key === "analyticsEvents") && - prop.value.type === "ArrayExpression" - ) { - entry[key] = extractStringLiterals(prop.value); - } else if (key === "rateLimit" && prop.value.type === "ArrayExpression") { - entry[key] = extractRateLimitNames(prop.value); - } - } - return entry; +/** + * Parse a feature.manifest.ts and extract per-use-case attributes only: + * { [useCaseName]: { mutates, audits[], publishes[], consumes[], analyticsEvents[], rateLimit[] } } + * Returns null if the file is missing or doesn't match the expected shape; + * returns {} for a manifest whose useCases object is empty/absent. + */ +export function parseManifestUseCases(manifestPath) { + const arg = parseManifestArg(manifestPath); + if (arg === null) return null; + const useCasesProp = arg.properties.find( + (p) => + p.type === "Property" && + p.key.type === "Identifier" && + p.key.name === "useCases", + ); + if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression") + return {}; + return extractUseCasesMap(useCasesProp.value); } diff --git a/packages/core-eslint/rules/_manifest-ast.test.js b/packages/core-eslint/rules/_manifest-ast.test.js index feff64d..e9818f4 100644 --- a/packages/core-eslint/rules/_manifest-ast.test.js +++ b/packages/core-eslint/rules/_manifest-ast.test.js @@ -133,6 +133,51 @@ describe("parseManifestFully", () => { expect(parseManifestFully(fp)).toBeNull(); }); + it("parses a `satisfies FeatureManifest` manifest (must not silently no-op the gates)", () => { + const fp = writeManifest(`export const xManifest = defineFeature({ + name: "x", + requiredCores: [], + useCases: { + doThing: { mutates: true, audits: ["x.done"], publishes: ["x.done"], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], +} satisfies FeatureManifest);`); + expect(parseManifestUseCases(fp)).toEqual({ + doThing: { + mutates: true, + audits: ["x.done"], + publishes: ["x.done"], + consumes: [], + analyticsEvents: [], + rateLimit: [], + }, + }); + expect(parseManifestFully(fp)?.name).toBe("x"); + }); + + it("parses the combined `as const satisfies FeatureManifest` idiom", () => { + const fp = writeManifest(`export const xManifest = defineFeature({ + name: "x", + requiredCores: [], + useCases: { + doThing: { mutates: false, audits: [], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: [], +} as const satisfies FeatureManifest);`); + expect(parseManifestUseCases(fp)).toEqual({ + doThing: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: [], + rateLimit: [], + }, + }); + }); + it("is not fooled by 'name:' inside a JSDoc comment (regex would false-match)", () => { const fp = writeManifest(`/** * Sample comment with name: "fake" embedded in it.