{"version":3,"file":"eval-gold.d.ts","sourceRoot":"","sources":["../../../src/core/search/eval-gold.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAYzD,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB;AA8DD;oEACoE;AACpE,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAiBhH;AAED;oDACoD;AACpD,wBAAgB,cAAc,CAC7B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,SAAS,SAAS,EAAE,GAC3B;IAAE,OAAO,EAAE,SAAS,EAAE,CAAC;IAAC,MAAM,EAAE,mBAAmB,EAAE,CAAA;CAAE,CAWzD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,SAAS,EAAE,GAAG,mBAAmB,EAAE,CAmDxG;AAED,6CAA6C;AAC7C,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,CAE5D","sourcesContent":["/**\n * Gold-set loading and anchor resolution.\n *\n * A gold span recorded as bare line numbers rots on the next refactor, and a\n * rotted gold set fails *silently* — it just scores lower. So each span also\n * carries an `anchor`: a literal snippet that must sit inside the range. The\n * anchor is the source of truth; the line numbers are a cache of where it was\n * when the fixture was last regenerated.\n *\n * That gives three properties the first gold set lacked:\n *  - `resolveGoldSet` recomputes ranges from anchors, so `--fix` re-pins the\n *    whole set after a refactor instead of someone hand-editing 60 numbers;\n *  - `validateGoldSet` fails loudly when an anchor has moved out of its\n *    recorded range, drifted, or become ambiguous;\n *  - anchors are never used for scoring, so this cannot flatter retrieval —\n *    `spanMatchesGold` still sees only a path and a line range.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { EvalGoldSpan, EvalQuery } from \"./eval.js\";\n\n/** Lines of context kept either side of a non-block anchor (an error literal,\n *  a single statement) — enough to be a real target, tight enough that a\n *  60-line chunk elsewhere in the file does not count as a hit. */\nconst LINE_ANCHOR_PAD = 2;\n/** Upper bound on a resolved block, so an anchor that fails to find its\n *  closing brace cannot silently swallow an entire file. */\nconst MAX_BLOCK_LINES = 120;\n/** Fallback extent when a block anchor never finds its closing brace. */\nconst UNCLOSED_BLOCK_LINES = 15;\n\nexport interface GoldValidationIssue {\n\tqueryId: string;\n\tpath: string;\n\tproblem: string;\n}\n\nfunction readLines(corpusRoot: string, rel: string): string[] | undefined {\n\ttry {\n\t\treturn readFileSync(path.resolve(corpusRoot, rel), \"utf-8\").split(\"\\n\");\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** Indices (0-based) of every line containing `anchor`. */\nfunction findAnchorLines(lines: readonly string[], anchor: string): number[] {\n\tconst found: number[] = [];\n\tfor (let i = 0; i < lines.length; i++) {\n\t\tif (lines[i].includes(anchor)) found.push(i);\n\t}\n\treturn found;\n}\n\n/** Leading-whitespace width of a line, for matching a block's closer to its\n *  opener. Tabs count as one; this only ever compares lines in one file, which\n *  is consistently indented. */\nfunction indentWidth(line: string): number {\n\treturn /^[\\t ]*/.exec(line)?.[0].length ?? 0;\n}\n\n/**\n * Extent of the declaration an anchor names.\n *\n * A line ending in an opener (`{`, `(`, `[`) starts a block, which runs to the\n * first closing bracket indented no deeper than the opener. Anything else is a\n * statement, scored with a small pad.\n *\n * The indentation test is load-bearing. This used to close only on a bracket\n * at *column 0*, which is right for a top-level declaration and wrong for\n * every class member: a method's own `}` is indented, so the scan walked past\n * it to the end of the enclosing class. Three of the four boundary-class gold\n * spans were resolving to 80, 34 and 28 lines for methods that are 7, 4 and 4\n * lines long, all ending on the same line — the class's closing brace. Gold\n * that wide scores a hit for retrieving a neighbouring method, which is not\n * what the query asked for.\n */\nfunction resolveExtent(lines: readonly string[], anchorIndex: number): { startLine: number; endLine: number } {\n\tconst head = lines[anchorIndex];\n\tconst opensBlock = /[{([]\\s*$/.test(head);\n\tif (!opensBlock) {\n\t\treturn {\n\t\t\tstartLine: Math.max(1, anchorIndex + 1 - LINE_ANCHOR_PAD),\n\t\t\tendLine: Math.min(lines.length, anchorIndex + 1 + LINE_ANCHOR_PAD),\n\t\t};\n\t}\n\tconst anchorIndent = indentWidth(head);\n\tconst limit = Math.min(lines.length, anchorIndex + 1 + MAX_BLOCK_LINES);\n\tfor (let i = anchorIndex + 1; i < limit; i++) {\n\t\t// `}`, `};`, `});`, `];` at or outside the opener's indentation.\n\t\tif (/^[\\t ]*[}\\])]/.test(lines[i]) && indentWidth(lines[i]) <= anchorIndent) {\n\t\t\treturn { startLine: anchorIndex + 1, endLine: i + 1 };\n\t\t}\n\t}\n\treturn { startLine: anchorIndex + 1, endLine: Math.min(lines.length, anchorIndex + 1 + UNCLOSED_BLOCK_LINES) };\n}\n\n/** Re-pin one gold span's line range from its anchor. Returns the span\n *  unchanged when it is file-scoped or has no anchor to resolve. */\nexport function resolveGoldSpan(corpusRoot: string, span: EvalGoldSpan): { span: EvalGoldSpan; problem?: string } {\n\tconst lines = readLines(corpusRoot, span.path);\n\tif (!lines) return { span, problem: `file not found: ${span.path}` };\n\n\tif (span.scope === \"file\") {\n\t\treturn { span: { ...span, startLine: 1, endLine: lines.length } };\n\t}\n\tif (!span.anchor) return { span, problem: `span-scoped gold needs an anchor: ${span.path}` };\n\n\tconst matches = findAnchorLines(lines, span.anchor);\n\tif (matches.length === 0) return { span, problem: `anchor not found in ${span.path}: ${span.anchor}` };\n\tif (matches.length > 1) {\n\t\treturn { span, problem: `anchor is ambiguous (${matches.length} matches) in ${span.path}: ${span.anchor}` };\n\t}\n\n\tconst { startLine, endLine } = resolveExtent(lines, matches[0]);\n\treturn { span: { ...span, startLine, endLine } };\n}\n\n/** Re-pin every gold span in the set. Problems are collected, not thrown, so\n *  a regeneration run reports all drift at once. */\nexport function resolveGoldSet(\n\tcorpusRoot: string,\n\tdataset: readonly EvalQuery[],\n): { dataset: EvalQuery[]; issues: GoldValidationIssue[] } {\n\tconst issues: GoldValidationIssue[] = [];\n\tconst resolved = dataset.map((query) => ({\n\t\t...query,\n\t\tgold: query.gold.map((span) => {\n\t\t\tconst result = resolveGoldSpan(corpusRoot, span);\n\t\t\tif (result.problem) issues.push({ queryId: query.id, path: span.path, problem: result.problem });\n\t\t\treturn result.span;\n\t\t}),\n\t}));\n\treturn { dataset: resolved, issues };\n}\n\n/**\n * Check the committed fixture against the corpus without rewriting it.\n *\n * Catches the two ways a gold set goes wrong: the anchor no longer exists (the\n * code was deleted or renamed), or it exists but has moved outside the\n * recorded range (the fixture is stale and every score computed from it is\n * wrong).\n */\nexport function validateGoldSet(corpusRoot: string, dataset: readonly EvalQuery[]): GoldValidationIssue[] {\n\tconst issues: GoldValidationIssue[] = [];\n\tconst seenIds = new Set<string>();\n\n\tfor (const query of dataset) {\n\t\tif (seenIds.has(query.id)) issues.push({ queryId: query.id, path: \"\", problem: \"duplicate query id\" });\n\t\tseenIds.add(query.id);\n\t\tif (query.gold.length === 0) issues.push({ queryId: query.id, path: \"\", problem: \"query has no gold spans\" });\n\n\t\tfor (const span of query.gold) {\n\t\t\tconst lines = readLines(corpusRoot, span.path);\n\t\t\tif (!lines) {\n\t\t\t\tissues.push({ queryId: query.id, path: span.path, problem: \"file not found\" });\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (span.startLine === undefined || span.endLine === undefined) {\n\t\t\t\tissues.push({ queryId: query.id, path: span.path, problem: \"gold span is missing line numbers\" });\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (span.startLine < 1 || span.endLine < span.startLine || span.endLine > lines.length) {\n\t\t\t\tissues.push({\n\t\t\t\t\tqueryId: query.id,\n\t\t\t\t\tpath: span.path,\n\t\t\t\t\tproblem: `line range ${span.startLine}-${span.endLine} out of bounds (file has ${lines.length} lines)`,\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (span.scope === \"file\") continue;\n\t\t\tif (!span.anchor) {\n\t\t\t\tissues.push({ queryId: query.id, path: span.path, problem: \"span-scoped gold needs an anchor\" });\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst matches = findAnchorLines(lines, span.anchor);\n\t\t\tif (matches.length === 0) {\n\t\t\t\tissues.push({ queryId: query.id, path: span.path, problem: `anchor not found: ${span.anchor}` });\n\t\t\t} else if (matches.length > 1) {\n\t\t\t\tissues.push({\n\t\t\t\t\tqueryId: query.id,\n\t\t\t\t\tpath: span.path,\n\t\t\t\t\tproblem: `anchor is ambiguous (${matches.length} matches): ${span.anchor}`,\n\t\t\t\t});\n\t\t\t} else if (matches[0] + 1 < span.startLine || matches[0] + 1 > span.endLine) {\n\t\t\t\tissues.push({\n\t\t\t\t\tqueryId: query.id,\n\t\t\t\t\tpath: span.path,\n\t\t\t\t\tproblem: `anchor moved to line ${matches[0] + 1}, outside recorded range ${span.startLine}-${span.endLine}`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\treturn issues;\n}\n\n/** Load the gold set from a fixture file. */\nexport function loadGoldSet(fixturePath: string): EvalQuery[] {\n\treturn JSON.parse(readFileSync(fixturePath, \"utf-8\")) as EvalQuery[];\n}\n"]}