/** * Signal mining: turn the evidence the store already holds into intake items * an operator can groom (Phase 4, chokepoints). * * The fleet records a great deal about how work went wrong and then never * revisits it. A settlement flag is read once, in the tick digest of the hour * it appeared; a failure class is counted in `stats` and otherwise forgotten. * Nothing looks across runs and says "this keeps happening, someone should file * an issue" — which is exactly the observation that turns recurring waste into * fixed work. This module is that pass, and nothing else. * * ## Pure by construction * * Rows in, signals out. No store, no tracker, no `Date.now()` — the caller * (the daemon's hourly pass) reads `statsRuns(project, since)`, hands them over * with the same `since` it used, and files each returned signal through * `recordIntake({ project, text, source })`. `source` is the idempotency key, * so re-running the pass on an unchanged window files nothing new; the caller's * hour gate is belt-and-braces rather than the thing that makes it safe. * * ## Why a signal needs two occurrences * * One settlement flag and one failed run are *already surfaced*, each by its * own report, in the tick that produced them. Filing an intake item for them * would duplicate that surface and bury the operator within a day. What no * existing surface can see is the pattern across runs — the same test file * weakened twice, the same class of failure recurring, the same CI job red on * unrelated issues. That is the whole value here, so two occurrences inside the * window is the floor for every kind. * * ## Three signals, and where each one's evidence actually lives * * 1. **test-weakening** — `runs.settlementFlags`, restricted to the weakening * family `detectWeakening` produces (`test-file-deleted`, `test-disabled`, * `assertions-removed`, `test-timeout-raised`). Grouped by file, because * "this file's coverage keeps eroding" is the unit an operator can act on. * * 2. **failure-class-repeat** — `runs.failureClass`, grouped by class. No * exclusion list: an exclusion list is where the next class quietly hides, * and a repeating "infrastructure" class is a real issue to file (our * runners keep dying) rather than noise. The text carries the recorded * recovery actions so a groomer can dismiss a self-healing class in one * read. * * 3. **ci-job-red** — the failing *job names*, parsed out of `runs.lastError`. * * That third one is worth explaining, because the obvious source for it does * not work. A `Classification`'s `evidence` line (failure-class.ts) names the * failing checks, but it is never persisted as a column: it rides into an * escalation and is gone. Mining it would need a schema change. * * What *is* persisted, with no schema change at all, is the tracker's own * verdict sentence: `settlePushedGreen` writes `verification.reason` verbatim * into `runs.lastError` on a CI-red terminal row (settlement.ts), and the * GitHub adapter builds that reason as `Checks failed: (), * …` on both its GraphQL and REST planes. The review-revision path persists the * classifier's own `checks failed: (), …` into the same column. So * the job name is already on the row, in one of two exact shapes this module * parses — and only those two, anchored, so an unrelated `lastError` yields * nothing rather than a fabricated job name. * * The honest limits of that, stated rather than papered over: * * - A row whose `lastError` was later overwritten (a merge-conflict recovery * rewrites it) no longer carries its CI red, and this pass cannot see it. * - Only the checks named in that one sentence are visible; a red that GitHub * reported after the row settled is not on the row. * * Both make this signal an undercount, never an overcount, which is the right * direction for something that files work for a human: a missed pattern costs * a week, an invented one costs trust. */ import type { RunRecord, SettlementFlagKind } from "./types.ts"; /** What a mined signal is about. Part of the `source` key, so these names are * persisted in `intake_items` and must not be renamed casually. */ export type MinedSignalKind = "test-weakening" | "failure-class-repeat" | "ci-job-red"; /** One filed-once observation about a repeating pattern. */ export interface MinedSignal { kind: MinedSignalKind; /** What the pattern is about: a file path, a failure class, a CI job name. */ subject: string; /** `mined::` — stable across passes, distinct across subjects, * and the idempotency key `recordIntake` derives its row id from. */ source: string; /** What the operator reads. Self-contained: a groomer must be able to act * (or dismiss) without re-deriving anything from the store. */ text: string; /** Matching occurrences inside the window. Always ≥ {@link MIN_OCCURRENCES}. */ occurrences: number; /** Distinct issues the pattern spans, ascending. */ issues: number[]; } export interface MiningInput { /** Named in the text so an item read months later says which fleet it came from. */ project: string; now: number; /** * The project's rows over the window — `store.statsRuns(project, since)`. * Rows outside the window are ignored here too, so passing a wider read is * safe: the window, not the query, defines what counts. */ runs: readonly RunRecord[]; /** Defaults to seven days, the window the plan specifies. */ windowMs?: number; } /** Seven days: long enough for a weekly pattern, short enough that a fixed * problem stops being re-filed. */ export const DEFAULT_MINING_WINDOW_MS = 7 * 86_400_000; /** Below this, the per-run report already said it; see the module header. */ export const MIN_OCCURRENCES = 2; /** * The settlement-flag kinds that mean coverage got weaker. The other kinds are * deliberately absent: `lane-escape` and the `claimed-proof-*` family are about * one run's honesty (the settlement report is the right surface, and a repeat * is not a filable unit of work), `pr-adopted` is a recovery succeeding, and * `changed-line-missing` / `base-branch-red` are faults with their own loud * paths already. */ const WEAKENING_KINDS: Partial> = { "test-file-deleted": true, "test-disabled": true, "assertions-removed": true, "test-timeout-raised": true, }; /** * The two persisted shapes that name failing CI jobs, both anchored at the * start of the value: the tracker's verdict (`Checks failed: `, written by * `settlePushedGreen`) and the classifier's line (`checks failed: `, written by * the review-revision path). Anchored on purpose — an arbitrary `lastError` * that merely mentions the phrase must not be mined for job names. */ const CHECKS_FAILED_FORM = /^checks failed:\s*(.+)$/i; /** * One ` ()` entry, consuming its trailing separator. * * Matched rather than split on commas, because a job name may itself contain * one (`build, test (macos)`) and a comma split would invent a job called * "build". The trailing parenthetical is required and cannot nest, which is * what both producers always emit; that requirement is also what stops a prose * sentence from yielding imaginary job names. */ const CHECK_ENTRY_FORM = /\s*(.+?)\s*\([^()]*\)\s*(?:,|$)/g; /** The store's own window convention, as `stats.ts` uses it. */ const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt; /** One subject's accumulating evidence. */ interface Bucket { occurrences: number; issues: Set; repos: Set; /** Free-form detail lines, bounded, newest last — what makes the text actionable. */ details: string[]; /** Kinds/actions seen for this subject, in first-seen order. */ tags: string[]; } /** How many evidence lines one signal's text carries. Enough to act on, few * enough that an intake list stays readable. */ const MAX_DETAILS = 3; function bucket(map: Map, key: string): Bucket { let found = map.get(key); if (found === undefined) { found = { occurrences: 0, issues: new Set(), repos: new Set(), details: [], tags: [] }; map.set(key, found); } return found; } function note(b: Bucket, run: RunRecord, detail: string, tag?: string): void { b.occurrences += 1; b.issues.add(run.issue); b.repos.add(run.repo); if (b.details.length < MAX_DETAILS) b.details.push(detail); if (tag !== undefined && !b.tags.includes(tag)) b.tags.push(tag); } /** `2026-08-24`, so a text line dates its own evidence without a clock. */ function day(at: number): string { return new Date(at).toISOString().slice(0, 10); } /** * Every failing job named by a persisted CI-red `lastError`, or an empty list * when the value is not one of the two known shapes. */ export function failingJobsFromLastError(lastError: string | undefined): string[] { if (lastError === undefined) return []; const matched = CHECKS_FAILED_FORM.exec(lastError.trim()); if (matched === null) return []; const jobs: string[] = []; for (const entry of matched[1]!.matchAll(CHECK_ENTRY_FORM)) { // Whatever the parenthetical says is not read: the producers have changed // conclusion spelling across versions, and the list is failures by // construction. Only the name is taken, and only when it is non-empty. const name = entry[1]!.trim(); if (name.length > 0 && !jobs.includes(name)) jobs.push(name); } return jobs; } /** * Mine one project's window. Deterministic: same rows and `now` produce the * same signals in the same order (loudest first, then alphabetical), so a * caller can diff two passes. */ export function mineSignals(input: MiningInput): MinedSignal[] { const since = input.now - (input.windowMs ?? DEFAULT_MINING_WINDOW_MS); const weakening = new Map(); const classes = new Map(); const jobs = new Map(); for (const run of input.runs) { const at = WINDOW_KEY(run); if (at < since || at > input.now) continue; for (const flag of run.settlementFlags ?? []) { if (WEAKENING_KINDS[flag.kind] !== true) continue; note( bucket(weakening, flag.file), run, `${day(at)} #${run.issue} ${flag.kind}${flag.line === undefined ? "" : ` line ${flag.line}`}: ${flag.detail}` + (flag.unattributed === true ? " [the dispatching issue never named this file]" : ""), flag.kind, ); } if (run.failureClass !== undefined) { note( bucket(classes, run.failureClass), run, `${day(at)} #${run.issue} (${run.repo}) attempt ${run.attempt}${run.lastError === undefined ? "" : `: ${oneLine(run.lastError)}`}`, run.recoveryAction, ); } for (const job of failingJobsFromLastError(run.lastError)) { note(bucket(jobs, job), run, `${day(at)} #${run.issue} (${run.repo}) attempt ${run.attempt}`); } } const windowDays = Math.round((input.now - since) / 86_400_000); const signals: MinedSignal[] = [ ...emit(weakening, "test-weakening", (subject, b) => `Test coverage for \`${subject}\` was weakened in ${plural(b.occurrences, "settled run")} ` + `over the last ${windowDays} days (${b.tags.join(", ")}). ` + `A single weakening is reviewed in its own settlement report; this is the repeat that report cannot see, ` + `so the question is whether the coverage was replaced or quietly lost.`, ), ...emit(classes, "failure-class-repeat", (subject, b) => `${plural(b.occurrences, "run")} failed as \`${subject}\` over the last ${windowDays} days ` + `(recovery recorded: ${b.tags.length === 0 ? "none" : b.tags.join(", ")}). ` + `Recurring at this rate it is a fleet problem rather than one run's bad luck: either the class needs a ` + `mechanical recovery it does not have, or the thing it keeps tripping over needs fixing.`, ), ...emit(jobs, "ci-job-red", (subject, b) => `CI job \`${subject}\` was red on ${plural(b.occurrences, "settled run")} over the last ${windowDays} days. ` + `Job names come from the verdict sentence persisted on each run, so this is an undercount, never an ` + `overcount. Repeating across unrelated issues usually means the job itself is broken, flaky or ` + `mis-scoped rather than that every diff was wrong.`, ), ]; return signals.sort( (a, b) => b.occurrences - a.occurrences || a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject), ); } /** Buckets over the floor, rendered. */ function emit( buckets: Map, kind: MinedSignalKind, headline: (subject: string, b: Bucket) => string, ): MinedSignal[] { const out: MinedSignal[] = []; for (const [subject, b] of buckets) { if (b.occurrences < MIN_OCCURRENCES) continue; const issues = [...b.issues].sort((x, y) => x - y); out.push({ kind, subject, source: `mined:${kind}:${subject}`, text: [ headline(subject, b), "", `Issues: ${issues.map((i) => `#${i}`).join(", ")} · repos: ${[...b.repos].sort().join(", ")}`, ...b.details.map((d) => `- ${d}`), ...(b.occurrences > b.details.length ? [`- …and ${b.occurrences - b.details.length} more in the window`] : []), ].join("\n"), occurrences: b.occurrences, issues, }); } return out; } function plural(n: number, noun: string): string { return `${n} ${noun}${n === 1 ? "" : "s"}`; } /** One bounded line: an intake item is a summary, not a transcript. */ function oneLine(text: string): string { const flat = text.replace(/\s+/g, " ").trim(); return flat.length <= 160 ? flat : `${flat.slice(0, 157)}…`; }