|
| 1 | +// Issue-drafting core (#8103, epic #8082) — expands a loose maintainer prompt into a gate-ready draft |
| 2 | +// issue body in this repo's heavy template, grounded in REAL precedent from the current checkout. The gate |
| 3 | +// only enforces what an issue explicitly says (see .claude/skills/contributor-pipeline-gardening/ |
| 4 | +// reference.md, "The gate only enforces what the issue explicitly says"), so a publishable issue must cite |
| 5 | +// exact files/functions/patterns — this module automates that grounding pass and assembles the draft, and |
| 6 | +// it says so EXPLICITLY wherever it could not ground part of the prompt, never inventing a plausible but |
| 7 | +// unverified requirement. |
| 8 | +// |
| 9 | +// PURE, ADAPTER-AGNOSTIC (the issue's own ⚠️ required shape): no process.argv, no fs, no IO of any kind. |
| 10 | +// The caller supplies the searchable corpus (the CLI wrapper scripts/draft-issue.ts reads the checkout; a |
| 11 | +// future ORB dashboard API route can supply the same shape from whatever storage it has — a Worker has no |
| 12 | +// filesystem, which is exactly why the search input is data, not a path) and gets back a structured |
| 13 | +// result. Mirrors the pure-core/host-adapter split of packages/loopover-engine/src/calibration/ |
| 14 | +// signal-tracking.ts + src/review/signal-tracking-wire.ts. |
| 15 | +// |
| 16 | +// Hard boundaries (#8103): drafts BODY TEXT only. Never publishes, never picks labels/milestone/ |
| 17 | +// contributor-vs-maintainer-only status, never decides relationships — those stay maintainer decisions on |
| 18 | +// every issue, no exceptions. |
| 19 | + |
| 20 | +/** One searchable file of the checkout, supplied by the caller — `path` is repo-relative. */ |
| 21 | +export type CorpusFile = { path: string; content: string }; |
| 22 | + |
| 23 | +/** How specific an extracted term is. Backticked/path/identifier terms name something exact, so failing to |
| 24 | + * ground one is a real spec gap the draft must flag; a plain word failing to ground is just vocabulary and |
| 25 | + * is silently dropped rather than manufactured into a scary-but-empty ⚠️ warning. */ |
| 26 | +export type GroundingTermTier = "exact" | "path" | "identifier" | "word"; |
| 27 | + |
| 28 | +export type GroundingTerm = { term: string; tier: GroundingTermTier }; |
| 29 | + |
| 30 | +/** One place a grounding term was actually found in the supplied corpus. `line` is 1-based. */ |
| 31 | +export type GroundingMatch = { path: string; line: number; text: string }; |
| 32 | + |
| 33 | +/** A term from the loose prompt that WAS grounded in real precedent. */ |
| 34 | +export type GroundedTerm = GroundingTerm & { matches: readonly GroundingMatch[] }; |
| 35 | + |
| 36 | +export type IssueDraftOptions = { |
| 37 | + /** Cap on distinct terms extracted from the prompt (default 12). */ |
| 38 | + maxTerms?: number; |
| 39 | + /** Cap on matches kept per grounded term (default 3). */ |
| 40 | + maxMatchesPerTerm?: number; |
| 41 | +}; |
| 42 | + |
| 43 | +export type IssueDraftResult = { |
| 44 | + /** The drafted issue body (heavy template) for the maintainer to read, edit, and only then publish. */ |
| 45 | + body: string; |
| 46 | + groundedTerms: readonly GroundedTerm[]; |
| 47 | + /** Specific terms (exact/path/identifier tier) with NO precedent in the searched corpus — surfaced |
| 48 | + * verbatim in the body as ⚠️ UNGROUNDED so the human decision point is visible, never papered over. */ |
| 49 | + ungroundedTerms: readonly GroundingTerm[]; |
| 50 | +}; |
| 51 | + |
| 52 | +const DEFAULT_MAX_TERMS = 12; |
| 53 | +const DEFAULT_MAX_MATCHES_PER_TERM = 3; |
| 54 | +const MAX_MATCH_TEXT_CHARS = 160; |
| 55 | + |
| 56 | +// Words too generic to ground anything by themselves — searching these would match half the repo and |
| 57 | +// produce citation noise, the opposite of the explicit-precedent discipline this tool exists to serve. |
| 58 | +const STOPWORDS = new Set([ |
| 59 | + "the", "and", "for", "with", "that", "this", "from", "into", "when", "then", "them", "they", |
| 60 | + "should", "would", "could", "must", "have", "has", "had", "are", "was", "were", "been", |
| 61 | + "add", "adds", "added", "new", "make", "makes", "made", "use", "uses", "used", "using", |
| 62 | + "file", "files", "code", "test", "tests", "issue", "issues", "also", "only", "over", "under", |
| 63 | + "each", "every", "all", "any", "some", "not", "never", "always", "existing", "current", "real", |
| 64 | + "same", "way", "more", "less", "one", "two", "like", "its", "our", "your", "their", "than", |
| 65 | +]); |
| 66 | + |
| 67 | +/** |
| 68 | + * Extract the candidate grounding terms from a loose prompt, most-specific first: |
| 69 | + * 1. `exact` — backtick-quoted fragments, kept verbatim (the maintainer already named something); |
| 70 | + * 2. `path` — path-shaped tokens (contain `/` or end in a source-file extension); |
| 71 | + * 3. `identifier` — camelCase / snake_case / dotted words (the shapes real symbols take); |
| 72 | + * 4. `word` — remaining plain words ≥ 4 chars that aren't stopwords. |
| 73 | + * Each tier's matches are consumed from the text before the next tier scans, so a fragment never |
| 74 | + * double-extracts (e.g. `record.ts` out of `backtest-track-record.ts`). Deduplicated case-insensitively |
| 75 | + * in tier order, capped at `maxTerms`. |
| 76 | + */ |
| 77 | +export function extractGroundingTerms(prompt: string, maxTerms: number = DEFAULT_MAX_TERMS): GroundingTerm[] { |
| 78 | + const seen = new Set<string>(); |
| 79 | + const terms: GroundingTerm[] = []; |
| 80 | + const push = (term: string, tier: GroundingTermTier) => { |
| 81 | + const key = term.toLowerCase(); |
| 82 | + if (term.length < 3 || seen.has(key) || STOPWORDS.has(key)) return; |
| 83 | + seen.add(key); |
| 84 | + terms.push({ term, tier }); |
| 85 | + }; |
| 86 | + |
| 87 | + for (const [, quoted] of prompt.matchAll(/`([^`]+)`/g)) push(quoted!.trim(), "exact"); |
| 88 | + let rest = prompt.replace(/`[^`]*`/g, " "); |
| 89 | + |
| 90 | + const pathPattern = /[A-Za-z0-9_.-]*\/[A-Za-z0-9_./-]+|[A-Za-z0-9_-]+\.(?:tsx?|sql|ya?ml|jsonc?|md)\b/g; |
| 91 | + for (const [token] of rest.matchAll(pathPattern)) push(token, "path"); |
| 92 | + rest = rest.replace(pathPattern, " "); |
| 93 | + |
| 94 | + const identifierPattern = /\b(?:[a-z0-9]+(?:[A-Z][a-z0-9]*)+|[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+)\b/g; |
| 95 | + for (const [token] of rest.matchAll(identifierPattern)) push(token, "identifier"); |
| 96 | + rest = rest.replace(identifierPattern, " "); |
| 97 | + |
| 98 | + for (const [token] of rest.matchAll(/\b[A-Za-z]{4,}\b/g)) push(token, "word"); |
| 99 | + |
| 100 | + return terms.slice(0, Math.max(0, maxTerms)); |
| 101 | +} |
| 102 | + |
| 103 | +/** Rank source paths the way a precedent citation should read: live code first, then shared packages, |
| 104 | + * then scripts, then any test file (wherever it lives), everything else (workflows, config) last. */ |
| 105 | +function pathRank(path: string): number { |
| 106 | + if (path.includes("/test/") || path.includes(".test.")) return 3; |
| 107 | + if (path.startsWith("src/")) return 0; |
| 108 | + if (path.startsWith("packages/")) return 1; |
| 109 | + if (path.startsWith("scripts/")) return 2; |
| 110 | + return 4; |
| 111 | +} |
| 112 | + |
| 113 | +/** Definition lines make better citations than usages or comments — a contributor mirroring precedent |
| 114 | + * needs the declaration, not a random mention. */ |
| 115 | +function lineRank(text: string): number { |
| 116 | + return /^(?:export\s|function\s|class\s|const\s|type\s)/.test(text) ? 0 : 1; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Search the supplied corpus for one term (case-insensitive substring). Returns the term's grounded |
| 121 | + * matches — definition lines in best-ranked paths first, then by path/line for byte-stable deterministic |
| 122 | + * output — capped at `maxMatchesPerTerm`, or null when the corpus has no trace of the term at all. |
| 123 | + * A `word`-tier term only searches files whose PATH contains it: a plain word matching arbitrary comment |
| 124 | + * prose across the repo is citation noise, but a word that names a file ("backtest", "track") is a real |
| 125 | + * anchor — this is what keeps loose vocabulary from grounding to random unrelated lines. |
| 126 | + */ |
| 127 | +export function groundTerm( |
| 128 | + groundingTerm: GroundingTerm, |
| 129 | + corpus: readonly CorpusFile[], |
| 130 | + maxMatchesPerTerm: number = DEFAULT_MAX_MATCHES_PER_TERM, |
| 131 | +): GroundedTerm | null { |
| 132 | + const needle = groundingTerm.term.toLowerCase(); |
| 133 | + const searchable = groundingTerm.tier === "word" ? corpus.filter((file) => file.path.toLowerCase().includes(needle)) : corpus; |
| 134 | + const matches: GroundingMatch[] = []; |
| 135 | + for (const file of searchable) { |
| 136 | + const lines = file.content.split("\n"); |
| 137 | + for (let i = 0; i < lines.length; i += 1) { |
| 138 | + if (!lines[i]!.toLowerCase().includes(needle)) continue; |
| 139 | + matches.push({ path: file.path, line: i + 1, text: lines[i]!.trim().slice(0, MAX_MATCH_TEXT_CHARS) }); |
| 140 | + } |
| 141 | + } |
| 142 | + if (matches.length === 0) return null; |
| 143 | + matches.sort( |
| 144 | + (a, b) => pathRank(a.path) - pathRank(b.path) || lineRank(a.text) - lineRank(b.text) || a.path.localeCompare(b.path) || a.line - b.line, |
| 145 | + ); |
| 146 | + return { ...groundingTerm, matches: matches.slice(0, Math.max(1, maxMatchesPerTerm)) }; |
| 147 | +} |
| 148 | + |
| 149 | +/** True when a cited path is graded by Codecov's patch gate (coverage.include: `src/**` and the engine's |
| 150 | + * `src/**` — mirrors codecov.yml's ignore list + vitest.config.ts's include, kept in sync by hand). */ |
| 151 | +function pathIsCoverageGraded(path: string): boolean { |
| 152 | + if (path === "src/env.d.ts") return false; |
| 153 | + return path.startsWith("src/") || path.startsWith("packages/loopover-engine/src/"); |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * Draft a gate-ready issue body from a loose prompt + a searchable corpus. The output is a STARTING DRAFT |
| 158 | + * in the heavy template (Context / Requirements / Deliverables / Test Coverage Requirements / Expected |
| 159 | + * Outcome / Links & Resources) with grounded precedent cited as `path:line`, Requirements grouped one |
| 160 | + * bullet per anchor file (never one per raw term — near-duplicate terms grounding to the same file must |
| 161 | + * not read as separate requirements), and every ungroundable SPECIFIC term flagged ⚠️ UNGROUNDED at the |
| 162 | + * exact spot a human decision is still needed. Sections a human must still fill are explicit |
| 163 | + * `<!-- MAINTAINER: ... -->` markers, so nothing half-drafted can read as finished. Throws on a blank |
| 164 | + * prompt — there is nothing to ground. Pure and deterministic: same prompt + corpus ⇒ same draft. |
| 165 | + */ |
| 166 | +export function draftIssueBody(prompt: string, corpus: readonly CorpusFile[], options: IssueDraftOptions = {}): IssueDraftResult { |
| 167 | + const trimmedPrompt = prompt.trim(); |
| 168 | + if (!trimmedPrompt) throw new Error("cannot draft from an empty prompt"); |
| 169 | + |
| 170 | + const terms = extractGroundingTerms(trimmedPrompt, options.maxTerms ?? DEFAULT_MAX_TERMS); |
| 171 | + const groundedTerms: GroundedTerm[] = []; |
| 172 | + const ungroundedTerms: GroundingTerm[] = []; |
| 173 | + for (const term of terms) { |
| 174 | + const grounded = groundTerm(term, corpus, options.maxMatchesPerTerm ?? DEFAULT_MAX_MATCHES_PER_TERM); |
| 175 | + if (grounded) groundedTerms.push(grounded); |
| 176 | + // A plain word failing to ground is vocabulary, not a spec gap — only specific tiers get flagged. |
| 177 | + else if (term.tier !== "word") ungroundedTerms.push(term); |
| 178 | + } |
| 179 | + |
| 180 | + // Dedupe citations by path:line (several terms often ground on the same line), and group the |
| 181 | + // Requirements by each grounded term's TOP path so one anchor file yields one bullet. |
| 182 | + const citations = new Map<string, { match: GroundingMatch; terms: string[] }>(); |
| 183 | + for (const grounded of groundedTerms) { |
| 184 | + for (const match of grounded.matches) { |
| 185 | + const key = `${match.path}:${match.line}`; |
| 186 | + const existing = citations.get(key); |
| 187 | + if (existing) existing.terms.push(grounded.term); |
| 188 | + else citations.set(key, { match, terms: [grounded.term] }); |
| 189 | + } |
| 190 | + } |
| 191 | + const anchorGroups = new Map<string, { terms: string[]; topLine: number }>(); |
| 192 | + for (const grounded of groundedTerms) { |
| 193 | + const top = grounded.matches[0]!; |
| 194 | + const group = anchorGroups.get(top.path); |
| 195 | + if (group) group.terms.push(grounded.term); |
| 196 | + else anchorGroups.set(top.path, { terms: [grounded.term], topLine: top.line }); |
| 197 | + } |
| 198 | + const citedPaths = [...new Set(groundedTerms.flatMap((grounded) => grounded.matches.map((match) => match.path)))]; |
| 199 | + // matches is never empty on a GroundedTerm (groundTerm caps at ≥1), so index directly rather than |
| 200 | + // optional-chain through a link that could never take its undefined side. |
| 201 | + const anchorPath = groundedTerms.length > 0 ? groundedTerms[0]!.matches[0]!.path : undefined; |
| 202 | + |
| 203 | + const lines: string[] = ["## Context", "", `Loose intent (maintainer's own words): ${trimmedPrompt}`, ""]; |
| 204 | + if (citations.size > 0) { |
| 205 | + lines.push("Real precedent in the current checkout (verified by search, not memory):", ""); |
| 206 | + for (const citation of citations.values()) { |
| 207 | + lines.push( |
| 208 | + `- \`${citation.match.path}:${citation.match.line}\` — \`${citation.match.text}\` (grounds ${citation.terms.map((term) => `"${term}"`).join(", ")})`, |
| 209 | + ); |
| 210 | + } |
| 211 | + lines.push(""); |
| 212 | + } else { |
| 213 | + lines.push("> ⚠️ NO grounded precedent was found for ANY part of this prompt — every requirement below needs human verification before publishing.", ""); |
| 214 | + } |
| 215 | + |
| 216 | + lines.push("## Requirements", ""); |
| 217 | + if (anchorPath) { |
| 218 | + lines.push( |
| 219 | + `> ⚠️ Required pattern. Mirror the existing implementation in \`${anchorPath}\` — a differently-shaped`, |
| 220 | + "> implementation, a second parallel mechanism, or an unspecified choice among multiple plausible", |
| 221 | + "> artifacts does NOT satisfy this issue.", |
| 222 | + "", |
| 223 | + ); |
| 224 | + } |
| 225 | + for (const [path, group] of anchorGroups) { |
| 226 | + lines.push( |
| 227 | + `- 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.`, |
| 228 | + ); |
| 229 | + } |
| 230 | + for (const term of ungroundedTerms) { |
| 231 | + lines.push( |
| 232 | + `- > ⚠️ 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.`, |
| 233 | + ); |
| 234 | + } |
| 235 | + lines.push(""); |
| 236 | + |
| 237 | + lines.push( |
| 238 | + "## Deliverables", |
| 239 | + "", |
| 240 | + "- [ ] <!-- MAINTAINER: name each concrete artifact (exact file paths) — the gate enforces only what is written here. -->", |
| 241 | + "", |
| 242 | + "## Test Coverage Requirements", |
| 243 | + "", |
| 244 | + ); |
| 245 | + if (citedPaths.some(pathIsCoverageGraded)) { |
| 246 | + lines.push( |
| 247 | + "99%+ Codecov patch coverage (branch-counted) on every changed line — aim for 100%, including both", |
| 248 | + "sides of every `??`/ternary/`&&`, invariant tests, and a regression test for any fix.", |
| 249 | + ); |
| 250 | + } else { |
| 251 | + lines.push( |
| 252 | + "The cited paths are outside coverage.include (`src/**` and the engine's `src/**`), so Codecov does", |
| 253 | + "not gate this patch — full unit tests are still required per house convention where logic exists.", |
| 254 | + ); |
| 255 | + } |
| 256 | + lines.push( |
| 257 | + "", |
| 258 | + "## Expected Outcome", |
| 259 | + "", |
| 260 | + "<!-- MAINTAINER: state what is true after this ships that was not true before. -->", |
| 261 | + "", |
| 262 | + "## Links & Resources", |
| 263 | + "", |
| 264 | + ); |
| 265 | + for (const path of citedPaths) lines.push(`- \`${path}\``); |
| 266 | + if (citedPaths.length === 0) lines.push("- <!-- MAINTAINER: no grounded files to cite — add the real anchors by hand. -->"); |
| 267 | + lines.push(""); |
| 268 | + |
| 269 | + return { body: lines.join("\n"), groundedTerms, ungroundedTerms }; |
| 270 | +} |
0 commit comments