diff --git a/scripts/draft-issue.ts b/scripts/draft-issue.ts new file mode 100644 index 0000000000..9a2feb3242 --- /dev/null +++ b/scripts/draft-issue.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Issue-drafting CLI (#8103, epic #8082) — the FIRST thin consumer of the pure issue-drafting core +// (src/services/issue-drafting.ts): reads the loose prompt, walks the checkout to build the searchable +// corpus, calls the core, and writes the drafted body to a local file for the maintainer to read, edit, +// and only then publish by hand. All grounding/drafting logic lives in the core (unit-tested there); this +// file is the thin IO wrapper — mirrors scripts/export-d1-data.ts's identical role next to +// export-d1-core.ts. NEVER publishes anything, NEVER touches labels/milestones — see the core's own +// boundary comment. +// +// tsx scripts/draft-issue.ts --prompt "" --output [--root .] +// tsx scripts/draft-issue.ts --prompt-file --output [--root .] +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { draftIssueBody, type CorpusFile } from "../src/services/issue-drafting.js"; + +type Args = { + prompt: string | undefined; + promptFile: string | undefined; + output: string | undefined; + root: string; +}; + +// The corpus mirrors where real precedent lives (the gate's own wantedPaths, minus content-free dirs). +const CORPUS_DIRS = ["src", "packages", "scripts", "test", "migrations", ".github/workflows"]; +const CORPUS_EXTENSIONS = [".ts", ".tsx", ".sql", ".yml", ".yaml", ".jsonc", ".md"]; +const SKIP_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".turbo"]); +const MAX_FILE_BYTES = 512 * 1024; + +function parseArgs(argv: string[]): Args { + const args: Args = { prompt: undefined, promptFile: undefined, output: undefined, root: "." }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (flag === "--prompt") args.prompt = argv[++i]; + else if (flag === "--prompt-file") args.promptFile = argv[++i]; + else if (flag === "--output") args.output = argv[++i]; + else if (flag === "--root") args.root = argv[++i]!; + } + return args; +} + +function collectCorpus(root: string): CorpusFile[] { + const corpus: CorpusFile[] = []; + const walk = (dir: string) => { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; // a listed corpus dir may not exist in a partial checkout -- skip, never crash the draft + } + for (const entry of entries.sort()) { + if (SKIP_DIR_NAMES.has(entry)) continue; + const fullPath = join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) walk(fullPath); + else if (CORPUS_EXTENSIONS.some((extension) => entry.endsWith(extension)) && stats.size <= MAX_FILE_BYTES) { + corpus.push({ path: relative(root, fullPath), content: readFileSync(fullPath, "utf8") }); + } + } + }; + for (const dir of CORPUS_DIRS) walk(join(root, dir)); + return corpus; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const prompt = args.prompt ?? (args.promptFile ? readFileSync(args.promptFile, "utf8") : undefined); + if (!prompt || !args.output) { + console.error("Usage: tsx scripts/draft-issue.ts (--prompt | --prompt-file ) --output [--root .]"); + process.exit(2); + } + + const corpus = collectCorpus(args.root); + const result = draftIssueBody(prompt, corpus); + writeFileSync(args.output, result.body); + console.error( + `drafted from ${corpus.length} corpus file(s): ${result.groundedTerms.length} term(s) grounded, ` + + `${result.ungroundedTerms.length} UNGROUNDED marker(s) to resolve by hand → ${args.output}`, + ); + console.error("review + edit before publishing — this tool never publishes, and labels/milestone stay your call."); +} + +main(); diff --git a/src/services/issue-drafting.ts b/src/services/issue-drafting.ts new file mode 100644 index 0000000000..745297d164 --- /dev/null +++ b/src/services/issue-drafting.ts @@ -0,0 +1,270 @@ +// Issue-drafting core (#8103, epic #8082) — expands a loose maintainer prompt into a gate-ready draft +// issue body in this repo's heavy template, grounded in REAL precedent from the current checkout. The gate +// only enforces what an issue explicitly says (see .claude/skills/contributor-pipeline-gardening/ +// reference.md, "The gate only enforces what the issue explicitly says"), so a publishable issue must cite +// exact files/functions/patterns — this module automates that grounding pass and assembles the draft, and +// it says so EXPLICITLY wherever it could not ground part of the prompt, never inventing a plausible but +// unverified requirement. +// +// PURE, ADAPTER-AGNOSTIC (the issue's own ⚠️ required shape): no process.argv, no fs, no IO of any kind. +// The caller supplies the searchable corpus (the CLI wrapper scripts/draft-issue.ts reads the checkout; a +// future ORB dashboard API route can supply the same shape from whatever storage it has — a Worker has no +// filesystem, which is exactly why the search input is data, not a path) and gets back a structured +// result. Mirrors the pure-core/host-adapter split of packages/loopover-engine/src/calibration/ +// signal-tracking.ts + src/review/signal-tracking-wire.ts. +// +// Hard boundaries (#8103): drafts BODY TEXT only. Never publishes, never picks labels/milestone/ +// contributor-vs-maintainer-only status, never decides relationships — those stay maintainer decisions on +// every issue, no exceptions. + +/** One searchable file of the checkout, supplied by the caller — `path` is repo-relative. */ +export type CorpusFile = { path: string; content: string }; + +/** How specific an extracted term is. Backticked/path/identifier terms name something exact, so failing to + * ground one is a real spec gap the draft must flag; a plain word failing to ground is just vocabulary and + * is silently dropped rather than manufactured into a scary-but-empty ⚠️ warning. */ +export type GroundingTermTier = "exact" | "path" | "identifier" | "word"; + +export type GroundingTerm = { term: string; tier: GroundingTermTier }; + +/** One place a grounding term was actually found in the supplied corpus. `line` is 1-based. */ +export type GroundingMatch = { path: string; line: number; text: string }; + +/** A term from the loose prompt that WAS grounded in real precedent. */ +export type GroundedTerm = GroundingTerm & { matches: readonly GroundingMatch[] }; + +export type IssueDraftOptions = { + /** Cap on distinct terms extracted from the prompt (default 12). */ + maxTerms?: number; + /** Cap on matches kept per grounded term (default 3). */ + maxMatchesPerTerm?: number; +}; + +export type IssueDraftResult = { + /** The drafted issue body (heavy template) for the maintainer to read, edit, and only then publish. */ + body: string; + groundedTerms: readonly GroundedTerm[]; + /** Specific terms (exact/path/identifier tier) with NO precedent in the searched corpus — surfaced + * verbatim in the body as ⚠️ UNGROUNDED so the human decision point is visible, never papered over. */ + ungroundedTerms: readonly GroundingTerm[]; +}; + +const DEFAULT_MAX_TERMS = 12; +const DEFAULT_MAX_MATCHES_PER_TERM = 3; +const MAX_MATCH_TEXT_CHARS = 160; + +// Words too generic to ground anything by themselves — searching these would match half the repo and +// produce citation noise, the opposite of the explicit-precedent discipline this tool exists to serve. +const STOPWORDS = new Set([ + "the", "and", "for", "with", "that", "this", "from", "into", "when", "then", "them", "they", + "should", "would", "could", "must", "have", "has", "had", "are", "was", "were", "been", + "add", "adds", "added", "new", "make", "makes", "made", "use", "uses", "used", "using", + "file", "files", "code", "test", "tests", "issue", "issues", "also", "only", "over", "under", + "each", "every", "all", "any", "some", "not", "never", "always", "existing", "current", "real", + "same", "way", "more", "less", "one", "two", "like", "its", "our", "your", "their", "than", +]); + +/** + * Extract the candidate grounding terms from a loose prompt, most-specific first: + * 1. `exact` — backtick-quoted fragments, kept verbatim (the maintainer already named something); + * 2. `path` — path-shaped tokens (contain `/` or end in a source-file extension); + * 3. `identifier` — camelCase / snake_case / dotted words (the shapes real symbols take); + * 4. `word` — remaining plain words ≥ 4 chars that aren't stopwords. + * Each tier's matches are consumed from the text before the next tier scans, so a fragment never + * double-extracts (e.g. `record.ts` out of `backtest-track-record.ts`). Deduplicated case-insensitively + * in tier order, capped at `maxTerms`. + */ +export function extractGroundingTerms(prompt: string, maxTerms: number = DEFAULT_MAX_TERMS): GroundingTerm[] { + const seen = new Set(); + const terms: GroundingTerm[] = []; + const push = (term: string, tier: GroundingTermTier) => { + const key = term.toLowerCase(); + if (term.length < 3 || seen.has(key) || STOPWORDS.has(key)) return; + seen.add(key); + terms.push({ term, tier }); + }; + + for (const [, quoted] of prompt.matchAll(/`([^`]+)`/g)) push(quoted!.trim(), "exact"); + let rest = prompt.replace(/`[^`]*`/g, " "); + + const pathPattern = /[A-Za-z0-9_.-]*\/[A-Za-z0-9_./-]+|[A-Za-z0-9_-]+\.(?:tsx?|sql|ya?ml|jsonc?|md)\b/g; + for (const [token] of rest.matchAll(pathPattern)) push(token, "path"); + rest = rest.replace(pathPattern, " "); + + const identifierPattern = /\b(?:[a-z0-9]+(?:[A-Z][a-z0-9]*)+|[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+)\b/g; + for (const [token] of rest.matchAll(identifierPattern)) push(token, "identifier"); + rest = rest.replace(identifierPattern, " "); + + for (const [token] of rest.matchAll(/\b[A-Za-z]{4,}\b/g)) push(token, "word"); + + return terms.slice(0, Math.max(0, maxTerms)); +} + +/** Rank source paths the way a precedent citation should read: live code first, then shared packages, + * then scripts, then any test file (wherever it lives), everything else (workflows, config) last. */ +function pathRank(path: string): number { + if (path.includes("/test/") || path.includes(".test.")) return 3; + if (path.startsWith("src/")) return 0; + if (path.startsWith("packages/")) return 1; + if (path.startsWith("scripts/")) return 2; + return 4; +} + +/** Definition lines make better citations than usages or comments — a contributor mirroring precedent + * needs the declaration, not a random mention. */ +function lineRank(text: string): number { + return /^(?:export\s|function\s|class\s|const\s|type\s)/.test(text) ? 0 : 1; +} + +/** + * Search the supplied corpus for one term (case-insensitive substring). Returns the term's grounded + * matches — definition lines in best-ranked paths first, then by path/line for byte-stable deterministic + * output — capped at `maxMatchesPerTerm`, or null when the corpus has no trace of the term at all. + * A `word`-tier term only searches files whose PATH contains it: a plain word matching arbitrary comment + * prose across the repo is citation noise, but a word that names a file ("backtest", "track") is a real + * anchor — this is what keeps loose vocabulary from grounding to random unrelated lines. + */ +export function groundTerm( + groundingTerm: GroundingTerm, + corpus: readonly CorpusFile[], + maxMatchesPerTerm: number = DEFAULT_MAX_MATCHES_PER_TERM, +): GroundedTerm | null { + const needle = groundingTerm.term.toLowerCase(); + const searchable = groundingTerm.tier === "word" ? corpus.filter((file) => file.path.toLowerCase().includes(needle)) : corpus; + const matches: GroundingMatch[] = []; + for (const file of searchable) { + const lines = file.content.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i]!.toLowerCase().includes(needle)) continue; + matches.push({ path: file.path, line: i + 1, text: lines[i]!.trim().slice(0, MAX_MATCH_TEXT_CHARS) }); + } + } + if (matches.length === 0) return null; + matches.sort( + (a, b) => pathRank(a.path) - pathRank(b.path) || lineRank(a.text) - lineRank(b.text) || a.path.localeCompare(b.path) || a.line - b.line, + ); + return { ...groundingTerm, matches: matches.slice(0, Math.max(1, maxMatchesPerTerm)) }; +} + +/** True when a cited path is graded by Codecov's patch gate (coverage.include: `src/**` and the engine's + * `src/**` — mirrors codecov.yml's ignore list + vitest.config.ts's include, kept in sync by hand). */ +function pathIsCoverageGraded(path: string): boolean { + if (path === "src/env.d.ts") return false; + return path.startsWith("src/") || path.startsWith("packages/loopover-engine/src/"); +} + +/** + * Draft a gate-ready issue body from a loose prompt + a searchable corpus. The output is a STARTING DRAFT + * in the heavy template (Context / Requirements / Deliverables / Test Coverage Requirements / Expected + * Outcome / Links & Resources) with grounded precedent cited as `path:line`, Requirements grouped one + * bullet per anchor file (never one per raw term — near-duplicate terms grounding to the same file must + * not read as separate requirements), and every ungroundable SPECIFIC term flagged ⚠️ UNGROUNDED at the + * exact spot a human decision is still needed. Sections a human must still fill are explicit + * `` markers, so nothing half-drafted can read as finished. Throws on a blank + * prompt — there is nothing to ground. Pure and deterministic: same prompt + corpus ⇒ same draft. + */ +export function draftIssueBody(prompt: string, corpus: readonly CorpusFile[], options: IssueDraftOptions = {}): IssueDraftResult { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) throw new Error("cannot draft from an empty prompt"); + + const terms = extractGroundingTerms(trimmedPrompt, options.maxTerms ?? DEFAULT_MAX_TERMS); + const groundedTerms: GroundedTerm[] = []; + const ungroundedTerms: GroundingTerm[] = []; + for (const term of terms) { + const grounded = groundTerm(term, corpus, options.maxMatchesPerTerm ?? DEFAULT_MAX_MATCHES_PER_TERM); + if (grounded) groundedTerms.push(grounded); + // A plain word failing to ground is vocabulary, not a spec gap — only specific tiers get flagged. + else if (term.tier !== "word") ungroundedTerms.push(term); + } + + // Dedupe citations by path:line (several terms often ground on the same line), and group the + // Requirements by each grounded term's TOP path so one anchor file yields one bullet. + const citations = new Map(); + for (const grounded of groundedTerms) { + for (const match of grounded.matches) { + const key = `${match.path}:${match.line}`; + const existing = citations.get(key); + if (existing) existing.terms.push(grounded.term); + else citations.set(key, { match, terms: [grounded.term] }); + } + } + const anchorGroups = new Map(); + for (const grounded of groundedTerms) { + const top = grounded.matches[0]!; + const group = anchorGroups.get(top.path); + if (group) group.terms.push(grounded.term); + else anchorGroups.set(top.path, { terms: [grounded.term], topLine: top.line }); + } + const citedPaths = [...new Set(groundedTerms.flatMap((grounded) => grounded.matches.map((match) => match.path)))]; + // matches is never empty on a GroundedTerm (groundTerm caps at ≥1), so index directly rather than + // optional-chain through a link that could never take its undefined side. + const anchorPath = groundedTerms.length > 0 ? groundedTerms[0]!.matches[0]!.path : undefined; + + const lines: string[] = ["## Context", "", `Loose intent (maintainer's own words): ${trimmedPrompt}`, ""]; + if (citations.size > 0) { + lines.push("Real precedent in the current checkout (verified by search, not memory):", ""); + for (const citation of citations.values()) { + lines.push( + `- \`${citation.match.path}:${citation.match.line}\` — \`${citation.match.text}\` (grounds ${citation.terms.map((term) => `"${term}"`).join(", ")})`, + ); + } + lines.push(""); + } else { + lines.push("> ⚠️ NO grounded precedent was found for ANY part of this prompt — every requirement below needs human verification before publishing.", ""); + } + + lines.push("## Requirements", ""); + if (anchorPath) { + lines.push( + `> ⚠️ Required pattern. Mirror the existing implementation in \`${anchorPath}\` — a differently-shaped`, + "> implementation, a second parallel mechanism, or an unspecified choice among multiple plausible", + "> artifacts does NOT satisfy this issue.", + "", + ); + } + for (const [path, group] of anchorGroups) { + lines.push( + `- Anchor the ${group.terms.map((term) => `"${term}"`).join(" / ")} work on \`${path}\` (see \`${path}:${group.topLine}\`); state in the PR how the change relates to it.`, + ); + } + for (const term of ungroundedTerms) { + lines.push( + `- > ⚠️ UNGROUNDED: no precedent found in the searched checkout for \`${term.term}\` — verify the requirement by hand (or drop it) before publishing; do NOT leave this marker in the published issue.`, + ); + } + lines.push(""); + + lines.push( + "## Deliverables", + "", + "- [ ] ", + "", + "## Test Coverage Requirements", + "", + ); + if (citedPaths.some(pathIsCoverageGraded)) { + lines.push( + "99%+ Codecov patch coverage (branch-counted) on every changed line — aim for 100%, including both", + "sides of every `??`/ternary/`&&`, invariant tests, and a regression test for any fix.", + ); + } else { + lines.push( + "The cited paths are outside coverage.include (`src/**` and the engine's `src/**`), so Codecov does", + "not gate this patch — full unit tests are still required per house convention where logic exists.", + ); + } + lines.push( + "", + "## Expected Outcome", + "", + "", + "", + "## Links & Resources", + "", + ); + for (const path of citedPaths) lines.push(`- \`${path}\``); + if (citedPaths.length === 0) lines.push("- "); + lines.push(""); + + return { body: lines.join("\n"), groundedTerms, ungroundedTerms }; +} diff --git a/test/unit/issue-drafting.test.ts b/test/unit/issue-drafting.test.ts new file mode 100644 index 0000000000..a804b611d2 --- /dev/null +++ b/test/unit/issue-drafting.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + draftIssueBody, + extractGroundingTerms, + groundTerm, + type CorpusFile, + type GroundingTerm, +} from "../../src/services/issue-drafting"; + +const identifierTerm = (term: string): GroundingTerm => ({ term, tier: "identifier" }); + +const CORPUS: CorpusFile[] = [ + { + path: "src/services/threshold-backtest.ts", + content: "// threshold registry\nexport function detectChangedThresholds(diff: string) {}\nconst KNOWN_THRESHOLDS = {};", + }, + { + path: "scripts/backtest-track-record.ts", + content: 'const THRESHOLD_BACKTEST_EVENT_TYPE = "calibration.threshold_backtest_run";\ncomputeRegressedVerdictTrackRecord(comparisons);', + }, + { + path: "packages/loopover-engine/src/calibration/backtest-score.ts", + content: "export function scoreBacktest() {}", + }, +]; + +describe("issue-drafting extractGroundingTerms (#8103)", () => { + it("extracts backticked fragments verbatim as exact-tier, ahead of everything else", () => { + const terms = extractGroundingTerms("wire `computeRegressedVerdictTrackRecord` into the trend view"); + expect(terms[0]).toEqual({ term: "computeRegressedVerdictTrackRecord", tier: "exact" }); + expect(terms).toContainEqual({ term: "trend", tier: "word" }); + expect(terms).toContainEqual({ term: "view", tier: "word" }); + }); + + it("extracts path-shaped tokens (slash paths and bare file.ext) as path-tier without re-extracting their fragments", () => { + const terms = extractGroundingTerms("mirror scripts/backtest-track-record.ts and wrangler.jsonc here"); + expect(terms).toContainEqual({ term: "scripts/backtest-track-record.ts", tier: "path" }); + expect(terms).toContainEqual({ term: "wrangler.jsonc", tier: "path" }); + // The path token is consumed before later tiers scan: no junk "record.ts" sub-token survives. + expect(terms.map((extracted) => extracted.term)).not.toContain("record.ts"); + }); + + it("extracts camelCase and snake_case/dotted identifiers as identifier-tier", () => { + const terms = extractGroundingTerms("call scoreBacktest and read metadata_json plus gate.checkMode"); + expect(terms).toContainEqual({ term: "scoreBacktest", tier: "identifier" }); + expect(terms).toContainEqual({ term: "metadata_json", tier: "identifier" }); + expect(terms).toContainEqual({ term: "gate.checkMode", tier: "identifier" }); + }); + + it("drops stopwords, short tokens, and case-insensitive duplicates", () => { + const terms = extractGroundingTerms("add the new backtest for Backtest and a db"); + expect(terms).toEqual([{ term: "backtest", tier: "word" }]); + }); + + it("caps at maxTerms in tier order and clamps a negative cap to zero", () => { + const prompt = "`exactOne` `exactTwo` scoreBacktest plainword"; + expect(extractGroundingTerms(prompt, 2).map((extracted) => extracted.term)).toEqual(["exactOne", "exactTwo"]); + expect(extractGroundingTerms(prompt).length).toBe(4); + expect(extractGroundingTerms(prompt, -1)).toEqual([]); + }); +}); + +describe("issue-drafting groundTerm (#8103)", () => { + it("returns null when the corpus has no trace of the term", () => { + expect(groundTerm(identifierTerm("frobnicator"), CORPUS)).toBeNull(); + }); + + it("finds case-insensitive substring matches with 1-based lines and trimmed text", () => { + const grounded = groundTerm(identifierTerm("KNOWN_thresholds"), CORPUS); + expect(grounded).toEqual({ + term: "KNOWN_thresholds", + tier: "identifier", + matches: [{ path: "src/services/threshold-backtest.ts", line: 3, text: "const KNOWN_THRESHOLDS = {};" }], + }); + }); + + it("ranks src/ ahead of packages/ ahead of scripts/ ahead of test/ ahead of everything else", () => { + const spread: CorpusFile[] = [ + { path: ".github/workflows/ci.yml", content: "needle" }, + { path: "test/unit/a.test.ts", content: "needle" }, + { path: "scripts/a.ts", content: "needle" }, + { path: "packages/loopover-engine/src/a.ts", content: "needle" }, + { path: "src/a.ts", content: "needle" }, + ]; + const grounded = groundTerm(identifierTerm("needle"), spread, 5); + expect(grounded!.matches.map((match) => match.path)).toEqual([ + "src/a.ts", + "packages/loopover-engine/src/a.ts", + "scripts/a.ts", + "test/unit/a.test.ts", + ".github/workflows/ci.yml", + ]); + }); + + it("breaks rank ties by path then line for deterministic output, and caps matches (min 1)", () => { + const tied: CorpusFile[] = [ + { path: "src/b.ts", content: "needle\nnot this line\nneedle" }, + { path: "src/a.ts", content: "needle" }, + ]; + const capped = groundTerm(identifierTerm("needle"), tied, 2); + expect(capped!.matches).toEqual([ + { path: "src/a.ts", line: 1, text: "needle" }, + { path: "src/b.ts", line: 1, text: "needle" }, + ]); + // A zero/negative cap still keeps one match -- a grounded term with no citation would be useless. + expect(groundTerm(identifierTerm("needle"), tied, 0)!.matches).toHaveLength(1); + }); + + it("prefers definition lines over comment/usage mentions within the same path rank", () => { + const withDefinition: CorpusFile[] = [ + { path: "src/a.ts", content: "// scoreBacktest is mentioned here first\nexport function scoreBacktest() {}" }, + ]; + const grounded = groundTerm(identifierTerm("scoreBacktest"), withDefinition); + expect(grounded!.matches[0]).toEqual({ path: "src/a.ts", line: 2, text: "export function scoreBacktest() {}" }); + }); + + it("demotes test files below live code regardless of directory", () => { + const mixed: CorpusFile[] = [ + { path: "src/services/a.test.ts", content: "needle" }, + { path: "src/services/a.ts", content: "needle" }, + ]; + const grounded = groundTerm(identifierTerm("needle"), mixed, 2); + expect(grounded!.matches.map((match) => match.path)).toEqual(["src/services/a.ts", "src/services/a.test.ts"]); + }); + + it("grounds word-tier terms only against files whose path contains the word — content-only mentions stay noise", () => { + const wordCorpus: CorpusFile[] = [ + { path: "src/api/routes.ts", content: "remoteTrackingSha and other track mentions" }, + { path: "scripts/backtest-track-record.ts", content: "the track record tool" }, + ]; + const grounded = groundTerm({ term: "track", tier: "word" }, wordCorpus); + expect(grounded!.matches).toEqual([{ path: "scripts/backtest-track-record.ts", line: 1, text: "the track record tool" }]); + // No file path carries the word at all -> not grounded, even though file CONTENT mentions it. + expect(groundTerm({ term: "verdict", tier: "word" }, wordCorpus)).toBeNull(); + }); + + it("bounds captured match text at 160 chars", () => { + const long = "x".repeat(400); + const grounded = groundTerm(identifierTerm("xxxx"), [{ path: "src/long.ts", content: long }]); + expect(grounded!.matches[0]!.text).toHaveLength(160); + }); +}); + +describe("issue-drafting draftIssueBody (#8103)", () => { + it("throws on an empty or whitespace-only prompt", () => { + expect(() => draftIssueBody("", CORPUS)).toThrow(/empty prompt/); + expect(() => draftIssueBody(" \n", CORPUS)).toThrow(/empty prompt/); + }); + + it("assembles the heavy template with grounded citations, the required-pattern callout, and maintainer markers", () => { + const result = draftIssueBody("extend `detectChangedThresholds` with a sibling for logic changes", CORPUS); + expect(result.body).toContain("## Context"); + expect(result.body).toContain("Loose intent (maintainer's own words): extend `detectChangedThresholds`"); + expect(result.body).toContain("Real precedent in the current checkout (verified by search, not memory):"); + expect(result.body).toContain("`src/services/threshold-backtest.ts:2`"); + expect(result.body).toContain("> ⚠️ Required pattern. Mirror the existing implementation in `src/services/threshold-backtest.ts`"); + expect(result.body).toContain("## Deliverables"); + expect(result.body).toContain("