From ab797fffc48fbc2f37acd4399fc0ffc35fbc0e24 Mon Sep 17 00:00:00 2001 From: Garfielk <7425454@qq.com> Date: Thu, 15 Oct 2026 13:15:26 +0800 Subject: [PATCH] fix(core-change-watch): report bounded history coverage accurately Fix issue #195 defect 1 by probing one scoped commit beyond the analysis cap and reporting complete, capped, unknown, or skipped window coverage without treating identical hotspots as proof. The change is specified in docs/specs/2026-10-15-195-history-window-coverage.md and keeps the extra probe out of hotspot statistics. Validated with the focused real-Git regressions, the full test suite, documentation link checks, and package verification. Co-authored-by: Codex (GPT-6 Sol) --- .../2026-10-15-195-history-window-coverage.md | 111 +++++++++++ .../project-harness/core-change-watch.md | 24 ++- scripts/core-change-watch/evidence-pack.mjs | 21 +- .../core-change-watch/git-history-profile.mjs | 28 ++- test/governance/core-change-watch.test.mjs | 179 +++++++++++++++++- 5 files changed, 350 insertions(+), 13 deletions(-) create mode 100644 docs/specs/2026-10-15-195-history-window-coverage.md diff --git a/docs/specs/2026-10-15-195-history-window-coverage.md b/docs/specs/2026-10-15-195-history-window-coverage.md new file mode 100644 index 00000000..b6a456ee --- /dev/null +++ b/docs/specs/2026-10-15-195-history-window-coverage.md @@ -0,0 +1,111 @@ +# Truthful Git History Window Coverage + +## Traceability + +- Spec ID: 195-history-window-coverage +- Story: [Issue #195, defect 1](https://github.com/QoderAI/better-harness/issues/195) +- Status: Implemented + +## Intent + +Keep bounded hotspot evidence from claiming complete time-window coverage when +Git history has not been exhausted. Coverage is separate from ranking confidence +and from similarity between hotspot arrays. + +## Acceptance Scenarios + +- AC-1: For real commits aged 150/75/10/5/1 days, a cap of 3 preserves the + bounded counts [3,3,3], exposes additional history, marks the 90/180-day + windows capped, and makes history-window-coverage review rather than ready. + The 30-day window remains unknown: an older next commit cannot prove that + later unscanned commits have older timestamps. A cap of 10 yields [3,4,5] + with complete coverage and ready, even though ranking confidence can be low. +- AC-2: Naturally identical windows and history exactly equal to the cap remain + complete when Git has no additional commits. Neither equality nor reaching + the cap alone proves truncation. Only the requested number of commits + contributes hotspot statistics; the extra probe is not analyzed. +- AC-3: The range exposes whether more scoped commits exist, whether the + repository is shallow, and the minimum/maximum analyzed commit timestamps + (ISO strings, null for empty history). Each window adds coverage status and + reasons. Shallow history is unknown unless an omitted in-window commit proves + capping. Non-monotonic timestamps must never establish false completeness. +- AC-4: Unborn repositories retain valid empty history-profile results; their + coverage can be complete but the matrix requires observed scoped commits. + The evidence pack's existing HEAD requirement is outside this fix. Skipped history retains + stable window fields with skipped coverage and a review matrix result. + Custom positive window sizes use their own coverage, including a single + requested window, without requiring three default windows. +- AC-5: Coverage uses the same Git scope/pathspec as statistics. File ignores + and language filters do not erase commit-limit evidence. Existing output + fields and trend labels remain available; interpretation and canonical + guidance explicitly qualify capped/unknown/skipped windows. A zero-count + analyzed window describes the observed sample, not the absence of commits + in unscanned history; this also holds for non-monotonic timestamps. + +## Non-Goals + +- Other defects or follow-up comments in issue #195; changes to scoring, + project discovery, asset inventory, or existing PR #202. +- Fetching/deepening history, unbounded history scans, reconstructing omitted + hotspots, or proving that all later commits predate a time boundary. +- Removing fields, new dependencies, UI changes, release metadata, pushing, + publishing, creating a PR, or committing on the user's behalf. + +## Plan and Tasks + +1. Convert the supplied reproduction into behavior tests through the public + analyzeGitHistoryProfile and buildEvidencePack seams in + test/governance/core-change-watch.test.mjs; retain a real failing run. +2. In scripts/core-change-watch/git-history-profile.mjs, read at most cap + 1 + commits with the existing scoped Git argv, retain only cap for analysis, + and inspect shallow state. The next commit can prove capping for a window; + otherwise any remaining or shallow history leaves completeness unknown. +3. In scripts/core-change-watch/evidence-pack.mjs, require complete coverage + and observed history for ready; expose coverage in matrix evidence and + preserve the no-history schema fallback with skipped status. +4. Update the canonical [Core Change Watch guidance](../../references/project-harness/core-change-watch.md), + regenerate the routing graph, run the focused, documentation, full test, + and package gates, then perform standards/spec and traceability review. + +## Test and Review Evidence + +- Baseline: upstream/main 840ff549; supplied real-Git reproduction failed again + on 2026-10-15 with truncated windows must not claim ready coverage. +- AC-1 red/green: `npx vitest run test/governance/core-change-watch.test.mjs + -t 'capped history windows'` first failed with ready instead of review, then + passed after the fix. The original reproduction also passed against the + new worktree's modules (only its import paths changed). +- AC-1 through AC-5: `npx vitest run test/governance/core-change-watch.test.mjs` + passed 34/34, including all seven new real-Git behavior regressions. +- AC-5 follow-up: independent review found that a capped/unknown zero-count + sample still claimed no commits landed in the window. The new real-Git + regression failed on that wording before the fix, then passed with observed + sample wording for unknown/0 and capped/0, while a full scan found one commit. + Incremental Standards and Spec reviews reported 0 new findings and confirmed + the P2 closed. +- Documentation: `node scripts/doc-link-graph/cli.mjs skills/better-harness` + regenerated an unchanged graph (39 files, 56 links); + `npx vitest run test/skills-docs/doc-link-graph.test.mjs` passed 8/8. +- Full suite: `npm test -- --maxWorkers=4` passed 1789 tests, with 7 skipped + (119 files passed, 1 skipped). The first default run failed two Node + permission tests because a temporary dependency symlink escaped the + worktree. With physical local dependencies, a second default run failed an + unchanged Skill watcher's 3-second deadline; its isolated suite passed 9/9. + These failures are retained in the local delivery logs, not counted as passes. +- Packaging: `npm run pack:verify` passed (747 npm entries, 1008 runtime ZIP + entries). `git diff --check` passed. Commands above ran on macOS through + `mise exec node@24.21.0 --`; Windows/Linux execution remains unverified. +- Independent read-only Standards and Spec AI reviews each reported 0 findings. + The over-engineering review found no actionable deletion. Review Readiness + confirmed this issue/spec/AC chain, five changed files, no generated graph + drift, no staged changes, no unrelated metadata, and no new commit. +- Risk: one extra numstat commit is bounded overhead, not hotspot evidence. + Unknown is intentionally conservative when a cap hides older traversal + entries; no monotonic timestamp assumption is made. Scope and shallow + restrictions describe accessible Git history, not remote completeness. +- Compatibility: additive coverage/range metadata; ready can become review. + Existing labels describe requested horizons, not independent corroboration. + No dependency or CLI flag changes. Revert the focused diff to recover. +- AI: Generated by Codex; independent AI review and local runtime receipts + will be recorded here. No human technical review or three-platform execution + is implied by local macOS checks. diff --git a/references/project-harness/core-change-watch.md b/references/project-harness/core-change-watch.md index 3f52fbb2..dbf1d3ae 100644 --- a/references/project-harness/core-change-watch.md +++ b/references/project-harness/core-change-watch.md @@ -79,7 +79,29 @@ segment; retain them only as bounded search leads. `historyProfile.confidence` describes whether the inspected commit count and time span are sufficient for hotspot ranking. It does not set final model or -report confidence. For a readiness claim based on history: +report confidence. Window completeness is separate: + +- `range.hasMoreCommits` reports whether a scoped cap + 1 probe found more + history; the extra commit never contributes hotspot statistics. Reaching + the cap alone, or identical hotspot arrays, does not prove missing history. +- `range.oldestTimestamp` and `range.newestTimestamp` are the minimum/maximum + analyzed commit times, not a guarantee that all commits between them were + scanned. `range.shallow` records the local shallow-repository boundary. +- Each window's `coverage.status` is `complete` when accessible scoped history + was exhausted in a non-shallow repository, `capped` when the next omitted + commit is in the window, or `unknown` when remaining/shallow history prevents + proof of completeness. An older next commit cannot prove that later + unscanned commits are older: Git timestamps need not be monotonic. +- Coverage describes the Git scope/pathspec before file-ignore and language + filtering. `--no-history` emits `skipped` coverage and null range observations. +- `history-window-coverage` is `ready` only with observed scoped commits and + complete coverage for every requested window, including custom horizons. + Empty, skipped, capped, or unknown history requires `review`. Trend labels + name requested horizons; capped/unknown windows do not independently prove + sustained or legacy activity. Low ranking confidence can coexist with + complete coverage. + +For a readiness claim based on history: - name the inspected window, such as the latest 30 commits or last 90 days; - open the commits or nearby specs, tests, release notes, or review artifacts diff --git a/scripts/core-change-watch/evidence-pack.mjs b/scripts/core-change-watch/evidence-pack.mjs index d11b2ca8..e228e8e9 100644 --- a/scripts/core-change-watch/evidence-pack.mjs +++ b/scripts/core-change-watch/evidence-pack.mjs @@ -395,10 +395,17 @@ function buildReviewMatrix(projectProfile, historyProfile, coreAnalysis, diffImp ), matrixItem( "history-window-coverage", - "History uses 30/90/180 style windows with confidence notes", - historyProfile.historyWindows.length >= 3 && historyProfile.confidence ? "ready" : "review", - { windows: historyProfile.historyWindows.map((item) => item.days), confidence: historyProfile.confidence?.confidence ?? "unknown" }, - "Low history confidence should downgrade hotspot certainty, not block source inspection.", + "Requested history windows have complete coverage with confidence notes", + historyProfile.range.analyzedCommits > 0 + && historyProfile.historyWindows.length > 0 + && historyProfile.historyWindows.every((item) => item.coverage.status === "complete") + && historyProfile.confidence ? "ready" : "review", + { + windows: historyProfile.historyWindows.map((item) => item.days), + coverage: historyProfile.historyWindows.map((item) => ({ days: item.days, ...item.coverage })), + confidence: historyProfile.confidence?.confidence ?? "unknown", + }, + "Capped, unknown, skipped, or empty history needs review. Ranking confidence is separate from coverage and does not block source inspection.", ), matrixItem( "large-repo-safety", @@ -492,13 +499,17 @@ export async function buildEvidencePack(options = {}) { days, since: null, analyzedCommits: 0, + coverage: { status: "skipped", reasons: ["History scan skipped by --no-history."] }, trendLabel: days <= 30 ? "recent-hotspots" : days <= 90 ? "sustained-hotspots" : "legacy-or-long-range-hotspots", interpretation: "History scan skipped; this window is present for schema stability only.", hotFiles: [], supportingHotFiles: [], hotPaths: [], })), - range: { maxCommits: 0, analyzedCommits: 0, newest: null, oldest: null }, + range: { + maxCommits: 0, analyzedCommits: 0, newest: null, oldest: null, + hasMoreCommits: null, shallow: null, newestTimestamp: null, oldestTimestamp: null, + }, } : await analyzeGitHistoryProfile({ cwd: repoRoot, diff --git a/scripts/core-change-watch/git-history-profile.mjs b/scripts/core-change-watch/git-history-profile.mjs index 30372c5c..f46335bc 100644 --- a/scripts/core-change-watch/git-history-profile.mjs +++ b/scripts/core-change-watch/git-history-profile.mjs @@ -137,20 +137,27 @@ function collectHistoryStats(commits, allowedLanguages, analysisScope) { }; } -function historyWindows(commits, allowedLanguages, daysList, analysisScope) { +function historyWindows(commits, allowedLanguages, daysList, analysisScope, nextCommit, shallow) { const nowSeconds = Math.floor(Date.now() / 1000); return daysList.map((days) => { const sinceSeconds = nowSeconds - days * 24 * 60 * 60; const windowCommits = commits.filter((commit) => commit.timestamp >= sinceSeconds); const stats = collectHistoryStats(windowCommits, allowedLanguages, analysisScope); const trend = trendForWindow(days, windowCommits.length); + const reasons = []; + if (nextCommit) reasons.push("Additional scoped commits exist beyond maxCommits; unscanned timestamps may fall in this window."); + if (shallow) reasons.push("Shallow history cannot establish complete window coverage."); + const status = nextCommit?.timestamp >= sinceSeconds ? "capped" : reasons.length > 0 ? "unknown" : "complete"; return { days, since: new Date(sinceSeconds * 1000).toISOString(), analyzedCommits: windowCommits.length, + coverage: { status, reasons }, trendLabel: trend.label, - interpretation: trend.interpretation, + interpretation: status === "complete" + ? trend.interpretation + : `${trend.interpretation} Coverage is ${status}; this horizon does not establish hotspot persistence.`, hotFiles: sortedHotFiles(stats.fileStats, 10), supportingHotFiles: sortedHotFiles(stats.supportingFileStats, 10), hotPaths: sortedCounts(stats.pathStats, 10).map((item) => ({ @@ -164,7 +171,7 @@ function historyWindows(commits, allowedLanguages, daysList, analysisScope) { function trendForWindow(days, analyzedCommits) { const horizon = days <= 30 ? "recent" : days <= 90 ? "sustained" : "long-range"; const label = days <= 30 ? "recent-hotspots" : days <= 90 ? "sustained-hotspots" : "legacy-or-long-range-hotspots"; - const emptySuffix = analyzedCommits === 0 ? " No commits landed in this window, so treat it as missing evidence." : ""; + const emptySuffix = analyzedCommits === 0 ? " No commits were observed in the analyzed sample for this window." : ""; return { label, interpretation: `${horizon} window for spotting ${label.replaceAll("-", " ")} from bounded git history.${emptySuffix}`, @@ -231,7 +238,7 @@ export async function analyzeGitHistoryProfile(options = {}) { try { output = git( repoRoot, - ["log", `-${maxCommits}`, "--numstat", "--format=commit%x1f%H%x1f%ct%x1f%s", ...scopePathspecArgs(analysisScope)], + ["log", `-${maxCommits + 1}`, "--numstat", "--format=commit%x1f%H%x1f%ct%x1f%s", ...scopePathspecArgs(analysisScope)], { timeout: 30_000 }, ); } catch (error) { @@ -249,8 +256,13 @@ export async function analyzeGitHistoryProfile(options = {}) { if (output === undefined) throw error; } const parsedCommits = parseLogWithNumstat(output, analysisScope); - const filteredHistory = applyCommitIgnores(parsedCommits, options.ignore); + // One extra commit proves whether the limit binds; it never contributes stats. + // An older next commit cannot prove completeness: Git timestamps need not be monotonic. + const nextCommit = parsedCommits[maxCommits]; + const shallow = git(repoRoot, ["rev-parse", "--is-shallow-repository"]).trim() === "true"; + const filteredHistory = applyCommitIgnores(parsedCommits.slice(0, maxCommits), options.ignore); const commits = filteredHistory.commits; + const timestamps = commits.map((commit) => commit.timestamp); const stats = collectHistoryStats(commits, allowedLanguages, analysisScope); const maxHotFiles = positiveInt(options.maxHotFiles, 40); @@ -266,9 +278,13 @@ export async function analyzeGitHistoryProfile(options = {}) { analyzedCommits: commits.length, newest: commits[0]?.shortSha ?? null, oldest: commits.at(-1)?.shortSha ?? null, + hasMoreCommits: Boolean(nextCommit), + shallow, + newestTimestamp: timestamps.length > 0 ? new Date(Math.max(...timestamps) * 1000).toISOString() : null, + oldestTimestamp: timestamps.length > 0 ? new Date(Math.min(...timestamps) * 1000).toISOString() : null, }, confidence: historyConfidence(commits, maxCommits), - historyWindows: historyWindows(commits, allowedLanguages, windowDays, analysisScope), + historyWindows: historyWindows(commits, allowedLanguages, windowDays, analysisScope, nextCommit, shallow), languageTouchCounts: sortedCounts(stats.languageStats, 12).map((item) => ({ language: item.name, touches: item.count, diff --git a/test/governance/core-change-watch.test.mjs b/test/governance/core-change-watch.test.mjs index d9b1f4ee..cac590a1 100644 --- a/test/governance/core-change-watch.test.mjs +++ b/test/governance/core-change-watch.test.mjs @@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { test } from "vitest"; +import { test, vi } from "vitest"; import { fileRoleFor } from "../../scripts/core-change-watch/common.mjs"; import { analyzeChangeDrift } from "../../scripts/core-change-watch/change-drift.mjs"; @@ -21,6 +21,7 @@ function git(cwd, args, options = {}) { encoding: "utf8", stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], input: options.input, + env: { ...process.env, ...options.env }, }); if (result.status !== 0) { @@ -65,6 +66,182 @@ async function makeRepo(files) { return repo; } +const HISTORY_NOW = Date.parse("2026-10-15T12:00:00Z"); + +async function makeDatedHistory(ages) { + const repo = await makeRepo({ "src/app.js": "export const value = 0;\n" }); + for (const [index, days] of ages.entries()) { + await writeFixtureFile(repo, "src/app.js", `export const value = ${index};\n`); + git(repo, ["add", "."]); + const date = new Date(HISTORY_NOW - days * 86_400_000).toISOString(); + git(repo, index === 0 ? ["commit", "--amend", "--no-edit", "-q"] : ["commit", "-q", "-m", `change ${index}`], { + env: { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + }); + } + return repo; +} + +test("capped history windows cannot claim ready coverage (AC-1)", async () => { + const repo = await makeDatedHistory([150, 75, 10, 5, 1]); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + const pack = await buildEvidencePack({ cwd: repo, maxCommits: 3, baseRef: "HEAD" }); + assert.deepEqual(pack.historyProfile.historyWindows.map((item) => item.analyzedCommits), [3, 3, 3]); + assert.equal(pack.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "review"); + assert.equal(pack.historyProfile.range.hasMoreCommits, true); + assert.equal(pack.historyProfile.range.shallow, false); + assert.equal(pack.historyProfile.range.oldestTimestamp, "2026-10-05T12:00:00.000Z"); + assert.equal(pack.historyProfile.range.newestTimestamp, "2026-10-14T12:00:00.000Z"); + assert.deepEqual(pack.historyProfile.historyWindows.map((item) => item.coverage.status), ["unknown", "capped", "capped"]); + assert.equal(pack.historyProfile.hotFiles[0].commits, 3); + + const full = await buildEvidencePack({ cwd: repo, maxCommits: 10, baseRef: "HEAD" }); + assert.deepEqual(full.historyProfile.historyWindows.map((item) => item.analyzedCommits), [3, 4, 5]); + assert.deepEqual(full.historyProfile.historyWindows.map((item) => item.coverage.status), ["complete", "complete", "complete"]); + assert.equal(full.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "ready"); + } finally { + now.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } +}); + +test("identical windows at exactly the commit cap remain complete (AC-2)", async () => { + const repo = await makeDatedHistory([10, 5, 1]); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + const pack = await buildEvidencePack({ cwd: repo, maxCommits: 3, baseRef: "HEAD" }); + const history = pack.historyProfile; + assert.equal(history.range.analyzedCommits, 3); + assert.equal(history.range.hasMoreCommits, false); + assert.deepEqual(history.historyWindows.map((item) => item.coverage.status), ["complete", "complete", "complete"]); + assert.deepEqual(history.historyWindows[0].hotFiles, history.historyWindows[2].hotFiles); + assert.equal(pack.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "ready"); + } finally { + now.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } +}); + +test("non-monotonic dates cannot prove unscanned windows complete (AC-3)", async () => { + const repo = await makeDatedHistory([1, 200, 5, 2]); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + const unknown = await analyzeGitHistoryProfile({ cwd: repo, maxCommits: 2 }); + assert.equal(unknown.range.hasMoreCommits, true); + assert.deepEqual(unknown.historyWindows.map((item) => item.coverage.status), ["unknown", "unknown", "unknown"]); + const capped = await analyzeGitHistoryProfile({ cwd: repo, maxCommits: 3 }); + assert.deepEqual(capped.historyWindows.map((item) => item.coverage.status), ["capped", "capped", "capped"]); + assert.equal(capped.range.oldestTimestamp, "2026-03-29T12:00:00.000Z"); + assert.equal(capped.range.newestTimestamp, "2026-10-13T12:00:00.000Z"); + assert.equal(capped.range.analyzedCommits, 3); + } finally { + now.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } +}); + +test("empty history window interpretations describe the analyzed sample (AC-5)", async () => { + const repo = await makeDatedHistory([1, 200, 100]); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + for (const [maxCommits, status] of [[1, "unknown"], [2, "capped"]]) { + const history = await analyzeGitHistoryProfile({ cwd: repo, maxCommits, historyWindows: "30" }); + const window = history.historyWindows[0]; + assert.equal(window.analyzedCommits, 0); + assert.equal(window.coverage.status, status); + assert.equal(window.interpretation, + "recent window for spotting recent hotspots from bounded git history." + + " No commits were observed in the analyzed sample for this window." + + ` Coverage is ${status}; this horizon does not establish hotspot persistence.`); + } + const full = await analyzeGitHistoryProfile({ cwd: repo, maxCommits: 10, historyWindows: "30" }); + assert.equal(full.historyWindows[0].analyzedCommits, 1); + assert.equal(full.historyWindows[0].coverage.status, "complete"); + } finally { + now.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } +}); + +test("shallow Git history leaves window completeness unknown (AC-3)", async () => { + const repo = await makeDatedHistory([150, 75, 10, 5, 1]); + const cloneRoot = await mkdtemp(path.join(os.tmpdir(), "better-harness-shallow-")); + const clone = path.join(cloneRoot, "repo"); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + git(repo, ["clone", "-q", "--no-local", "--depth=2", repo, clone]); + const pack = await buildEvidencePack({ cwd: clone, maxCommits: 10, baseRef: "HEAD" }); + assert.equal(pack.historyProfile.range.shallow, true); + assert.equal(pack.historyProfile.range.hasMoreCommits, false); + assert.equal(pack.historyProfile.range.analyzedCommits, 2); + assert.deepEqual(pack.historyProfile.historyWindows.map((item) => item.coverage.status), ["unknown", "unknown", "unknown"]); + assert.equal(pack.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "review"); + } finally { + now.mockRestore(); + await rm(cloneRoot, { recursive: true, force: true }); + await rm(repo, { recursive: true, force: true }); + } +}); + +test("empty, skipped, and custom history windows report their evidence boundary (AC-4)", async () => { + const repo = await makeDatedHistory([150, 75, 10, 5, 1]); + const unborn = await mkdtemp(path.join(os.tmpdir(), "better-harness-unborn-")); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + git(unborn, ["init", "-q"]); + const history = await analyzeGitHistoryProfile({ cwd: unborn }); + assert.equal(history.range.analyzedCommits, 0); + assert.equal(history.range.hasMoreCommits, false); + assert.equal(history.range.oldestTimestamp, null); + assert.equal(history.range.newestTimestamp, null); + assert.deepEqual(history.historyWindows.map((item) => item.coverage.status), ["complete", "complete", "complete"]); + await mkdir(path.join(repo, "unused")); + const empty = await buildEvidencePack({ cwd: repo, packageRelPath: "unused", baseRef: "HEAD" }); + assert.equal(empty.historyProfile.range.analyzedCommits, 0); + assert.equal(empty.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "review"); + + const skipped = await buildEvidencePack({ cwd: repo, noHistory: true, historyWindows: "7,120" }); + assert.deepEqual(skipped.historyProfile.historyWindows.map((item) => item.days), [7, 120]); + assert.deepEqual(skipped.historyProfile.historyWindows.map((item) => item.coverage.status), ["skipped", "skipped"]); + assert.equal(skipped.historyProfile.range.hasMoreCommits, null); + assert.equal(skipped.historyProfile.range.shallow, null); + assert.equal(skipped.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "review"); + + const custom = await buildEvidencePack({ cwd: repo, maxCommits: 10, historyWindows: "7", baseRef: "HEAD" }); + assert.deepEqual(custom.historyProfile.historyWindows.map((item) => item.analyzedCommits), [2]); + assert.equal(custom.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "ready"); + const capped = await analyzeGitHistoryProfile({ cwd: repo, maxCommits: 3, historyWindows: "7,120" }); + assert.deepEqual(capped.historyWindows.map((item) => item.coverage.status), ["unknown", "capped"]); + } finally { + now.mockRestore(); + await rm(unborn, { recursive: true, force: true }); + await rm(repo, { recursive: true, force: true }); + } +}); + +test("history coverage respects Git scope before file and language filters (AC-5)", async () => { + const repo = await makeDatedHistory([150, 75, 10, 5, 1]); + const now = vi.spyOn(Date, "now").mockReturnValue(HISTORY_NOW); + try { + const ignored = await analyzeGitHistoryProfile({ cwd: repo, maxCommits: 3, ignore: "src/**", languages: "python" }); + assert.equal(ignored.range.hasMoreCommits, true); + assert.deepEqual(ignored.hotFiles, []); + assert.deepEqual(ignored.historyWindows.map((item) => item.coverage.status), ["unknown", "capped", "capped"]); + + await writeFixtureFile(repo, "packages/widget/src/app.js", "export const widget = true;\n"); + git(repo, ["add", "."]); + git(repo, ["commit", "-q", "-m", "add scoped package"]); + const scoped = await buildEvidencePack({ cwd: repo, packageRelPath: "packages/widget", maxCommits: 1, baseRef: "HEAD" }); + assert.equal(scoped.historyProfile.range.hasMoreCommits, false); + assert.equal(scoped.historyProfile.range.analyzedCommits, 1); + assert.deepEqual(scoped.historyProfile.historyWindows.map((item) => item.coverage.status), ["complete", "complete", "complete"]); + assert.equal(scoped.reviewMatrix.find((item) => item.id === "history-window-coverage").status, "ready"); + } finally { + now.mockRestore(); + await rm(repo, { recursive: true, force: true }); + } +}); + test("project profile identifies language, manifest, source root, and entry signals", async () => { const repo = await makeRepo({ "package.json": JSON.stringify({ scripts: { test: "node --test" }, dependencies: { express: "^5.0.0" } }),