From 73cd0c6ae162ecf0cbd37a5c4c955a3f833153a3 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 26 Jul 2026 08:50:54 +0800 Subject: [PATCH] fix(scripts): resolve Record intersection-type keys in check-docs-drift's field walker extractTypeLiteralFieldEntries parses a type by brace-counting from the first '{' to its match. For an intersection like 'export type FocusManifestFeaturesConfig = { present: boolean } & Record', that stops at the object literal's own '}', so the '& Record<...>' half -- the 8 real feature keys (rag, reputation, safety, grounding, e2eTests, screenshots, improvementSignal, amsReputationBridge) and FocusManifestExperimentalConfig's 'gittensor' -- was never inspected. The checker had zero power to catch a 9th/renamed key landing without a doc update. Parse the intersection's 'Record' half and resolve K's string-literal members to their own field entries: inline '"a" | "b"' literals, a locally-declared string-literal union, or the '(typeof SOME_ARRAY)[number]' const-array indirection ConvergedFeatureKey/ExperimentalPluginKey actually use. Fully general -- any '{...} & Record<...>' in the scanned source, no type name hardcoded. A non-literal key (Record, etc.) resolves to nothing, inventing no field names. The real FocusManifest field count rises 106->115 (the 8 feature keys + gittensor), and the drift check still passes against the current .loopover.yml.example (all 9 already documented -- no false positives). Tests: a fixture exercising every key form (inline / named union / typeof-array / non-literal) plus a live-source assertion that the 8+1 real keys are now surfaced. --- scripts/check-docs-drift.ts | 41 +++++++++++++++++++ test/unit/check-docs-drift-script.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/scripts/check-docs-drift.ts b/scripts/check-docs-drift.ts index 7f2d3bf321..b5cf603661 100644 --- a/scripts/check-docs-drift.ts +++ b/scripts/check-docs-drift.ts @@ -76,9 +76,50 @@ function extractTypeLiteralFieldEntries(text: string, typeName: string): TypeLit const fieldMatch = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\??:\s*(.+);\s*$/.exec(line); if (fieldMatch) fields.push({ name: fieldMatch[1]!, typeText: fieldMatch[2]!.trim() }); } + // Intersection half: `{ ... } & Record`. Brace-counting above stops at the object + // literal's own `}`, so the Record's keys -- the actual feature/experimental keys of + // FocusManifestFeaturesConfig / FocusManifestExperimentalConfig -- sit entirely outside it and were never + // seen. Resolve each KeyUnion member (inline `"a" | "b"` literals, or a locally-declared string-literal union + // type like ConvergedFeatureKey) to its own field entry. Generalizes to any `{...} & Record<...>` (#8656). + // Only THIS declaration's tail (up to its terminating `;`), so a `& Record<...>` from a later type in the + // file is never mis-attributed here. `^[^;]*` always matches (even with no trailing `;`), so no branch. + const intersection = /^[^;]*/.exec(text.slice(index))![0]; + for (const recordMatch of intersection.matchAll(/&\s*Record<\s*([^,]+?)\s*,\s*([^>]+?)>/g)) { + const valueText = recordMatch[2]!.trim(); + for (const key of resolveRecordKeyLiterals(recordMatch[1]!, text)) { + fields.push({ name: key, typeText: valueText }); + } + } return fields; } +function stringLiteralsIn(source: string): string[] { + return [...source.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]!); +} + +/** Resolve a `Record` key operand `K` to its string-literal members, so each becomes a documented field: + * - inline `"a" | "b"` literals are taken directly; + * - a bare identifier is looked up as a locally-declared `export type K = ...`, resolving either a direct + * string-literal union (`"a" | "b"`) or the `(typeof SOME_ARRAY)[number]` indirection over a `const + * SOME_ARRAY = ["a", "b", ...] as const` string array -- the shape ConvergedFeatureKey / ExperimentalPluginKey + * actually use. + * Returns [] for a non-literal key (e.g. `Record`), so no field names are ever invented. */ +function resolveRecordKeyLiterals(keyText: string, text: string): string[] { + const inlineLiterals = stringLiteralsIn(keyText); + if (inlineLiterals.length > 0) return inlineLiterals; + const identifier = keyText.trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) return []; + const aliasMatch = new RegExp(`export type ${identifier}\\s*=\\s*([^;]+);`).exec(text); + if (!aliasMatch) return []; + const aliasBody = aliasMatch[1]!; + const directUnion = stringLiteralsIn(aliasBody); + if (directUnion.length > 0) return directUnion; + const typeofArray = /\(\s*typeof\s+([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*\[\s*number\s*\]/.exec(aliasBody); + if (!typeofArray) return []; + const arrayMatch = new RegExp(`const ${typeofArray[1]}\\s*=\\s*\\[([^\\]]*)\\]`).exec(text); + return arrayMatch ? stringLiteralsIn(arrayMatch[1]!) : []; +} + /** Every top-level field name DECLARED directly on the `RepositorySettings` object-literal type in src/types.ts * (#4617) -- the FULL ~100-field surface, not just the `*GateMode` subset `extractGateModeFields` above * targets. Unlike that regex (which matches a NAME SHAPE anywhere in the file), this is anchored on the type's diff --git a/test/unit/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts index d28a889209..eea44347b8 100644 --- a/test/unit/check-docs-drift-script.test.ts +++ b/test/unit/check-docs-drift-script.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { checkDocsDrift, @@ -216,6 +217,53 @@ describe("check-docs-drift script", () => { it("returns [] when FocusManifest has no declaration in the text", () => { expect(extractFocusManifestFields("export type SomethingElse = { x: string };")).toEqual([]); }); + + it("resolves a `{ ... } & Record` intersection half's keys to their own leaves (#8656)", () => { + const fixture = ` + export type FocusManifest = { + present: boolean; + features: FeaturesConfig; + experimental: ExperimentalConfig; + named: NamedConfig; + numeric: NumericConfig; + loose: LooseConfig; + missing: MissingConfig; + broken: BrokenConfig; + }; + export type FeaturesConfig = { present: boolean } & Record<"newKey", boolean | null>; + export const ARR_KEYS = ["gamma", "delta"] as const; + export type ArrKey = (typeof ARR_KEYS)[number]; + export type ExperimentalConfig = { present: boolean } & Record; + export type NamedKeys = "alpha" | "beta"; + export type NamedConfig = { present: boolean } & Record; + export type NumKey = number; + export type NumericConfig = { present: boolean } & Record; + export type LooseConfig = { present: boolean } & Record; + export type MissingArrKey = (typeof MISSING_ARR)[number]; + export type MissingConfig = { present: boolean } & Record; + export type BrokenConfig = { present: boolean } & Record<123, boolean | null>; + `; + // Inline literal (features.newKey), a locally-declared `(typeof ARR)[number]` const-array union + // (experimental.gamma/delta), and a direct string-literal union (named.alpha/beta) each resolve to leaves. + // A `Record` or an unresolvable key (NumKey/LooseConfig/MissingConfig/BrokenConfig) invents + // no field names. + expect(extractFocusManifestFields(fixture)).toEqual([ + "features.newKey", + "experimental.gamma", + "experimental.delta", + "named.alpha", + "named.beta", + ]); + }); + + it("surfaces the real FocusManifestFeaturesConfig / FocusManifestExperimentalConfig Record keys against the live source (#8656)", () => { + const source = readFileSync("packages/loopover-engine/src/focus-manifest.ts", "utf8"); + const fields = extractFocusManifestFields(source); + for (const key of ["rag", "reputation", "safety", "grounding", "e2eTests", "screenshots", "improvementSignal", "amsReputationBridge"]) { + expect(fields, `features.${key}`).toContain(`features.${key}`); + } + expect(fields).toContain("experimental.gittensor"); + }); }); describe("checkDocsDrift", () => {