/** * Extraction of inline GraphQL documents from `.ts`/`.tsx` source. * * Generalizes two patterns proven in production incidents (BOFF-4703 * manufacturedops, BOFF-3529 planmagnet): * - bare backtick template literals: `const LIST = \`query ListX { ... }\`;` * - `gql`-tagged template literals: `` gql`query ListX { ... }` `` * * Both are matched by the same regex — the extractor only cares that the * backtick content starts with `query` / `mutation` / `subscription` / * `fragment`; whether it's preceded by a `gql` tag is irrelevant to whether * the document is real GraphQL that must validate against the schema. * * Same-file top-level `const NAME = \`...\`;` string constants are collected * and used to expand `${NAME}` interpolations (the manufacturedops * `*_FIELDS` selection-set fragment pattern) before parsing. */ /** Max passes when expanding `${NAME}` interpolations; guards against cyclic consts. */ const MAX_INTERPOLATION_DEPTH = 10; const DOC_BLOCK_RE = /`(\s*(?:query|mutation|subscription|fragment)\s[\s\S]*?)`/g; const CONST_RE = /const\s+([A-Za-z_$][\w$]*)\s*=\s*`([^`]*)`/g; const INTERPOLATION_RE = /\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g; /** * Collect `const NAME = `...`;` single-backtick string constants from a source * file — the selection-set fragments this codebase interpolates into * operations. Constants containing a nested backtick are intentionally not * matched (same limitation as the original per-repo scripts this generalizes). */ export function collectStringConstants(source: string): Map { const consts = new Map(); let match: RegExpExecArray | null; CONST_RE.lastIndex = 0; while ((match = CONST_RE.exec(source)) !== null) { consts.set(match[1], match[2]); } return consts; } /** Replace `${NAME}` with the corresponding constant, transitively. */ export function expandInterpolations(body: string, consts: ReadonlyMap): string { let current = body; for (let pass = 0; pass < MAX_INTERPOLATION_DEPTH; pass++) { if (!current.includes('${')) return current; const next = current.replace(INTERPOLATION_RE, (whole, name: string) => consts.has(name) ? (consts.get(name) as string) : whole ); if (next === current) return current; current = next; } return current; } /** Names of any `${...}` interpolations remaining after expansion. */ export function collectUnresolvedInterpolations(body: string): string[] { const out: string[] = []; let match: RegExpExecArray | null; const re = new RegExp(INTERPOLATION_RE.source, 'g'); while ((match = re.exec(body)) !== null) { out.push(match[1]); } return out; } export interface ExtractedBlock { file: string; index: number; /** `false` for a `fragment ... on ...` block — those are collected as fragments, not validated standalone. */ isFragment: boolean; /** Fully `${NAME}`-expanded source text. */ source: string; unresolved: string[]; } /** * Extract every backtick block that looks like a GraphQL document (operation * or fragment) from a single source file's text, expanding same-file * `${NAME}` constant interpolations along the way. */ export function extractBlocks(source: string, file: string): ExtractedBlock[] { const consts = collectStringConstants(source); const blocks: ExtractedBlock[] = []; let match: RegExpExecArray | null; const re = new RegExp(DOC_BLOCK_RE.source, 'g'); let index = 0; while ((match = re.exec(source)) !== null) { const expanded = expandInterpolations(match[1], consts); const unresolved = [...new Set(collectUnresolvedInterpolations(expanded))]; const isFragment = /^\s*fragment\s/.test(expanded); blocks.push({ file, index: index++, isFragment, source: expanded, unresolved }); } return blocks; }