From 85856035a8aec84a76d4eb877b60871c9810d48e Mon Sep 17 00:00:00 2001 From: Aryam Goyal Date: Sat, 8 Aug 2026 16:32:40 +0530 Subject: [PATCH] release: ship FixMap v0.8.8 --- .github/workflows/publish.yml | 25 +- CHANGELOG.md | 35 ++ README.md | 558 ++---------------- apps/web/app/_components/product-map.tsx | 13 +- apps/web/app/_components/site-header.tsx | 12 +- apps/web/app/_components/theme-toggle.tsx | 24 + apps/web/app/_lib/site-data.ts | 9 +- apps/web/app/changelog/page.tsx | 39 +- apps/web/app/demo.tsx | 24 +- apps/web/app/demo/page.tsx | 55 +- apps/web/app/docs/page.tsx | 12 +- apps/web/app/evidence/page.tsx | 15 +- apps/web/app/get-started/page.tsx | 14 +- apps/web/app/globals.css | 136 ++++- apps/web/app/layout.tsx | 12 +- apps/web/app/page.tsx | 2 +- apps/web/app/product/page.tsx | 8 +- apps/web/package.json | 2 +- benchmarks/adversarial/results.json | 3 + benchmarks/cases.json | 115 ++++ benchmarks/heldout/results.json | 6 +- docs/assets/fixmap-cli-demo.svg | 37 +- docs/releases/v0.8.8-issue-verification.md | 68 +++ .../reports/declines-fabricated-identifier.md | 2 +- examples/reports/declines-unmatched-terms.md | 2 +- examples/reports/declines-vague-task.md | 2 +- package-lock.json | 28 +- package.json | 9 +- packages/action/dist/index.mjs | 482 ++++++++++++--- packages/action/package.json | 4 +- packages/action/src/github.ts | 18 +- packages/action/src/issue-source.ts | 32 +- packages/action/src/runner.ts | 16 +- packages/action/test/github.test.ts | 29 + packages/action/test/issue-source.test.ts | 22 + packages/action/test/runner.test.ts | 24 + packages/cli/README.md | 2 +- packages/cli/package.json | 4 +- packages/cli/src/cli-runner.ts | 56 +- packages/cli/src/mcp.ts | 69 +-- packages/cli/src/repository-source.ts | 2 + packages/cli/test/cli-runner.test.ts | 60 +- packages/core/package.json | 2 +- packages/core/src/explain.ts | 9 + packages/core/src/grounding.ts | 9 +- packages/core/src/index.ts | 2 + packages/core/src/plan.ts | 5 +- packages/core/src/rank.ts | 5 +- packages/core/src/repo-scan.ts | 354 +++++++++-- packages/core/src/report.ts | 65 +- packages/core/src/signals.ts | 30 +- packages/core/src/types.ts | 16 +- packages/core/src/validate.ts | 69 +++ packages/core/src/verify.ts | 32 +- packages/core/test/explain.test.ts | 25 + packages/core/test/plan.test.ts | 16 + packages/core/test/rank.test.ts | 48 ++ packages/core/test/repo-scan.test.ts | 161 +++++ packages/core/test/report.test.ts | 25 + packages/core/test/signals.test.ts | 12 + packages/core/test/validate.test.ts | 36 ++ packages/core/test/verify.test.ts | 12 + scripts/evaluate.mjs | 77 ++- scripts/render-demo.mjs | 10 +- scripts/render-honest-examples.mjs | 4 +- server.json | 4 +- 66 files changed, 2195 insertions(+), 920 deletions(-) create mode 100644 apps/web/app/_components/theme-toggle.tsx create mode 100644 docs/releases/v0.8.8-issue-verification.md create mode 100644 packages/action/test/issue-source.test.ts create mode 100644 packages/core/src/validate.ts create mode 100644 packages/core/test/validate.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5df13e3..459f163 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -142,6 +142,18 @@ jobs: console.log(`Validated ${process.env.GITHUB_REF_NAME} at ${process.env.GITHUB_SHA}.`); NODE + RELEASE_NOTES="${RUNNER_TEMP}/fixmap-release-notes.md" + awk -v version="$VERSION" ' + index($0, "## " version " - ") == 1 { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' CHANGELOG.md > "$RELEASE_NOTES" + if [[ ! -s "$RELEASE_NOTES" ]]; then + echo "::error::CHANGELOG.md has no release notes for ${VERSION}." + exit 1 + fi + echo "Validated release notes for ${VERSION} before publication." + - name: Require an unpublished GitHub release env: GH_TOKEN: ${{ github.token }} @@ -348,19 +360,8 @@ jobs: shell: bash run: | set -euo pipefail - RELEASE_NOTES="${RUNNER_TEMP}/fixmap-release-notes.md" - awk -v version="$VERSION" ' - index($0, "## " version " - ") == 1 { capture = 1; next } - capture && /^## / { exit } - capture { print } - ' CHANGELOG.md > "$RELEASE_NOTES" - if [[ ! -s "$RELEASE_NOTES" ]]; then - echo "::error::CHANGELOG.md has no release notes for ${VERSION}." - exit 1 - fi - gh release create "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "FixMap ${GITHUB_REF_NAME}" \ - --notes-file "$RELEASE_NOTES" + --notes-file "${RUNNER_TEMP}/fixmap-release-notes.md" diff --git a/CHANGELOG.md b/CHANGELOG.md index f002d21..3e303e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ Accuracy figures inside a released entry are the numbers measured **at that rele left as written. The current numbers live on the [evidence page](https://usefixmap.vercel.app/evidence), which is generated from the recorded results rather than transcribed by hand. +## 0.8.8 - 2026-08-08 + +### Added + +- Exact git-state repository scan caching now accelerates repeated Plan, Explain, Compare, Verify, MCP, and Action runs. Non-git and untracked-file states stay uncached, `cache-hit` makes reuse visible, and `--no-cache` forces a fresh scan (#495). +- JSON plans now carry `reportVersion: 1` with a documented additive-compatibility policy, and every report consumer shares the same structural validator (#468, #488). +- The website now follows the system colour scheme, includes a persistent light/dark toggle and accessible focus colour, and gives every route its own canonical and Open Graph metadata (#485-#487, #491-#492). + +### Fixed + +- Scanner diagnostics now distinguish oversized, non-text, and unreadable source; identify skipped submodules and resolved empty diffs; preserve paths in Markdown; use consistent decimal kB units; handle odd UTF-16BE manifests; and report friendly non-repository, unborn-repository, and missing-Git failures (#452-#453, #464, #469-#473, #481, #489-#490). +- Explain receives diff content in every interface, Verify rejects plans from a different repository and avoids duplicate generated-artifact findings, and generated counterparts rank below their maintained source and no longer become the next action (#451, #454, #465, #493). +- CLI validation now rejects plan-only `--report`, unresolved comparisons, empty evaluation inputs, and accidental leading-`@` file reads while preserving literal task text; the Action reserves its Markdown truncation fence, validates reports consistently, canonicalizes supported GitHub URL forms, bounds comment pagination, and separates explicit issue inputs from event-derived context (#455, #457-#460, #462-#463, #468, #482, #496). +- Checklist-only issues retain their task text, exclusions explain the matching files they removed, deployment ranking no longer treats bare HTTP status numbers as infrastructure terms, Go/Python/type-declaration tests are recognized, and new unplanned risk areas use an explicit warning severity without an unreachable informational branch (#456, #466-#467, #471, #476). + +### Website and documentation + +- The live four-stage walkthrough now writes and reuses one `plan.json`, quotes the complete task, and uses valid button-group semantics. Its agent transcript and the product illustration are generated from the real FixMap report engine rather than hand-written output (#463, #478-#480, #483, #492). +- The homepage and evidence page read the checked-in adversarial record, CI checks rendered assets for drift, the PowerShell first-run project contains rankable source, and the README is a focused 153-line entry point that routes detailed material to maintained docs (#475, #480, #484, #494). + +### Evidence + +- Added 23 self-evaluation cases covering the first half of the v0.8.8 issue set, and release CI rejects empty evaluation files before calculating rates (#461, #474). +- The release verification matrix for all 46 issues is published in `docs/releases/v0.8.8-issue-verification.md`. + +### Installation + +```bash +npm install --global @aryam/fixmap@0.8.8 +fixmap doctor +fixmap plan --issue https://github.com/chalk/chalk/issues/624 +``` + +The npm core and CLI packages, MCP Registry entry, GitHub tag/release, Action tag, README, changelog, recorded evidence, and production website must all resolve to 0.8.8 before the release is considered complete. + ## 0.8.7 - 2026-08-02 ### Fixed diff --git a/README.md b/README.md index 9d8b451..61ccff0 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # FixMap -### Know where to edit before the first edit. +Know where to edit before the first edit. -Paste a GitHub issue URL, describe a task, or point at a diff. FixMap returns ranked context files, test routes, risk notes, and explainable diagnostics—without an account, API key, or model call. +Paste a GitHub issue URL, describe a task, or point at a diff. FixMap returns ranked context files, reachable test commands, risk notes, and explicit diagnostics—without an account, API key, or model call. [![CI](https://github.com/aryamthecodebreaker/FixMap/actions/workflows/ci.yml/badge.svg)](https://github.com/aryamthecodebreaker/FixMap/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/%40aryam%2Ffixmap)](https://www.npmjs.com/package/@aryam/fixmap) @@ -12,302 +12,76 @@ Paste a GitHub issue URL, describe a task, or point at a diff. FixMap returns ra [![Marketplace](https://img.shields.io/badge/GitHub_Marketplace-FixMap-2ea44f?logo=github)](https://github.com/marketplace/actions/fixmap) [![MIT](https://img.shields.io/badge/license-MIT-74f0ba)](LICENSE) -[Install and start](#install-and-start) · [Watch the 24-second film](https://usefixmap.vercel.app/fixmap-launch.mp4) · [Install the Action](https://github.com/marketplace/actions/fixmap) · [Connect MCP](#mcp-server) · [Contribute](CONTRIBUTING.md) +[Website](https://usefixmap.vercel.app) · [Live demo](https://usefixmap.vercel.app/demo) · [Documentation](https://usefixmap.vercel.app/docs) · [Evidence](https://usefixmap.vercel.app/evidence) · [Changelog](CHANGELOG.md) -## Install and start +![A generated FixMap CLI report showing ranked context files, test routes, risks, analysis, and diagnostics](docs/assets/fixmap-cli-demo.svg) -Install FixMap once so the everyday command stays short and predictable: +## Install + +Requires Node.js 20.11 or newer. ```bash npm install --global @aryam/fixmap@latest fixmap plan --issue https://github.com/chalk/chalk/issues/624 ``` -FixMap fetches the public task, infers the repository, scans an isolated temporary checkout, and removes it when the report is complete. No clone, signup, configuration, or source upload is required. The CLI requires Node.js 20.11 or newer. The GitHub Action declares `using: node24`, which is the runtime GitHub supplies on its runners and places no requirement on your own Node version. - -### One-off trial - -This form fetches FixMap for one run and leaves no global installation: +For a one-off trial: ```bash npx -y @aryam/fixmap@latest plan --issue https://github.com/chalk/chalk/issues/624 ``` -If the current directory or one of its parents already contains FixMap, npm may deliberately choose that project-local binary. Check `--version`, or use the isolated-prefix procedure below when the exact package version matters. - -### Project installation - -Pin FixMap to one project when everyone working on that repository should get the same version: - -```bash -npm install --save-dev @aryam/fixmap -``` - -A project install is reached with `npx fixmap` inside the repository, or from an npm script. - -#### Safe PowerShell test project - -The directory must exist before `Set-Location` succeeds. This complete sequence creates a -scratch project first, stops on either directory error, installs FixMap in that project, -and proves the resolved version: - -```powershell -$fixmapTestPath = Join-Path $env:USERPROFILE "fixmaptesting" -New-Item -ItemType Directory -Path $fixmapTestPath -Force -ErrorAction Stop | Out-Null -Set-Location $fixmapTestPath -ErrorAction Stop -npm init -y -npm install --save-dev @aryam/fixmap -npx fixmap --version -npx fixmap plan --issue "password reset emails fail" -``` - -If `npm install` inside a *source checkout* of this repository fails with `ENOTEMPTY` on a phosphor-icons path, a previous install left a partial directory behind: delete `node_modules` and rerun. This affects contributors building from source on Windows, never anyone installing the published package. - -If `cd` or `Set-Location` fails, do not run the project-scoped `npm install` yet: PowerShell -stays in the previous directory, so npm will install there. Run `Get-Location`, create or -select the intended project directory, and then install. - -Run Doctor after installation to see the version and path that actually started: - -```bash -fixmap doctor -``` - -Doctor 0.8.4 and newer compares an exact npm-requested version when that newer Doctor is the process npm starts. An older project-local binary can win before newer Doctor code runs, so no new version can diagnose that decision from inside the old process. Treat the printed running version as authoritative, update or remove the stale installation, or test an exact version in an isolated prefix and invoke that prefix's shim directly. This PowerShell sequence cannot be redirected to an older package in the current directory or one of its parents: - -```powershell -$fixmapPrefix = Join-Path $env:TEMP "fixmap-cli-0.8.6" -npm install --global --prefix $fixmapPrefix @aryam/fixmap@0.8.7 -& "$fixmapPrefix\fixmap.cmd" --version -``` - -On macOS or Linux, use `fixmapPrefix="$(mktemp -d)"`, install with the same `--prefix`, and run `"$fixmapPrefix/bin/fixmap" --version`. - -| Command | Answers | -| --- | --- | -| [`fixmap plan`](#cli) | Which files, tests, and risks should I look at first? | -| [`fixmap plan --explain `](#ask-why) | Why is the file I expected *not* in that list? | -| [`fixmap plan --compare `](#measure-a-better-task) | Did refining the task move the real file up? | -| [`fixmap verify`](#verify-the-change-afterwards) | Did the change I made match the plan? | -| [`fixmap doctor`](#check-the-install) | Am I running the version I asked for? | -| [`fixmap mcp`](#mcp-server) | The same report, requested directly by an agent | - -The CLI points at the next useful command as you go, so `--explain` and `verify` surface when they apply rather than only living here. - - -![Animated FixMap terminal recording: one command produces ranked context files with confidence and reasons, a related test route, a high authentication risk note, and honest diagnostics.](docs/assets/fixmap-cli-demo.svg) - -## The problem FixMap solves - -Coding agents are fast after they find the right context. The expensive mistakes happen before the first edit: - -- opening a plausible file instead of the definition that owns the behavior -- missing the nearest test or workspace-specific test command -- treating an unresolved diff as “no changes” -- reviewing a change without an explicit map of affected code and risks - -FixMap adds a deterministic routing step before an agent starts searching. Its output is evidence, not a correctness claim: - -| Output | What it tells you | -| --- | --- | -| Ranked context files | Where to start, with confidence and inspectable reasons | -| Test routes | Which package command and related tests are likely to verify the change | -| Risk map | Which sensitive areas are touched and why | -| Diagnostics | Missing refs, scan limits, remote-fetch details, and other uncertainty | -| Markdown or JSON | A human handoff or machine-readable input for the next tool | - -## Use FixMap your way - -### CLI - -Analyze a task against any public GitHub repository: - -```bash -fixmap plan \ - --issue "support public GitHub issue URLs" \ - --repo https://github.com/aryamthecodebreaker/FixMap -``` - -Analyze private source or working-tree changes locally: +FixMap fetches a public task, infers its repository, scans a temporary isolated checkout, and removes it when the report is complete. Local repository analysis never uploads source. -```bash -fixmap plan --issue "password reset emails fail" -fixmap plan --diff main...HEAD -``` +## Everyday workflow -Write machine-readable output: +Save a plan before editing: ```bash -fixmap plan \ - --base main \ - --head HEAD \ - --format json \ - --output fixmap-report.json +fixmap plan --issue "password reset emails fail" --format json --output plan.json ``` -Remote repository mode is issue-only, and deliberately so: the checkout is a single-commit shallow clone of the default branch, which has no history for a diff range to resolve against and no working tree to compare. Passing `--diff`, `--base`/`--head` or `--working-tree` with a GitHub URL fails immediately and says this, rather than cloning first and then reporting an unresolvable ref. - -That clone is also the expensive part of a remote run — minutes on a large monorepo, for a ranking that is lexical. If you already have the repository on disk, pass `--repo .` with the issue URL and FixMap will rank against your checkout instead of fetching its own: +Ask why an expected path is missing: ```bash -fixmap plan --issue https://github.com/owner/repository/issues/123 --repo . +fixmap plan --issue "password reset emails fail" --explain src/auth/token.ts ``` -Set `FIXMAP_PROGRESS=1` when you want clone/scan progress; `true`, `yes`, and `on` work too, and `0`/`false`/`no`/`off` silence it even in a terminal, where it is otherwise on by default. Progress is intentionally written to stderr so JSON/stdout remains pipe-safe; in PowerShell, merge it for display with `2>&1` or suppress it with `2>$null` if your host records native stderr as an error stream. The same applies to the next-step hints printed after a successful plan — they are stderr, not failure. `FIXMAP_VERBOSE_USAGE=1` restores the full usage block after every argument error. - -### Which URLs and paths are accepted - -`--issue` takes a public GitHub issue or pull request URL. A `?query`, a `#fragment`, and a `www.` or `api.` host are normalized away, so a URL copied from a browser or an API client works as-is. Other hosts, embedded credentials, and explicit ports are rejected, as are compare, tree, discussion, and file URLs — they carry no task text to rank. - -`--repo` takes a local path, a `file://` URL, `https://github.com/owner/repository`, or a `git@github.com:owner/repository.git` SSH form, which is rewritten to the public HTTPS URL. FixMap only reads public repositories over HTTPS; the SSH form is accepted because it identifies the same repository, not because credentials are used. - -Every relative path — `--repo`, `--issue-file`, `--issue @file`, `--output` — resolves against the **current working directory**, never against each other. `--issue-file ../task.md --repo ./checkout` reads the task from the parent of your shell's directory and scans `checkout` inside it. - -For long or private task text, avoid shell command-length limits by reading UTF-8 text from a file or stdin: +Refine the task and compare the ranking: ```bash -fixmap plan --issue-file task.md -Get-Content task.md -Raw | fixmap plan --issue - +fixmap plan --issue "sendMail throws during password reset" --compare plan.json ``` -`--issue @task.md` is also accepted as a file shorthand. Repeated `--issue` flags are rejected instead of silently discarding the earlier task. - -### Ask why - -Every report explains the files it chose. `--explain` answers the harder question — why a file you expected is missing: +Verify the completed diff against the saved plan: ```bash -fixmap plan --issue "password reset emails fail" \ - --explain src/billing/invoice.ts -``` - -Explain accepts repository-relative paths, normalized `.`/`..` segments, or an absolute path inside the selected repository. It resolves the scanned casing on case-insensitive Windows checkouts and clearly rejects paths outside the repository. Git-tracked symlink paths are followed when the operating system permits them; a disabled or dangling Windows symlink is reported as not scanned. - -```text -# Why src/billing/invoice.ts - -Scored 2, below the lowest reported score of 24. Name a symbol, error string, -or path from this file in the task to raise it. -``` - -It distinguishes the cases that actually differ: the file was ranked, it scored below the cutoff, it was deliberately excluded (a test, a lockfile, generated output whose source was ranked instead), or the scan never saw it. When a scan hits its file limit, it says so rather than implying the path does not exist. Add `--format json` for the machine-readable form. - -### Measure a better task - -The habit worth having is: plan, add the identifier the task was missing, re-plan, and check whether the real file rose. `--compare` prints that instead of leaving you to diff two JSON files by eye: - -```bash -fixmap plan --issue "ranking confidence" \ - --format json --output before.json - -fixmap plan \ - --issue "confidenceForEntry gives every top-8 file high confidence" \ - --compare before.json -``` - -```text -2 entered, 2 left, 6 moved. The leading file changed from -scripts/evaluate-adversarial.mjs to packages/core/src/rank.ts. - -Task grounding changed from **descriptive** to **anchored**. - -## Moved -- `packages/core/src/rank.ts` rose from rank 2 to 1, score 9 to 42, confidence medium to high -``` - -That is FixMap's own feedback loop, measured in one command: naming a symbol moved the fix site from second place to first and turned a descriptive task into an anchored one. - -### Keep the noise out - -Demo pages, marketing copy, and documentation often contain every symptom word a product documents, so they compete with the implementation. FixMap's built-in penalties cover conventions like `examples/`; a repository's own layout it cannot know: - -```bash -fixmap plan --issue "password reset emails fail" \ - --exclude apps/web --exclude 'docs/**' --limit 3 -``` - -Patterns can also live in a `.fixmapignore` file at the repository root, one per line. FixMap supports the documented subset `*`, `**`, `?`, root-leading `/`, directory-trailing `/`, `#` comments, and ordered `!` negation; bracket characters are literals, not character classes, and FixMap does not claim every gitignore extension. Patterns use `/` as the separator on every platform, and a Windows-style `src\app` is normalized to `src/app` so a path pasted from Explorer or PowerShell still matches — which also means `\` does not escape anything. File and CLI patterns combine and are deduplicated. Omitting MCP `exclude` means “use `.fixmapignore` only”; sending patterns adds to that file. `--explain` reports an excluded file as excluded, naming the effective pattern, rather than claiming it scored too low. - -`--limit` caps how many context files come back. The useful signal is usually the top one to three; the rest burns agent context and invites drive-by edits. - -To map what you are editing right now, without crafting a git spec: - -```bash -fixmap plan --working-tree --issue "reset flow" -``` - -That means staged and unstaged tracked changes against `HEAD`. Untracked files stay out of the **change set** unless you add `--include-untracked`, so agent metadata and scratch files are not reported as edits. - -They are still ranking candidates. The repository scan reads `git ls-files --others --exclude-standard`, so a new file you just wrote can appear in the context list without appearing in `changedFiles` — which is what you want, since a file an agent created moments ago is usually the most relevant thing in the repository. `--include-untracked` governs which files count as *changed*, not which files can be ranked. - -### Verify the change afterwards - -`plan` answers where to start. `verify` answers whether the change that followed matches the plan — by comparing the saved report against a real git diff: - -```bash -fixmap plan --issue "password reset emails fail" \ - --format json --output fixmap-report.json - -# ...make the change... - -fixmap verify --report fixmap-report.json --diff main...HEAD -``` - -```text -FixMap verified 3 changed files against the plan and raised 1 error and 2 warnings. - -- **error** A file was edited in a generated or retired location. A build regenerates - these, so the change will be lost. Edit the source they are produced from. - - `dist/auth/reset-password.js` -- **warning** One file changed that the plan did not rank. Either the task grew beyond - the original description, or the ranking missed them — worth checking which. - - `src/billing/charge.ts` -- **warning** Code changed but no test did. The plan routed this test as most related. - - `test/reset-password.test.ts` -``` - -It checks five things: edits in generated or retired locations, files the change needed that the plan never ranked, an untouched leading file, source moving with no test moving, and risk areas the plan never flagged. Nothing is executed — both inputs are things you already have. - -Only a discarded, untracked generated edit exits non-zero. A committed generated release artifact is a warning: confirm its maintained source changed and it was rebuilt. Everything else is advisory because a plan can be wrong and a change can still be right. - -### Check the install - -Doctor reports the version and path that actually started, plus conflicts the running process can observe: - -```bash -fixmap doctor -``` - -```text -# FixMap Doctor - -- ok Running version: 0.8.6 -- PROBLEM Global install: 0.3.1 (this process is 0.8.6) - A globally installed fixmap shadows the version npx was asked for. Run - `npm uninstall -g @aryam/fixmap` or update the global installation. For a - clean pinned run, use the isolated-prefix command above. -- ok Node version: 24.13.0 +fixmap verify --report plan.json --diff main...HEAD ``` -It exits non-zero when the running Doctor finds a shadow, so a CI step fails rather than reading on. +Use `--working-tree` for staged and unstaged tracked edits, `--include-untracked` when new files should count as changes, `--exclude` or `.fixmapignore` to focus the map, and `--no-cache` to force a fresh scan. Run `fixmap --help` for the complete command reference. -Doctor compares the running package, the first `fixmap` shim on `PATH`, npm's global package, and an exact version requested through npm exec. The exact-request check is available when Doctor 0.8.4 or newer is the process npm starts. If an older project-local binary wins first, that old code cannot contain the newer detector; its printed running version is the evidence. Doctor also cannot infer a version intended in an unrelated shell command or inspect every historical npm-exec cache entry. When reproducibility matters, use the isolated-prefix command above and invoke its shim directly. +## What the report contains -### MCP server +- Ranked context files with scores, confidence, and evidence. +- Test routes that correspond to commands the repository actually declares. +- Six bounded risk areas: authentication, billing, automation, data, public API, and dependencies. +- Diagnostics for uncertainty, unread content, scan boundaries, excluded matches, and unresolved diffs. +- A grounded next action that avoids generated counterparts when maintained source exists. -FixMap exposes five stdio tools: `fixmap_plan` builds the starting map, `fixmap_explain` answers why a file is missing, `fixmap_compare` measures whether better task context improved the plan, `fixmap_verify` compares that plan with the later diff, and `fixmap_doctor` diagnoses install shadows. +FixMap is deterministic. It narrows investigation; it does not prove that a ranking or change is correct. -`fixmap_plan` takes `limit` to cap how many context files come back, which matters when the useful signal is the top one to three and the rest is context budget. `fixmap_verify` and `fixmap_compare` both accept a report either inline or as a path to a saved JSON file, so a large plan need not be re-embedded in the tool call. `fixmap_explain` takes the same scan options as `fixmap_plan` — `diff`, `base`/`head`, `workingTree`, `includeUntracked` — so an explanation can be asked against exactly the plan that was just run. `format` is case-insensitive on every tool. `fixmap_doctor` sets `isError` when the install is unhealthy, so a client branching only on that flag still sees a shadowed install. +## MCP server -Claude Code: +Expose Plan, Explain, Compare, Verify, and Doctor over local stdio: ```bash -claude mcp add fixmap -- fixmap mcp +fixmap mcp ``` -Cursor, Windsurf, or another MCP client: +Example client configuration: ```json { @@ -320,39 +94,13 @@ Cursor, Windsurf, or another MCP client: } ``` -The official MCP Registry identifier is `io.github.aryamthecodebreaker/fixmap`. MCP exposes `fixmap_plan`, `fixmap_explain`, `fixmap_compare`, `fixmap_verify`, and `fixmap_doctor`, including working-tree and limit controls. Analysis runs locally over stdio; FixMap does not send repository source to a hosted model or service. - -#### Tell the agent how much to trust it - -The `fixmap_plan` tool description carries this guidance, so most clients pick it up automatically. Add it to your agent's system prompt when you want it enforced: - -```text -Treat FixMap's output as a starting map, not proof that the task is valid. - -1. Check the analysis block first. If it reports unresolved or unverified - identifiers, vague task grounding, an incomplete scan, or a clustered - ranking, do not assume the top-ranked file is correct. -2. Verify that identifiers, error strings, commands, and paths named in the - task were actually found in the repository. -3. When no strong anchor resolves, search more broadly or ask for - clarification before editing. -4. Prefer changed files, exact definitions, imports, and tests over generic - keyword matches. -5. Never edit a file only because it ranked highly. Confirm the code there - relates to the requested behavior. -``` - -This matters because the ranking is a lead, not a conclusion: across the frozen suites, a top result labeled *high confidence* is the correct fixing file 9 times out of 15 — [measured below](#does-the-confidence-label-mean-anything), not asserted. - -### GitHub Action +See the [MCP setup guide](https://usefixmap.vercel.app/get-started#mcp) for client-specific instructions. -Install [FixMap from GitHub Marketplace](https://github.com/marketplace/actions/fixmap), or add the versioned Action directly: +## GitHub Action ```yaml name: FixMap - -on: - pull_request: +on: pull_request permissions: contents: read @@ -366,254 +114,40 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - id: fixmap - uses: aryamthecodebreaker/FixMap@v0.8.7 + - uses: aryamthecodebreaker/FixMap@v0.8.8 with: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -The Action upserts the newest matching marked pull-request comment, writes the complete report to the step summary, and exposes `report`, `context-count`, and `test-route-count` outputs. Plan and verify accept `working-tree`/`include-untracked`; plan also accepts `limit` and comma- or newline-separated `exclude`. Explain and compare remain CLI/MCP-only because Action comments operate on complete reports. Pin a [release tag](https://github.com/aryamthecodebreaker/FixMap/releases); a floating `v1` tag will follow wider acceptance testing. - -To close the plan→edit→verify loop without leaving GitHub, save the plan as an artifact and check later pushes against it with `mode: verify`: - -```yaml - - id: plan - uses: aryamthecodebreaker/FixMap@v0.8.7 - with: - format: json - - run: echo '${{ steps.plan.outputs.report }}' > fixmap-plan.json - - uses: actions/upload-artifact@v4 - with: - name: fixmap-plan - path: fixmap-plan.json - - # In a later run, after the fix is pushed: - - uses: aryamthecodebreaker/FixMap@v0.8.7 - with: - mode: verify - report-path: fixmap-plan.json -``` - -Verify mode exposes `finding-count` and `changed-file-count`, and fails the step only for an edit in a generated or retired location — the one finding that is wrong regardless of the task. Everything else is advisory, because a plan can be wrong and a change can still be right. - -On forked pull requests, GitHub supplies a read-only token. FixMap warns instead of failing and keeps the full report in the step summary and outputs. Do not switch to `pull_request_target` while checking out untrusted fork code just to restore comments. - -## Why trust the output? - -FixMap is deliberately inspectable: - -- **Deterministic:** the same task, repository, and diff produce the same ranking—there is no hidden model call. -- **Explainable:** every ranked file includes reasons such as path matches, content matches, exact definitions, changed-file evidence, or import proximity. -- **Local-first:** local repositories stay local; public URLs use an anonymous temporary checkout. -- **Non-executing:** FixMap never installs dependencies or runs repository build, test, hook, or package scripts. -- **Git-aware:** scans respect `.gitignore`; working-tree mode reports staged and unstaged tracked files as changed, adding untracked ones only when `--include-untracked` is explicit, though untracked source is always a ranking candidate; unresolved refs surface as errors and exit non-zero. -- **Monorepo-aware:** test routing understands npm, pnpm, Yarn, Bun, and workspace package boundaries. -- **Bounded:** file counts, text samples, issue bodies, network responses, and remote-fetch time are capped with explicit diagnostics. - -Public repository inputs accept only canonical credential-free `https://github.com/owner/repository` URLs. FixMap disables credential helpers, inherited Git configuration, hooks, submodules, symlinks, and LFS smudging, then removes the checkout on success or failure. Public issue fetching uses GitHub’s fixed API host without credentials or redirects. - -## Evidence, not hype - -Most tools show you the benchmark they tuned on. Here is both. - -![FixMap evidence audit: on nine held-out tasks that did not name the fixing file, FixMap and BM25 both ranked it in the top three for five cases, while BM25 led six to nine at Top-5.](docs/assets/fixmap-benchmark.svg) - -FixMap is measured against real issues that were later fixed by a merged pull request. Each case pins the commit *before* the fix, feeds FixMap the issue text a maintainer actually wrote, and checks whether the file that fix changed appears in the ranking. Cases are chosen mechanically, and every input and output is checked in. - -| | Held-out — 12 repos, **never tuned against** | Regression — 16 repos, guided development | -| --- | ---: | ---: | -| Fixing file ranked Top-1 | 7 / 12 — 58%
95% CI 32–81% | 11 / 16 — 69%
95% CI 44–86% | -| Fixing file ranked Top-3 | 8 / 12 — 67%
95% CI 39–86% | 16 / 16 — 100%
95% CI 81–100% | -| Wrong file ranked first while the right one was available | 2 / 12 — 17% | 5 / 16 — 31% | - -#### Some of those tasks already contained their answer - -Three of the twelve held-out tasks name a fixing file in the task text itself. Mongoose's says `Location: lib/document.js:2339`; svelte's and yargs' link a GitHub permalink straight to the file and line range. A ranker that reads explicit file mentions — which FixMap has — answers those by reading the task, not by searching the repository. Pooling them into one rate lets three cases carry the headline. - -Split by whether the task named the file, the held-out suite reads: - -| Held-out cohort | Cases | Top-1 | Top-3 | Top-5 | -| --- | ---: | ---: | ---: | ---: | -| Task **did not** name the file — *plan around this one* | 9 | **44%**
95% CI 19–73% | **56%**
95% CI 27–81% | 67% | -| Task named the file | 3 | 100% | 100% | 100% | -| Pooled (what we published before) | 12 | 58% | 67% | 75% | - -The same split on the regression suite barely moves it (69% → 69% Top-1), and its three named cases are 2 / 3 rather than 3 / 3 — so being named does not guarantee a hit, and with three cases per cohort the *size* of this effect is not established. What is established is structural: a generalization headline should not be computed over tasks that contain their own answer. The cohort is now derived at evaluation time from the same task text the ranker reads, so it cannot drift. - -**Plan around the held-out, unmentioned cohort.** The regression suite is where the ranking heuristics were developed — a case missed, the ranker changed — so its 100% describes fit, not accuracy on your repository. - -**And read the intervals, not the percentages.** At nine cases one result flipping moves Top-3 by eleven points. The honest statement is "roughly half, with a wide interval", not a precise success probability. Anyone quoting these figures to two significant figures, including us, is overstating them. - -#### Is this better than just searching the repository? - -The fair question about a ranked file list is whether it beats what an agent already gets for free. The same suites are scored against naive retrieval on **the same scanned corpus** — one repository scan per case, shared by every arm. - -Candidate policy turned out to matter more than the ranking function. FixMap does not rank the raw scan: it gates on `isSource && !isTest` and then deprioritises documentation for an implementation task. A baseline pointed at every scanned file therefore returns `README.md` and `CONTRIBUTING.md` first and loses to the wrong thing. So each baseline is run under three candidate policies and **compared at its strongest**. - -Held-out, tasks that did not name the file (9 cases), each baseline at its best policy: - -| Arm | Top-1 | Top-3 | Top-5 | -| --- | ---: | ---: | ---: | -| Path extraction — read paths out of the task | 0% | 0% | 0% | -| Literal keyword search, code files only | 22% | 44% | 67% | -| **BM25 retrieval, code files only** | **44%** | **56%** | **100%** | -| FixMap | 44% | 56% | 67% | - -**On repositories FixMap was never tuned against, BM25 over code files matches it at Top-1 and Top-3 and beats it at Top-5.** Paired McNemar exact tests put Top-1 and Top-3 at p = 1.0 — dead ties, two disagreements each way. At Top-5 the baseline wins 3 cases FixMap misses and FixMap wins none: BM25 has the fixing file in its top five for **9 of 9** cases, FixMap for 6 of 9. - -On the regression suite FixMap does lead — 69% vs 39% Top-1, 100% vs 62% Top-3 — but that is the suite whose cases shaped the ranker, and even there the lead is not significant against this baseline (p = 0.125 Top-1, p = 0.0625 Top-3). - -We are publishing this because it is what the measurement says. The honest reading is that FixMap's current advantage over plain BM25-over-code is **unproven on unseen repositories**, and that its Top-5 recall is behind. Closing that gap is the next piece of work, not a marketing line. - -[Read the benchmark self-audit.](docs/releases/2026-08-04-benchmark-self-audit.md) - -Path extraction scoring 0% on this cohort and 100% on the named one is the check that the cohort split measures what it claims. - -Reproduce it with `node scripts/evaluate-baseline.mjs --suite heldout`; every arm, policy, and ranking is recorded in [`benchmarks/heldout/baseline-results.json`](benchmarks/heldout/baseline-results.json). - -One thing the point estimates hide: held-out Top-1 stays close to Top-3 in both cohorts — **when FixMap finds the file at all, it usually ranks it first**, which is what matters to an agent that opens one file. The tuned suite's 100% Top-3 still conceals that in 31% of those cases something wrong ranks above the answer, so an agent following it opens the wrong file first. - -The three held-out misses are published with their real rankings in [`benchmarks/heldout/`](benchmarks/heldout), not removed or explained away. +The Action writes the complete report to the job summary and maintains one pull-request comment. Its checked-in bundle and metadata are release-gated. -Held-out repositories: mongoose, immer, jest, knex, mocha, React Hook Form, socket.io, svelte, vite, vue, winston, yargs. Regression repositories: Express, Axios, debug, ky, Zod, Pino, Fastify, Chalk, Vitest, ESLint, Webpack, Undici, Redux Toolkit, Prettier, Hono, and got. +## JSON compatibility -Median scan and rank across the pinned repositories is **1.75 s**, measured over three warm runs each. +New plans include `"reportVersion": 1`. Within a report version, fields may be added, but existing fields are not removed or retyped; consumers should ignore unknown fields. Breaking output changes require a new report version. Compare and Verify continue to accept legacy plans without a marker and reject unsupported marker values. -### Does the confidence label mean anything? +## Evidence -A confidence label is only useful if it predicts something. Across all 28 cases in both suites, when the top-ranked file is labeled: +The [evidence page](https://usefixmap.vercel.app/evidence) is generated from the checked-in held-out, regression, baseline, performance, and adversarial records. It publishes misses and confidence intervals alongside hits. CI rejects empty evaluation files, stale rendered artifacts, adversarial regressions, Action bundle drift, and benchmark drift. -| Top result labeled | Correct fixing file | | | -| --- | ---: | ---: | --- | -| high | 7 / 13 | **54%** | 95% CI 29–77% | -| medium | 9 / 11 | 82% | 95% CI 52–95% | -| low | 2 / 4 | 50% | 95% CI 15–85% | +## Safety boundary -The bands are not monotonic in this 28-case sample, so the label is a heuristic rather than a calibrated probability. **High confidence still means “check this lead first,” not certainty.** The intervals overlap heavily at these sample sizes, and the raw counts are published so the limitation is visible. +FixMap reads and ranks. It does not install dependencies, run repository scripts, execute tests, invoke git hooks, upload local source, or call a hosted model. Remote clones disable credential helpers, inherited git configuration, hooks, submodules, symlinks, and LFS smudging. -Since v0.8.0 the label is also **scarce**. It used to come from an absolute score threshold, which on a real Zod task labeled all eight results high while the leader was nineteen points ahead of the runner-up — telling an agent the eighth guess was as safe to edit as the first. High is now reserved for a file that leads, ties the lead within two points, or carries definition-site evidence of its own; and a leader that merely out-talks a definition site below it is capped at medium. v0.8.1 also stops documentation code fences from claiming definition evidence and stops a non-leading explicit path from becoming high merely because it was named. The table above is regenerated from the current 16-case regression and 12-case held-out suites. - -### Does it stay quiet when it should? - -Ranking the right file matters less than not inventing one. An [adversarial suite](benchmarks/adversarial) runs fabricated identifiers, real identifiers from the wrong repository, vague requests, absent features, and runtime-only symptoms against real pinned repositories, and asserts FixMap does not overclaim: - -| Result | Value | -| --- | ---: | -| Adversarial cases | 8 | -| **False-confidence rate** | **0.0** | - -Fabricated identifiers produce a diagnostic naming them and a low-confidence report rather than a persuasive wrong answer. - -**What is not claimed:** there is no tokens-saved or minutes-saved figure here. Establishing one honestly needs a controlled experiment running the same tasks with and without FixMap, which has not been done. Byte-based context-size proxies are recorded in [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) and labeled as estimates, not savings. - -Read the full [benchmark methodology and scanner measurements](docs/BENCHMARKS.md), or reproduce either suite yourself: - -```bash -npm run evaluate:heldout -``` - -## What changed in v0.8.6 - -v0.8.6 closes the final three ranking reports. Stylesheets are deprioritized when symptom words compete with implementation in a non-UI task, while genuine CSS/layout tasks remain unpenalized. An explicitly named generated artifact remains visible when a task is truly about it, but if maintained source exists its confidence is capped at medium and the reason names that source relationship. Ordering and all recorded evaluation rates remain unchanged (#347, #362, #371). - -## What changed in v0.8.5 - -v0.8.5 makes the supported installation path unmistakable. Install once with `npm install --global @aryam/fixmap@latest`, then use the short `fixmap plan ...` command everywhere, including MCP setup. The one-off npx form remains documented, but now states that npm may prefer an existing project-local binary. Doctor's output and limitations are described precisely: the running version is authoritative, and an older binary that wins before new code starts cannot contain a newer detector (#437). - -## What changed in v0.8.4 - -v0.8.4 added an exact-request mismatch detector to Doctor and replaced the earlier npm exec recommendation with an isolated-prefix/direct-shim procedure. A post-release child-project test then established the detector's unavoidable boundary: it works when Doctor 0.8.4 or newer starts, but an older project-local binary can win before newer code runs. The printed running version remains authoritative (#437). - -## What changed in v0.8.3 - -v0.8.3 corrects MCP comparison validation after an independent audit reproduced #398 against the published v0.8.2 package. `fixmap_compare` now rejects a truncated `{ "contextFiles": [] }` object, validates optional rank, score, and confidence fields when present, and still accepts complete reports that legitimately found zero context files. No ranking behavior or evaluation result changed. - -## What changed in v0.8.2 - -v0.8.2 closes an audit sweep filed against v0.8.1. Windows path handling works throughout — exclusions, symlinks, and manifests saved with a byte order mark. `.vue`, `.svelte`, `.java`, `.php`, `.rb`, `.cs`, `.mts` and `.cts` rank, having previously been scanned but never treated as source. The URLs people actually paste are accepted, and an unresolvable `--diff` now exits non-zero instead of reporting success on a plan that was never diff-aware. New diagnostics name what the report used to leave silent, most importantly a file whose contents were never read but which still ranked on its path. Hit rates are unchanged; confidence is more conservative and better calibrated. Two proposed ranking changes were measured, found not to help, and rejected — the numbers are in the CHANGELOG. - -Installation is now a release gate rather than a documentation promise. The publish workflow verifies npm `latest`, canonical package homepages, the CLI's exact core dependency, a clean global install with a real plan, the MCP Registry version, and the source commit before it creates the GitHub release. The website includes the complete install paths, all five MCP tools, and a realistic Plan → edit carefully → Verify agent conversation. - -[See the 107-issue verification ledger](docs/releases/v0.8.1-issue-verification.md) · [Inspect the changelog](CHANGELOG.md) · [Open the v0.8.1 release](https://github.com/aryamthecodebreaker/FixMap/releases/tag/v0.8.1) - -## What changed in v0.8.0 - -v0.8.0 closes all 22 open reports from a dogfooding sweep of v0.7.4. Two themes run through it. - -**FixMap was accurate about JavaScript and imprecise about everything else.** Go and Rust repositories returned ranked files and no test command at all, which is ranking without any way to check the change. Both now route `go test ./...` and `cargo test`, workspace-scoped to the crate being edited. Language is read from the root manifest rather than by asking whether any file ends in `.py` — which had labeled clap-rs/clap, a Rust project with one helper script, a Python repository. - -**High confidence meant "in the list" rather than "the answer".** It came from an absolute score threshold, so a real Zod task labeled all eight results high while the leader was nineteen points clear. High is now reserved for a file that leads, ties the lead, or carries definition-site evidence, and a leader that merely out-talks a definition site below it is capped at medium. - -New surface: `fixmap doctor`, `plan --compare`, `--exclude` and `.fixmapignore`, `--limit`, `--working-tree`, progress phases on stderr, the `fixmap_explain` MCP tool, `mode: verify` for the Action, and pull request URLs accepted as task input. Fixed: `verify --output` created no file, duplicate `--repo`/`--format` flags silently kept the last value, and diagnostics echoed unbounded user text — including a quadratic-backtracking path that took 2.4 seconds on a 30,000-character paste, on a code path the Action feeds from public pull requests. - -[Inspect the changelog](CHANGELOG.md) · [See the held-out results](benchmarks/heldout/README.md) · [See every regression ranking](benchmarks/external/README.md) · [Audit the efficiency assumptions](docs/BENCHMARKS.md) - -## Watch it work - -[![FixMap launch film preview: a terminal report showing the ranked reset-password context file, its related test route, and a high authentication risk note.](apps/web/public/fixmap-launch-poster.jpg)](https://usefixmap.vercel.app/fixmap-launch.mp4) - -[Play the launch film](https://usefixmap.vercel.app/fixmap-launch.mp4) · [Explore the browser demo](https://usefixmap.vercel.app/demo) · [Open the repository](https://github.com/aryamthecodebreaker/FixMap) - -The website demo runs against a small browser-only sample. The CLI, MCP server, and Action scan real repositories. - -## How ranking works - -FixMap combines bounded, visible signals rather than one opaque score: - -1. Normalize the issue, task text, repository input, and optional git diff. -2. Scan code, tests, documentation, and configuration while respecting ignore rules. -3. Rank path/content overlap, distinctive definition sites, changed files, import-graph proximity, nearby paths, and workspace ownership. -4. Route the closest package-level test command and related test files. -5. Report risk areas and diagnostics without executing the suggested commands. - -The implementation lives in [`packages/core`](packages/core), shared by every interface. - -## Repository layout - -```text -packages/core scanner, ranking, routing, reports -packages/cli npx/CLI entry point and MCP server -packages/action bundled GitHub Action -apps/web interactive Next.js product site -benchmarks transparent ranking evaluation cases -examples inspectable sample input and output -``` +See [SECURITY.md](SECURITY.md) for the trust model and reporting process. ## Develop locally -FixMap requires Node.js 20.11 or newer. - ```bash npm ci npm run ci ``` -`npm run ci` covers typechecking, tests, a high/critical production audit gate, linting, production builds, Action and MCP metadata, bundle drift, smoke tests, evaluations, and scanner correctness. Use `npm run benchmark:scan` for the non-gating performance benchmark. - -## Current scope - -FixMap ranks files with these extensions, and only these: - -`.cjs` `.cs` `.css` `.cts` `.go` `.java` `.js` `.json` `.jsx` `.md` `.mjs` `.mts` `.php` `.py` `.rb` `.rs` `.svelte` `.ts` `.tsx` `.vue` `.yaml` `.yml` - -Anything else is reported by `--explain` as outside the supported set rather than scored. `.vue` and `.svelte` files are sampled from their ` + Skip to main content - {children} +
{children}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index e6261e5..652c87a 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -119,7 +119,7 @@ export default function HomePage() {
{siteStats.heldout.top3}/{siteStats.heldout.cases}held-out fixes surfaced in the top three
{siteStats.medianSeconds}smedian scan and rank
-
0false-confidence findings in adversarial cases
+
{siteStats.adversarial.passed}/{siteStats.adversarial.cases}adversarial cases passed with {Math.round(siteStats.adversarial.falseConfidenceRate * 100)}% false confidence
diff --git a/apps/web/app/product/page.tsx b/apps/web/app/product/page.tsx index 8cca8f5..bc78397 100644 --- a/apps/web/app/product/page.tsx +++ b/apps/web/app/product/page.tsx @@ -15,7 +15,13 @@ import { ProductMap } from "../_components/product-map"; export const metadata: Metadata = { title: "How it works", - description: "See how FixMap turns a software problem into ranked files, reachable checks, reviewable risks, and honest diagnostics." + description: "See how FixMap turns a software problem into ranked files, reachable checks, reviewable risks, and honest diagnostics.", + alternates: { canonical: "/product" }, + openGraph: { + title: "How FixMap works", + description: "Plan, explain, compare, and verify with repository-grounded evidence.", + url: "/product" + } }; const stages = [ diff --git a/apps/web/package.json b/apps/web/package.json index c33de87..eab520a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@aryam/fixmap-core": "0.8.7", + "@aryam/fixmap-core": "0.8.8", "@phosphor-icons/react": "^2.1.10", "next": "16.2.11", "react": "19.2.7", diff --git a/benchmarks/adversarial/results.json b/benchmarks/adversarial/results.json index dddb894..cefb480 100644 --- a/benchmarks/adversarial/results.json +++ b/benchmarks/adversarial/results.json @@ -11,6 +11,7 @@ "topConfidence": "low", "grounding": "descriptive", "diagnostics": [ + "submodules-skipped", "content-unread", "identifier-unverified", "flat-ranking" @@ -124,6 +125,8 @@ "topConfidence": "medium", "grounding": "descriptive", "diagnostics": [ + "submodules-skipped", + "content-unread", "content-unread", "flat-ranking" ], diff --git a/benchmarks/cases.json b/benchmarks/cases.json index 2aa7e27..cfd63e3 100644 --- a/benchmarks/cases.json +++ b/benchmarks/cases.json @@ -30,5 +30,120 @@ { "task": "packages/core/src/rank.ts gives boosted files the wrong confidence level", "expected": ["packages/core/src/rank.ts", "packages/core/src/index.ts", "packages/core/src/plan.ts"] + }, + { + "issue": 451, + "task": "CLI --explain ranks without the diff, contradicting the plan it explains (MCP explain does not)", + "expected": ["packages/cli/src/cli-runner.ts"] + }, + { + "issue": 452, + "task": "A truncated UTF-16BE package.json aborts the entire scan with a raw Buffer error", + "expected": ["packages/core/src/repo-scan.ts"] + }, + { + "issue": 453, + "task": "Non-git directory scans silently drop the content-unread and generated-dominance diagnostics", + "expected": ["packages/core/src/repo-scan.ts"] + }, + { + "issue": 454, + "task": "verify blames the ranking for a file the ranker excludes by design (tracked generated artifacts double-reported)", + "expected": ["packages/core/src/verify.ts"] + }, + { + "issue": 455, + "task": "plan silently ignores the verify-only --report flag (the reverse guard exists)", + "expected": ["packages/cli/src/cli-runner.ts"] + }, + { + "issue": 456, + "task": "Dead branch: new-risk-area can never be info because buildRiskNotes(changed, changed) never returns low", + "expected": ["packages/core/src/verify.ts"] + }, + { + "issue": 457, + "task": "Action verify mode always fails on pull_request events, blaming an issue input the workflow never set", + "expected": ["packages/action/src/runner.ts"] + }, + { + "issue": 458, + "task": "fitStepSummary and fitCommentBody exceed their own limits by 4 chars when closing an open fence", + "expected": ["packages/action/src/runner.ts", "packages/action/src/github.ts"] + }, + { + "issue": 459, + "task": "Action rejects GitHub issue URLs the CLI accepts: query and fragment fail the run, www and api hosts silently rank as prose", + "expected": ["packages/action/src/issue-source.ts"] + }, + { + "issue": 460, + "task": "--compare exits 0 on an unresolvable diff and reports the failure as a task-refinement result", + "expected": ["packages/cli/src/cli-runner.ts"] + }, + { + "issue": 461, + "task": "npm run evaluate passes on an empty benchmarks/cases.json because NaN thresholds disable the ranking gate", + "expected": ["scripts/evaluate.mjs"] + }, + { + "issue": 462, + "task": "Release CHANGELOG notes are validated only after npm and MCP publish, so a missing entry leaves a half-published release", + "expected": [".github/workflows/publish.yml"] + }, + { + "issue": 463, + "task": "Demo prints a truncated, unescaped CLI command that reproduces a different ranking than the one on screen", + "expected": ["apps/web/app/demo.tsx"] + }, + { + "issue": 464, + "task": "--diff reports zero changed files with no diagnostic and running from a subdirectory silently drops the whole change set", + "expected": ["packages/core/src/repo-scan.ts"] + }, + { + "issue": 465, + "task": "verify accepts a plan from a different repository and reports a confident false verification", + "expected": ["packages/core/src/verify.ts"] + }, + { + "issue": 466, + "task": "Mentioning a 404 or 500 in a task ranks package.json and vercel.json above the real fix site", + "expected": ["packages/core/src/rank.ts"] + }, + { + "issue": 467, + "task": "Issue bodies written as unchecked checklists are silently emptied, then blamed on the repository", + "expected": ["packages/core/src/signals.ts", "packages/core/src/report.ts"] + }, + { + "issue": 468, + "task": "CLI verify and compare plus the Action throw raw TypeErrors on a damaged plan file while MCP validates correctly", + "expected": ["packages/cli/src/cli-runner.ts"] + }, + { + "issue": 469, + "task": "A repo with no commits yet gets a raw git diff command failure instead of the friendly message that exists for it", + "expected": ["packages/core/src/repo-scan.ts"] + }, + { + "issue": 470, + "task": "diff-unavailable discards git stderr and reports Command failed git diff for every cause", + "expected": ["packages/core/src/repo-scan.ts"] + }, + { + "issue": 471, + "task": "When exclusions remove the matching files, FixMap warns that the repository may not contain this behavior", + "expected": ["packages/core/src/report.ts"] + }, + { + "issue": 472, + "task": "A small UTF-16 source file is reported as 0KB and exceeded the text-sampling limit", + "expected": ["packages/core/src/repo-scan.ts"] + }, + { + "issue": 473, + "task": "Windows junction dedup keeps the alias path, not the real file, contradicting its documented contract", + "expected": ["packages/core/src/repo-scan.ts"] } ] diff --git a/benchmarks/heldout/results.json b/benchmarks/heldout/results.json index 0643ea6..6c704a4 100644 --- a/benchmarks/heldout/results.json +++ b/benchmarks/heldout/results.json @@ -278,8 +278,8 @@ "src/logic/createFormControl.ts", "src/types/form.ts", "src/useController.ts", - "src/__typetest__/form-state-subscribe.test-d.ts", - "src/useFormState.ts" + "src/useFormState.ts", + "src/useForm.ts" ], "topConfidence": "medium", "top1": true, @@ -370,7 +370,7 @@ "packages-private/dts-built-test/tsconfig.json", "packages-private/tsconfig.json", "packages-private/vite-debug/tsconfig.json", - "packages-private/dts-test/ref.test-d.ts" + "packages/reactivity/src/ref.ts" ], "topConfidence": "high", "top1": false, diff --git a/docs/assets/fixmap-cli-demo.svg b/docs/assets/fixmap-cli-demo.svg index 13d4d9f..f67a6e9 100644 --- a/docs/assets/fixmap-cli-demo.svg +++ b/docs/assets/fixmap-cli-demo.svg @@ -1,4 +1,4 @@ - + - + @@ -18,17 +18,24 @@ $ npx @aryam/fixmap plan --issue "password reset emails fail" FixMap found 1 context file and generated 2 test routes. ## Context Files - - src/auth/reset-password.ts (high confidence, score 16): path matches task terms: reset, - password; content matches task terms: reset, password, email; auth-related task signal - ## Test Route - - npm run test: repository root script named test. Related: - test/auth/reset-password.test.ts. - - npm run typecheck: repository root script named typecheck. Related: - src/auth/reset-password.ts. - ## Risk Map - - high authentication: authentication-related files are affected - ## Changed Files - - None found - ## Diagnostics - - None found + - src/auth/reset-password.ts (high confidence, score 24): path matches task terms: reset, + password; content matches task terms: reset, password, email; defines symbols matching + task terms: ResetPasswordRequest, buildResetPasswordEmail; auth-related task signal + ## Test Routes + - npm run test: repository root script named test. Related: + test/auth/reset-password.test.ts. + - npm run typecheck: repository root script named typecheck. Related: + src/auth/reset-password.ts. + ## Risk Map + - low authentication: ranked files touch authentication; review this area before + editing, but no diff evidence is available yet + ## Changed Files + - None found + ## Analysis + - Task grounding: **descriptive** + - Repository scan: **complete** + - Ranking shape: **separated** + - Next action: Inspect src/auth/reset-password.ts and its routed tests before editing. + ## Diagnostics + - None found diff --git a/docs/releases/v0.8.8-issue-verification.md b/docs/releases/v0.8.8-issue-verification.md new file mode 100644 index 0000000..38d078a --- /dev/null +++ b/docs/releases/v0.8.8-issue-verification.md @@ -0,0 +1,68 @@ +# v0.8.8 issue verification + +This release resolves the 46-issue backlog opened as GitHub issues #451 through #496. The table maps every issue to its implementation and regression evidence so the release can be audited without relying on the closing status alone. + +| Issue | Resolution | Regression evidence | +|---|---|---| +| #451 | CLI `--explain` now ranks with the scanned diff, matching plan and MCP. | CLI explain/diff tests and core explanation tests. | +| #452 | Odd-length UTF-16BE manifests degrade to `package-json-invalid` instead of aborting the scan. | Odd-byte manifest scan test. | +| #453 | Walk-mode scans now emit unread-content and generated-dominance diagnostics. | Non-git scanner diagnostic tests. | +| #454 | Verify excludes tracked generated twins from unmapped and no-test findings. | Tracked generated artifact verification test. | +| #455 | `plan --report` fails with a direct `--output` correction. | CLI misplaced-option test. | +| #456 | New-risk findings use an explicit warning severity, removing the structurally unreachable informational branch. | Verify risk-severity tests. | +| #457 | Action verify mode accepts pull-request-derived input when no explicit issue input exists. | Pull-request verify runner test. | +| #458 | Step summaries and comments reserve room for a closing Markdown fence. | Exact-limit Action rendering tests. | +| #459 | Action issue URLs accept canonical, `www`, and API hosts plus query/fragment suffixes, while rejecting unsafe lookalikes. | Issue-source URL matrix. | +| #460 | Compare fails when its requested diff cannot be resolved. | CLI unresolved-compare test. | +| #461 | Empty or malformed benchmark cohorts fail before rates are computed, so `NaN` cannot bypass a gate. | Local evaluation gate plus explicit non-empty validation. | +| #462 | Publish validates the release version and changelog before any npm or MCP mutation. | Ordered publish-workflow inspection and release CI. | +| #463 | Demo commands preserve and shell-escape the complete task text used for the displayed plan. | Web typecheck/build and command rendering review. | +| #464 | Explicit diffs report empty resolved ranges and use repository-root-relative Git commands. | Empty-diff and subdirectory scanner tests. | +| #465 | Verify rejects plans whose repository identity differs from the scanned checkout. | Plan/repository mismatch test. | +| #466 | Bare HTTP status codes no longer trigger deployment-config ranking. | Rank regression test and 23-case self cohort. | +| #467 | Unchecked checklist prose is preserved when it is the issue body; removed template options are counted and reported. | Signal extraction and report diagnostic tests. | +| #468 | CLI, Action, and MCP share one version-aware report validator and return actionable errors for damaged plans. | Validator, CLI, MCP, and Action invalid-report tests. | +| #469 | Unborn repositories receive a friendly diff diagnostic. | Unborn-repository scanner test. | +| #470 | Diff diagnostics retain Git stderr and the failing ref context. | Diff failure-detail tests. | +| #471 | An empty result re-ranks without exclusions and names the matching excluded paths. | Plan/report exclusion tests. | +| #472 | Unread diagnostics distinguish size, binary, and read failures and render decimal kB without rounding small files to zero. | Sampling-reason and size-unit tests. | +| #473 | Real-path deduplication prefers the physical path over Windows junction aliases. | Junction deduplication test. | +| #474 | The 23 title-only issues are a separate, path-unmentioned regression cohort with Top-1/3/5 rates and Wilson intervals. | `npm run evaluate`; answer-sheet file excluded from scanning. | +| #475 | Honest report, demo, and benchmark-card renderers are regenerated and checked for drift in CI. | `npm run check:rendered`. | +| #476 | Go `_test.go`, Python `test_*.py`, `*_test.py`, and TypeScript declaration tests are classified as tests. | Cross-language scanner and verify tests. | +| #477 | MCP describes `analysis.nextAction` as report guidance, not healthy CLI stderr. | MCP descriptor test/build. | +| #478 | Demo transcript and verification are generated from core report renderers at build time. | Web build plus rendered-output freshness check. | +| #479 | The four-step demo writes a runnable source file, saves `plan.json`, compares, and verifies that same plan. | Demo command review and web build. | +| #480 | PowerShell onboarding creates `src/reset-password.ts` before the first plan. | Get-started page build and browser smoke. | +| #481 | Sampling limits and displayed sizes both use decimal kB. | Exact boundary/size tests. | +| #482 | Action comment search is newest-first and capped at 50 pages even when a proxy repeats results. | Pagination-cap and newest-marker tests. | +| #483 | Product-map claims are generated from a real sample-repository report and its fixed risk areas. | Web typecheck/build. | +| #484 | The homepage imports recorded adversarial evidence, and the adversarial gate runs in root CI. | `npm run evaluate:adversarial:gate` and web build. | +| #485 | Faint text and strong-line tokens meet AA contrast across all routes; tiny stage labels are enlarged. | Browser route/contrast smoke. | +| #486 | Every route has its own canonical URL, Open Graph title, description, and URL. | Production metadata smoke across seven routes. | +| #487 | System-aware dark mode and a persistent light/dark toggle cover the full site without a first-paint flash. | Browser theme smoke in both preferences. | +| #488 | JSON plans carry `reportVersion: 1`; the additive compatibility policy is documented and legacy plans remain accepted. | Report, validator, README, and docs tests/build. | +| #489 | Git submodules are detected from exact gitlink mode, reported as skipped, and explained at the ancestor path. | Submodule scan/explain tests. | +| #490 | Plan Markdown renders `ScanDiagnostic.paths`, matching verification entries. | Report rendering test. | +| #491 | Keyboard focus uses a dedicated 3:1 non-text-contrast token. | Browser focus-state contrast smoke. | +| #492 | Demo presets are an `aria-pressed` button group rather than an incomplete tab widget. | Web build and keyboard/semantics smoke. | +| #493 | Generated twins are penalized below maintained source and never become `nextAction`. | Ranking and next-action regression tests. | +| #494 | README is a maintained 153-line entry point that routes detailed docs and changelog to their canonical sources. | README structure/link review. | +| #495 | Exact Git-state scan caching lives outside the repository, reports hits, invalidates conservatively, and supports `--no-cache`. | Cache hit, invalidation, CLI opt-out, and non-git tests. | +| #496 | Leading `@` is literal issue text; explicit `--issue-file` is the only file-input form. | Scoped-package, decorator, and mention CLI tests. | + +## Release acceptance + +Before tagging, v0.8.8 must pass all of the following from a clean checkout of the release commit: + +```text +npm ci +npm run ci +npm run evaluate:external +npm run evaluate:heldout +npm run evaluate:adversarial:gate +npm pack --dry-run --workspace @aryam/fixmap-core +npm pack --dry-run --workspace @aryam/fixmap +``` + +The release is complete only after the exact merge commit is tagged `v0.8.8`, the publish workflow succeeds, npm and MCP Registry serve 0.8.8, the GitHub release and Action tag resolve to that commit, all 46 issues are closed, and the production website passes the route, metadata, theme, and install-command smoke checks. diff --git a/examples/reports/declines-fabricated-identifier.md b/examples/reports/declines-fabricated-identifier.md index 1d18500..9f582bc 100644 --- a/examples/reports/declines-fabricated-identifier.md +++ b/examples/reports/declines-fabricated-identifier.md @@ -14,7 +14,7 @@ FixMap found 0 context files and generated 0 test routes. - None found -## Test Route +## Test Routes - None found diff --git a/examples/reports/declines-unmatched-terms.md b/examples/reports/declines-unmatched-terms.md index b7b746b..e27fd3c 100644 --- a/examples/reports/declines-unmatched-terms.md +++ b/examples/reports/declines-unmatched-terms.md @@ -14,7 +14,7 @@ FixMap found 0 context files and generated 0 test routes. - None found -## Test Route +## Test Routes - None found diff --git a/examples/reports/declines-vague-task.md b/examples/reports/declines-vague-task.md index 9e4a267..84488df 100644 --- a/examples/reports/declines-vague-task.md +++ b/examples/reports/declines-vague-task.md @@ -14,7 +14,7 @@ FixMap found 0 context files and generated 0 test routes. - None found -## Test Route +## Test Routes - None found diff --git a/package-lock.json b/package-lock.json index 1d2964c..3b1774f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fixmap-workspace", - "version": "0.8.7", + "version": "0.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fixmap-workspace", - "version": "0.8.7", + "version": "0.8.8", "license": "MIT", "workspaces": [ "packages/*", @@ -30,7 +30,7 @@ "name": "@fixmap/web", "version": "0.0.0", "dependencies": { - "@aryam/fixmap-core": "0.8.7", + "@aryam/fixmap-core": "0.8.8", "@phosphor-icons/react": "^2.1.10", "next": "16.2.11", "react": "19.2.7", @@ -5836,9 +5836,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -6410,9 +6410,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -8553,18 +8553,18 @@ }, "packages/action": { "name": "@fixmap/action", - "version": "0.8.7", + "version": "0.8.8", "license": "MIT", "dependencies": { - "@aryam/fixmap-core": "0.8.7" + "@aryam/fixmap-core": "0.8.8" } }, "packages/cli": { "name": "@aryam/fixmap", - "version": "0.8.7", + "version": "0.8.8", "license": "MIT", "dependencies": { - "@aryam/fixmap-core": "0.8.7", + "@aryam/fixmap-core": "0.8.8", "@modelcontextprotocol/sdk": "1.30.0" }, "bin": { @@ -8576,7 +8576,7 @@ }, "packages/core": { "name": "@aryam/fixmap-core", - "version": "0.8.7", + "version": "0.8.8", "license": "MIT", "devDependencies": {}, "engines": { diff --git a/package.json b/package.json index a1a3b26..5dbbad9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fixmap-workspace", - "version": "0.8.7", + "version": "0.8.8", "private": true, "description": "Local-first repo context for coding agents: paste a GitHub issue URL to get ranked files, test routes, and risks.", "license": "MIT", @@ -49,7 +49,7 @@ "benchmark:check": "npm run build:core && node scripts/benchmark-scan.mjs --tier 1000 --check", "benchmark:savings": "npm run build:core && node scripts/benchmark-savings.mjs", "benchmark:savings:record": "npm run build:core && node scripts/benchmark-savings.mjs --record", - "ci": "npm run typecheck && npm test && npm run audit:production && npm run lint && npm run build && npm run check:action-metadata && npm run check:server-manifest && npm run check:action-bundle && npm run smoke && npm run evaluate && npm run benchmark:check", + "ci": "npm run typecheck && npm test && npm run audit:production && npm run lint && npm run build && npm run check:action-metadata && npm run check:server-manifest && npm run check:action-bundle && npm run check:rendered && npm run smoke && npm run evaluate && npm run evaluate:adversarial:gate && npm run benchmark:check", "evaluate": "npm run build:core && node scripts/evaluate.mjs", "evaluate:external": "npm run build:core && node scripts/evaluate-external.mjs", "evaluate:external:record": "npm run build:core && node scripts/evaluate-external.mjs --record", @@ -64,7 +64,8 @@ "evaluate:adversarial": "npm run build:core && node scripts/evaluate-adversarial.mjs", "evaluate:adversarial:gate": "npm run build:core && node scripts/evaluate-adversarial.mjs --gate --check-recorded", "evaluate:adversarial:record": "npm run build:core && node scripts/evaluate-adversarial.mjs --record", - "render:examples": "npm run build:core && node scripts/render-honest-examples.mjs" + "render:examples": "npm run build:core && node scripts/render-honest-examples.mjs", + "check:rendered": "npm run render:examples && npm run build:cli && node scripts/render-demo.mjs && npm run render:benchmark-card && git diff --exit-code docs/assets examples/reports" }, "engines": { "node": "24.x" @@ -74,6 +75,8 @@ "postcss": "8.5.23" }, "fast-uri": "3.1.4", + "js-yaml": "4.3.1", + "nanoid": "3.3.17", "sharp": "0.35.3" }, "devDependencies": { diff --git a/packages/action/dist/index.mjs b/packages/action/dist/index.mjs index 18ca16a..4c0f8a4 100644 --- a/packages/action/dist/index.mjs +++ b/packages/action/dist/index.mjs @@ -282,7 +282,8 @@ var TRAILING_E_VERB_STEMS = /* @__PURE__ */ new Set([ "updat" ]); function extractTaskSignals(input) { - const issueText = stripUncheckedChecklistLines(input.issueText ?? ""); + const prepared = prepareChecklistText(input.issueText ?? ""); + const issueText = prepared.text; const taskText = [issueText, extractDiffContentLines(input.diffText ?? "")].join("\n"); const tokens = tokenizeText(taskText); return { @@ -291,11 +292,23 @@ function extractTaskSignals(input) { fileMentions: extractFileMentions(issueText), memberMentions: extractMemberMentions(issueText), exactFragments: extractExactFragments(taskText), - identifiers: extractIdentifiers(taskText) + identifiers: extractIdentifiers(taskText), + uncheckedChecklistLinesRemoved: prepared.removed, + uncheckedChecklistLinesPreserved: prepared.preserved }; } -function stripUncheckedChecklistLines(text) { - return text.split(/\r?\n/).filter((line) => !/^\s*[-*]\s*\[\s\]\s+/.test(line)).join("\n"); +function prepareChecklistText(text) { + const unchecked = /^\s*[-*]\s*\[\s\]\s+/; + const lines = text.split(/\r?\n/); + const removed = lines.filter((line) => unchecked.test(line)); + if (removed.length === 0) + return { text, removed: 0, preserved: 0 }; + const retained = lines.filter((line) => !unchecked.test(line)); + const hasSubstantiveRetainedText = retained.some((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !/^#{1,6}\s+/.test(trimmed); + }); + return hasSubstantiveRetainedText ? { text: retained.join("\n"), removed: removed.length, preserved: 0 } : { text, removed: 0, preserved: removed.length }; } function extractExactFragments(text) { const fragments = /* @__PURE__ */ new Set(); @@ -523,7 +536,7 @@ function buildNextAction(grounding, ranking, contextFiles, hasRoutedTests = true return "Verify or correct the unresolved identifiers before editing ranked files."; } if (grounding.unverifiedIdentifiers.length > 0) { - return "Narrow the repository or inspect large unread files before trusting identifier-based recommendations."; + return "Inspect the content-unread diagnostics and make those source files readable before trusting identifier-based recommendations."; } if (grounding.partiallyResolvedIdentifiers.length > 0) { return "Verify the partially matched symbol name in the leading file before editing."; @@ -538,7 +551,8 @@ function buildNextAction(grounding, ranking, contextFiles, hasRoutedTests = true return "Treat the leading files as a subsystem neighborhood and verify the exact edit point before changing code."; } if (contextFiles[0]) { - return hasRoutedTests ? `Inspect ${contextFiles[0].path} and its routed tests before editing.` : `Inspect ${contextFiles[0].path} before editing; no related test file was routed.`; + const leading = contextFiles.find((file) => !file.reasons.includes("generated build artifact; maintained source counterpart exists")) ?? contextFiles[0]; + return hasRoutedTests ? `Inspect ${leading.path} and its routed tests before editing.` : `Inspect ${leading.path} before editing; no related test file was routed.`; } return "Add a concrete repository anchor and rerun FixMap."; } @@ -955,10 +969,7 @@ var DEPLOYMENT_TERMS = [ "kubernetes", "hosting", "serverless", - "production", - "404", - "500", - "502" + "production" ]; var LOCKFILES = /* @__PURE__ */ new Set(["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"]); var AUXILIARY_CODE_DIRS = /* @__PURE__ */ new Set(["demo", "demos", "example", "examples", "sample", "samples"]); @@ -976,6 +987,7 @@ var PRESENTATION_CODE_PENALTY = 8; var TYPE_DECLARATION_PENALTY = 4; var BACKUP_COPY_PENALTY = 10; var BUNDLED_OUTPUT_PENALTY = 12; +var GENERATED_TWIN_PENALTY = 21; var GENERATED_TWIN_REASON = "generated build artifact; maintained source counterpart exists"; var BUNDLED_LINE_LENGTH = 400; var MIN_BUNDLE_SAMPLE_BYTES = 2e3; @@ -1056,7 +1068,9 @@ function rankContextFiles(repo, input, limit = DEFAULT_CONTEXT_FILE_LIMIT, minSc reasons.push("explicitly named in the task"); } if (isGeneratedPath(file.path) && maintainedStems.has(moduleStem(file.path))) { + score -= GENERATED_TWIN_PENALTY; reasons.push(GENERATED_TWIN_REASON); + reasons.push("generated counterpart deprioritized below maintained source"); } const pathTokens = tokenizePath(file.path); const pathOverlap = [...pathTokens].filter((token) => taskTokens.has(token)); @@ -1503,6 +1517,7 @@ function buildReportFromRepo(repo, input) { const testRoutes = buildTestRoutes(repo, contextPaths); const routedTestPaths = [...new Set(testRoutes.flatMap((route) => route.relatedFiles))]; return { + reportVersion: 1, summary: buildSummary(contextFiles.length, testRoutes.length), contextFiles, testRoutes, @@ -1512,8 +1527,9 @@ function buildReportFromRepo(repo, input) { ...repo.diagnostics, ...findGatedTestDiagnostics(repo.files, routedTestPaths), ...findMissingTestRouteDiagnostics(repo, contextFiles, testRoutes), - ...findTaskDiagnostics(grounding, ranking), - ...grounding.specificity === "vague" ? [] : findEmptyResultDiagnostics(repo, contextFiles, input.issueText ?? "") + ...findTaskDiagnostics(repo, grounding, ranking), + ...findTaskPreprocessingDiagnostics(input.issueText ?? ""), + ...grounding.specificity === "vague" ? [] : findEmptyResultDiagnostics(repo, contextFiles, input.issueText ?? "", input.exclude) ], analysis: { grounding, @@ -1548,7 +1564,7 @@ function findMissingTestRouteDiagnostics(repo, contextFiles, testRoutes) { message: runner ? `No test command was routed. FixMap read this as a ${language} repository (${evidence}) and found no supported package script; \`${runner}\` is the runner that fits, but confirm it against the project's own configuration before relying on it.` : "No test command was routed. FixMap found code context but no supported package test script, so tests were not assumed to be absent." }]; } -function findTaskDiagnostics(grounding, ranking) { +function findTaskDiagnostics(repo, grounding, ranking) { const diagnostics = []; if (grounding.unresolvedIdentifiers.length > 0) { diagnostics.push({ @@ -1565,10 +1581,12 @@ function findTaskDiagnostics(grounding, ranking) { }); } if (grounding.unverifiedIdentifiers.length > 0) { + const skipReasons = new Set(repo.files.filter((file) => file.isSource && file.textSampleComplete === false).map((file) => file.textSampleSkipReason)); + const cause = skipReasons.size === 1 && skipReasons.has("too-large") ? "one or more source files exceeded the text-sampling limit" : "one or more source files could not be sampled as UTF-8 text"; diagnostics.push({ code: "identifier-unverified", severity: "warning", - message: `Identifier${grounding.unverifiedIdentifiers.length === 1 ? "" : "s"} could not be verified because one or more source files exceeded the text-sampling limit: ${grounding.unverifiedIdentifiers.join(", ")}. FixMap did not claim that the identifier was absent, and confidence was capped at low without another anchor.` + message: `Identifier${grounding.unverifiedIdentifiers.length === 1 ? "" : "s"} could not be verified because ${cause}: ${grounding.unverifiedIdentifiers.join(", ")}. FixMap did not claim that the identifier was absent, and confidence was capped at low without another anchor.` }); } if (grounding.specificity === "vague") { @@ -1587,7 +1605,25 @@ function findTaskDiagnostics(grounding, ranking) { } return diagnostics; } -function findEmptyResultDiagnostics(repo, contextFiles, issueText) { +function findTaskPreprocessingDiagnostics(issueText) { + const signals = extractTaskSignals({ issueText }); + if (signals.uncheckedChecklistLinesPreserved > 0) { + return [{ + code: "task-checklist-filtered", + severity: "info", + message: `Preserved ${signals.uncheckedChecklistLinesPreserved} unchecked checklist ${signals.uncheckedChecklistLinesPreserved === 1 ? "line" : "lines"} because they contained the issue's only substantive task details.` + }]; + } + if (signals.uncheckedChecklistLinesRemoved > 0) { + return [{ + code: "task-checklist-filtered", + severity: "info", + message: `Removed ${signals.uncheckedChecklistLinesRemoved} unchecked issue-template ${signals.uncheckedChecklistLinesRemoved === 1 ? "option" : "options"} before ranking; selected checklist items and prose were retained.` + }]; + } + return []; +} +function findEmptyResultDiagnostics(repo, contextFiles, issueText, exclude) { if (contextFiles.length > 0 || repo.files.length === 0) { return []; } @@ -1597,6 +1633,19 @@ function findEmptyResultDiagnostics(repo, contextFiles, issueText) { changedFiles: repo.changedFiles }); const terms = [...signals.tokens].sort(); + if (exclude?.patterns.length) { + const withoutExclusions = rankContextFiles(repo, { issueText, diffText: repo.diffText }, DEFAULT_CONTEXT_FILE_LIMIT); + const excludedMatches = withoutExclusions.filter((file) => exclude.excludes(file.path)); + if (excludedMatches.length > 0) { + const paths = excludedMatches.map((file) => file.path); + return [{ + code: "no-context-match", + severity: "warning", + message: `No context files: ${paths.length} matching ${paths.length === 1 ? "file was" : "files were"} removed by exclusion patterns (${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", \u2026" : ""}). Remove the pattern or run --explain on one of these paths.`, + paths: paths.slice(0, 8) + }]; + } + } if (terms.length === 0 && signals.identifiers.size === 0 && signals.fileMentions.size === 0) { return [{ code: "no-task-terms", @@ -1824,7 +1873,10 @@ function renderMarkdownReport(report) { "", "## Diagnostics", "", - ...listOrEmpty(report.diagnostics.map((diagnostic) => `- **${diagnostic.severity}** ${diagnostic.message}`)) + ...listOrEmpty(report.diagnostics.flatMap((diagnostic) => [ + `- **${diagnostic.severity}** ${diagnostic.message}`, + ...(diagnostic.paths ?? []).slice(0, 8).map((path) => ` - \`${path}\``) + ])) ]; return `${lines.join("\n")} `; @@ -1839,7 +1891,9 @@ function listOrEmpty(lines) { // packages/core/dist/repo-scan.js import { execFile } from "node:child_process"; -import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; var WALK_IGNORED_DIRS = /* @__PURE__ */ new Set([...ALWAYS_IGNORED_DIRS, ...GENERATED_DIRS]); @@ -1869,12 +1923,20 @@ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([ ]); var SFC_EXTENSIONS = /* @__PURE__ */ new Set([".vue", ".svelte"]); var SFC_SCRIPT_BLOCK = /]*>([\s\S]*?)<\/script>/gi; -var TEST_PATTERNS = [/\.test\./, /\.spec\./, /(^|\/|\\)__tests__(\/|\\)/, /(^|\/|\\)tests?(\/|\\)/]; +var TEST_PATTERNS = [ + /\.test(?:\.|-d\.)/, + /\.spec\./, + /(^|\/|\\)__tests__(\/|\\)/, + /(^|\/|\\)tests?(\/|\\)/, + /_test\.go$/, + /(^|\/|\\)(?:test_[^/\\]+|[^/\\]+_test)\.py$/ +]; var MAX_TEXT_SAMPLE_BYTES = 64e3; var MAX_DIFF_TEXT_CHARS = 2e5; var MAX_SCANNED_FILES = 25e3; var GIT_MAX_BUFFER = 10 * 1024 * 1024; var exec = promisify(execFile); +var SCAN_CACHE_VERSION = 1; async function scanRepo(input) { const repoRoot = resolve(input.repoRoot); if (!await isDirectory(repoRoot)) { @@ -1893,9 +1955,39 @@ async function scanRepo(input) { }; } const diagnostics = []; - const files = await listFiles(repoRoot, diagnostics); - const trackedFiles = await listTrackedPaths(repoRoot); - const packageScripts = await readPackageScripts(repoRoot, files, diagnostics); + const cacheLocation = input.useCache ? await buildScanCacheLocation(repoRoot) : void 0; + const cached = cacheLocation ? await readScanCache(cacheLocation) : void 0; + let files; + let trackedFiles; + let packageScripts; + let packageManager; + if (cached) { + files = cached.files; + trackedFiles = cached.trackedFiles; + packageScripts = cached.packageScripts; + packageManager = cached.packageManager; + diagnostics.push(...cached.diagnostics, { + code: "cache-hit", + severity: "info", + message: `Reused the repository scan for the exact current git state (${files.length.toLocaleString()} files). Pass --no-cache to rescan.` + }); + } else { + files = await listFiles(repoRoot, diagnostics); + trackedFiles = await listTrackedPaths(repoRoot); + packageScripts = await readPackageScripts(repoRoot, files, diagnostics); + packageManager = detectPackageManager(files); + if (cacheLocation) { + await writeScanCache(cacheLocation, { + version: SCAN_CACHE_VERSION, + stateKey: cacheLocation.stateKey, + files, + trackedFiles, + packageScripts, + packageManager, + diagnostics: [...diagnostics] + }); + } + } const diffSpec = resolveDiffSpec(input); const diff = input.workingTree ? await readWorkingTree(repoRoot, input.includeUntracked === true, diagnostics) : await readDiff(repoRoot, diffSpec, diagnostics); return { @@ -1905,10 +1997,75 @@ async function scanRepo(input) { packageScripts, changedFiles: diff.changedFiles, diffText: diff.diffText, - packageManager: detectPackageManager(files), + packageManager, diagnostics }; } +async function buildScanCacheLocation(root) { + try { + const [{ stdout: head }, { stdout: status }] = await Promise.all([ + exec("git", ["rev-parse", "HEAD"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }), + exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], { + cwd: root, + maxBuffer: GIT_MAX_BUFFER + }) + ]); + if (status.split("\0").some((entry) => entry.startsWith("?? "))) + return void 0; + const dirtyDiff = status.length > 0 ? (await exec("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--", "."], { + cwd: root, + maxBuffer: GIT_MAX_BUFFER + })).stdout : ""; + const stateKey = hashText([ + String(SCAN_CACHE_VERSION), + resolve(root), + head.trim(), + status, + dirtyDiff + ].join("\0")); + const cacheRoot = process.env.FIXMAP_CACHE_DIR ?? join(process.env.LOCALAPPDATA ?? process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "fixmap", "scans"); + return { + path: join(cacheRoot, `${hashText(resolve(root))}-${stateKey}.json`), + stateKey + }; + } catch { + return void 0; + } +} +async function readScanCache(location) { + try { + const cached = JSON.parse(await readFile(location.path, "utf8")); + if (cached.version !== SCAN_CACHE_VERSION || cached.stateKey !== location.stateKey || !Array.isArray(cached.files) || !Array.isArray(cached.trackedFiles) || !Array.isArray(cached.packageScripts) || !Array.isArray(cached.diagnostics) || !["npm", "pnpm", "yarn", "bun"].includes(cached.packageManager ?? "")) + return void 0; + return cached; + } catch { + return void 0; + } +} +async function writeScanCache(location, cached) { + try { + await mkdir(dirname(location.path), { recursive: true }); + await writeFile(location.path, `${JSON.stringify(cached)} +`, { encoding: "utf8", flag: "wx" }); + } catch (error) { + const code = error.code; + if (code !== "EEXIST") { + return; + } + const existing = await readScanCache(location); + if (existing) + return; + try { + await unlink(location.path); + await writeFile(location.path, `${JSON.stringify(cached)} +`, { encoding: "utf8", flag: "wx" }); + } catch { + } + } +} +function hashText(value) { + return createHash("sha256").update(value).digest("hex").slice(0, 24); +} async function listTrackedPaths(root) { try { const { stdout } = await exec("git", ["ls-files", "--cached", "-z"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }); @@ -1922,23 +2079,33 @@ function resolveDiffSpec(input) { } async function listFiles(root, diagnostics) { const gitPaths = await listGitPaths(root); - if (gitPaths) { - return buildFilesFromPaths(root, gitPaths, diagnostics); - } - const files = await walkFiles(root, root, diagnostics, { count: 0, limitReported: false }); - return files.sort((a, b) => a.path.localeCompare(b.path)); + const files = gitPaths ? await buildFilesFromPaths(root, gitPaths.paths, diagnostics, gitPaths.gitLinks) : (await walkFiles(root, root, diagnostics, { count: 0, limitReported: false })).sort((a, b) => a.path.localeCompare(b.path)); + reportUnreadContent(diagnostics, files); + reportGeneratedDominance(diagnostics, files); + return files; } async function listGitPaths(root) { try { - const { stdout } = await exec("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }); - return [...new Set(stdout.split("\0").filter(Boolean))]; + const [{ stdout }, { stdout: staged }] = await Promise.all([ + exec("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { + cwd: root, + maxBuffer: GIT_MAX_BUFFER + }), + exec("git", ["ls-files", "--stage", "-z"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }) + ]); + const gitLinks = new Set(staged.split("\0").flatMap((entry) => { + const match = /^160000\s+[0-9a-f]+\s+\d+\t(.+)$/i.exec(entry); + return match?.[1] ? [normalizePath(match[1])] : []; + })); + return { paths: [...new Set(stdout.split("\0").filter(Boolean))], gitLinks }; } catch { return void 0; } } -async function buildFilesFromPaths(root, paths, diagnostics) { +async function buildFilesFromPaths(root, paths, diagnostics, knownGitLinks = /* @__PURE__ */ new Set()) { const results = []; const absent = []; + const gitLinks = []; const seenRealPaths = /* @__PURE__ */ new Map(); const linked = []; for (const [index, rawPath] of paths.entries()) { @@ -1950,18 +2117,28 @@ async function buildFilesFromPaths(root, paths, diagnostics) { if (isInAlwaysIgnoredDir(relativePath)) { continue; } + if (knownGitLinks.has(relativePath)) { + gitLinks.push(relativePath); + continue; + } const scanned = await toRepoFile(join(root, rawPath), relativePath); if (scanned.status === "absent") { absent.push(relativePath); continue; } + if (scanned.status === "not-a-file") { + gitLinks.push(relativePath); + continue; + } if (scanned.status !== "ok") { continue; } const seenIndex = seenRealPaths.get(scanned.realPath); if (seenIndex !== void 0) { const seenFile = results[seenIndex]; - if (await isSymbolicLink(join(root, seenFile.path))) { + const seenIsAlias = !sameFilesystemPath(resolve(root, seenFile.path), scanned.realPath); + const currentIsAlias = !sameFilesystemPath(resolve(root, relativePath), scanned.realPath); + if (seenIsAlias && !currentIsAlias) { linked.push({ path: seenFile.path, target: relativePath }); results[seenIndex] = scanned.file; } else { @@ -1974,8 +2151,7 @@ async function buildFilesFromPaths(root, paths, diagnostics) { } reportAbsentTrackedPaths(diagnostics, absent); reportLinkedDuplicates(diagnostics, linked); - reportUnreadContent(diagnostics, results); - reportGeneratedDominance(diagnostics, results); + reportSkippedSubmodules(diagnostics, gitLinks); return results.sort((a, b) => a.path.localeCompare(b.path)); } function reportAbsentTrackedPaths(diagnostics, absent) { @@ -1988,14 +2164,39 @@ function reportAbsentTrackedPaths(diagnostics, absent) { }); } function reportUnreadContent(diagnostics, files) { - const unread = files.filter((file) => file.isSource && file.textSampleComplete === false); + const unavailable = files.filter((file) => file.isSource && file.textSampleComplete === false && file.textSampleSkipReason !== "too-large"); + for (const reason of ["not-text", "unreadable"]) { + const affected = unavailable.filter((file) => file.textSampleSkipReason === reason); + if (affected.length === 0) + continue; + const sample2 = affected.slice(0, 3).map((file) => file.path).join(", "); + const prefix = `${affected.length.toLocaleString()} source file${affected.length === 1 ? "" : "s"}`; + diagnostics.push({ + code: "content-unread", + severity: "warning", + message: reason === "not-text" ? `${prefix} ${affected.length === 1 ? "is" : "are"} not UTF-8 text (for example UTF-16 or binary) and rank${affected.length === 1 ? "s" : ""} on path alone: ${sample2}${affected.length > 3 ? ", ..." : ""}. Re-save source as UTF-8 to rank its contents.` : `${prefix} could not be read and rank${affected.length === 1 ? "s" : ""} on path alone: ${sample2}${affected.length > 3 ? ", ..." : ""}. Check file permissions and retry.`, + paths: affected.slice(0, 8).map((file) => file.path) + }); + } + const unread = files.filter((file) => file.isSource && file.textSampleComplete === false && file.textSampleSkipReason === "too-large"); if (unread.length === 0) return; - const sample = unread.slice().sort((a, b) => b.sizeBytes - a.sizeBytes).slice(0, 3).map((file) => `${file.path} (${Math.round(file.sizeBytes / 1024).toLocaleString()}KB)`).join(", "); + const sample = unread.slice().sort((a, b) => b.sizeBytes - a.sizeBytes).slice(0, 3).map((file) => `${file.path} (${Math.ceil(file.sizeBytes / 1e3).toLocaleString()} kB)`).join(", "); diagnostics.push({ code: "content-unread", severity: "warning", - message: `${unread.length.toLocaleString()} source file${unread.length === 1 ? "" : "s"} could not be read as text and rank${unread.length === 1 ? "s" : ""} on path alone \u2014 largest: ${sample}${unread.length > 3 ? ", \u2026" : ""}. Files over ${(MAX_TEXT_SAMPLE_BYTES / 1e3).toLocaleString()}KB are not sampled.` + message: `${unread.length.toLocaleString()} source file${unread.length === 1 ? "" : "s"} could not be read as text and rank${unread.length === 1 ? "s" : ""} on path alone \u2014 largest: ${sample}${unread.length > 3 ? ", \u2026" : ""}. Files over ${(MAX_TEXT_SAMPLE_BYTES / 1e3).toLocaleString()} kB are not sampled.`, + paths: unread.slice(0, 8).map((file) => file.path) + }); +} +function reportSkippedSubmodules(diagnostics, gitLinks) { + if (gitLinks.length === 0) + return; + diagnostics.push({ + code: "submodules-skipped", + severity: "info", + message: `${gitLinks.length.toLocaleString()} git submodule${gitLinks.length === 1 ? " was" : "s were"} not scanned: ${gitLinks.slice(0, 3).join(", ")}${gitLinks.length > 3 ? ", \u2026" : ""}. Submodules are separate repositories; point --repo at one to map its contents.`, + paths: gitLinks.slice(0, 8) }); } var GENERATED_DOMINANCE_SHARE = 0.4; @@ -2085,7 +2286,8 @@ async function toRepoFile(absolutePath, relativePath) { isSource, kind: classifyFile(relativePath, extension), textSample: sample.text, - textSampleComplete: sample.complete + textSampleComplete: sample.complete, + ...sample.skipReason ? { textSampleSkipReason: sample.skipReason } : {} } }; } @@ -2101,12 +2303,8 @@ function extractScriptBlocks(text) { const joined = blocks.join("\n").trim(); return joined || text; } -async function isSymbolicLink(absolutePath) { - try { - return (await lstat(absolutePath)).isSymbolicLink(); - } catch { - return false; - } +function sameFilesystemPath(left, right) { + return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; } function isInAlwaysIgnoredDir(relativePath) { return relativePath.split("/").slice(0, -1).some((segment) => ALWAYS_IGNORED_DIRS.has(segment)); @@ -2145,8 +2343,9 @@ async function readPackageScripts(root, files, diagnostics) { }); continue; } - const decoded = decodeManifest(bytes); + let decoded; try { + decoded = decodeManifest(bytes); const parsed = JSON.parse(decoded.text); const packageDir = normalizePath(dirname(manifest.path)); const packageName = typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : void 0; @@ -2162,7 +2361,7 @@ async function readPackageScripts(root, files, diagnostics) { severity: "warning", message: `Could not parse ${manifest.path}; scripts from that package were skipped.` + // Encoding is no longer a cause of failure, so naming it here rules it out rather // than sending someone to re-save a file whose real problem is a syntax error. - (decoded.encoding === "utf8" ? "" : ` It was decoded as ${decoded.encoding}, so the problem is the JSON itself, not the encoding.`) + (!decoded || decoded.encoding === "utf8" ? "" : ` It was decoded as ${decoded.encoding}, so the problem is the JSON itself, not the encoding.`) }); } } @@ -2173,7 +2372,11 @@ function decodeManifest(bytes) { return { text: bytes.subarray(2).toString("utf16le"), encoding: "UTF-16LE" }; } if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) { - return { text: bytes.subarray(2).swap16().toString("utf16le"), encoding: "UTF-16BE" }; + const body = bytes.subarray(2); + if (body.length % 2 !== 0) { + throw new Error("Truncated UTF-16BE input has an odd byte count"); + } + return { text: Buffer.from(body).swap16().toString("utf16le"), encoding: "UTF-16BE" }; } if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) { return { text: bytes.subarray(3).toString("utf8"), encoding: "UTF-8 with a byte order mark" }; @@ -2191,17 +2394,24 @@ async function readDiff(repoRoot, diffSpec, diagnostics) { ]); const tracked = names.split(/\r?\n/).map((path) => path.trim()).filter(Boolean).map(normalizePath); const untracked = diffSpec.includes("..") ? [] : await listUntrackedPaths(repoRoot); + const changedFiles = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].sort((a, b) => a.localeCompare(b)); + diagnostics.push({ + code: "diff-resolved", + severity: "info", + message: changedFiles.length === 0 ? `The diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}" resolved to zero changed files, so results use the task text only. Paths are relative to the working directory; run from the repository root to include changes outside it.` : `Diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}" resolved ${changedFiles.length} changed ${changedFiles.length === 1 ? "path" : "paths"}.`, + paths: changedFiles.slice(0, 8) + }); return { - changedFiles: [.../* @__PURE__ */ new Set([...tracked, ...untracked])].sort((a, b) => a.localeCompare(b)), + changedFiles, diffText: diffText.slice(0, MAX_DIFF_TEXT_CHARS) }; } catch (error) { - const rawDetail = error instanceof Error ? error.message.split(/\r?\n/)[0] : "unknown git error"; - const detail = truncateForDiagnostic(rawDetail ?? "unknown git error", DIAGNOSTIC_SPEC_LIMIT * 2); + const checkoutState = isMissingGit(error) ? void 0 : await describeGitCheckout(repoRoot); + const detail = truncateForDiagnostic(gitErrorDetail(error), DIAGNOSTIC_SPEC_LIMIT * 2); diagnostics.push({ code: "diff-unavailable", severity: "warning", - message: describesMissingRepository(error) ? `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${NOT_A_GIT_CHECKOUT}` : `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${detail}. Results use the task text only.` + message: checkoutState === "not-repository" ? `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${NOT_A_GIT_CHECKOUT}` : checkoutState === "no-history" ? `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${NO_GIT_HISTORY}` : `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${detail}. Results use the task text only.` }); return { changedFiles: [], diffText: "" }; } @@ -2223,23 +2433,42 @@ async function readWorkingTree(repoRoot, includeUntracked, diagnostics) { }); return { changedFiles, diffText: diffText.slice(0, MAX_DIFF_TEXT_CHARS) }; } catch (error) { - const rawDetail = error instanceof Error ? error.message.split(/\r?\n/)[0] : "unknown git error"; + const checkoutState = isMissingGit(error) ? void 0 : await describeGitCheckout(repoRoot); diagnostics.push({ code: "diff-unavailable", severity: "warning", - message: describesMissingRepository(error) ? `Could not read the working tree: ${NOT_A_GIT_CHECKOUT}` : `Could not read the working tree: ${truncateForDiagnostic(rawDetail ?? "unknown git error", DIAGNOSTIC_SPEC_LIMIT * 2)}. Results use the task text only.` + message: checkoutState === "not-repository" ? `Could not read the working tree: ${NOT_A_GIT_CHECKOUT}` : checkoutState === "no-history" ? `Could not read the working tree: ${NO_GIT_HISTORY}` : `Could not read the working tree: ${truncateForDiagnostic(gitErrorDetail(error), DIAGNOSTIC_SPEC_LIMIT * 2)}. Results use the task text only.` }); return { changedFiles: [], diffText: "" }; } } var NOT_A_GIT_CHECKOUT = "this directory is not a git checkout. Ranking still works from the task text; --diff, --base/--head and --working-tree need a repository with history."; -function describesMissingRepository(error) { +var NO_GIT_HISTORY = "this repository has no commits yet, so there is nothing to diff against. Commit the initial work first, or run with --issue alone to rank from the task text."; +async function describeGitCheckout(root) { + try { + const { stdout } = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }); + if (stdout.trim() !== "true") + return "not-repository"; + } catch { + return "not-repository"; + } + try { + await exec("git", ["rev-parse", "--verify", "HEAD"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }); + return void 0; + } catch { + return "no-history"; + } +} +function gitErrorDetail(error) { const candidate = error; - const text = [ - typeof candidate?.message === "string" ? candidate.message : "", - typeof candidate?.stderr === "string" ? candidate.stderr : "" - ].join("\n"); - return /not a git repository|does not have a commit checked out/i.test(text); + if (candidate?.code === "ENOENT") + return "Git is not installed or is not available on PATH"; + const stderr = typeof candidate?.stderr === "string" ? candidate.stderr : ""; + const message = typeof candidate?.message === "string" ? candidate.message : String(error); + return stderr.split(/\r?\n/).find((line) => line.trim()) ?? message.split(/\r?\n/)[0] ?? "unknown git error"; +} +function isMissingGit(error) { + return error?.code === "ENOENT"; } function detectPackageManager(files) { const paths = new Set(files.map((file) => file.path)); @@ -2263,16 +2492,16 @@ function classifyFile(path, extension) { } async function readTextSample(path, sizeBytes) { if (sizeBytes > MAX_TEXT_SAMPLE_BYTES) { - return { text: "", complete: false }; + return { text: "", complete: false, skipReason: "too-large" }; } try { const bytes = await readFile(path); if (bytes.includes(0)) { - return { text: "", complete: false }; + return { text: "", complete: false, skipReason: "not-text" }; } return { text: bytes.toString("utf8"), complete: true }; } catch { - return { text: "", complete: false }; + return { text: "", complete: false, skipReason: "unreadable" }; } } async function listUntrackedPaths(repoRoot) { @@ -2307,10 +2536,9 @@ async function buildFixMapReport(input) { const excludedPaths = repo.files.filter((file) => exclude.excludes(file.path)).map((file) => file.path); if (excludedPaths.length === 0) return report; - const rankablePaths = repo.files.filter((file) => file.isSource && !file.isTest); report.diagnostics.push({ code: "paths-excluded", - severity: rankablePaths.length > 0 && rankablePaths.every((file) => exclude.excludes(file.path)) ? "warning" : "info", + severity: report.contextFiles.length === 0 ? "warning" : "info", message: `${exclude.patterns.length} exclusion ${exclude.patterns.length === 1 ? "pattern" : "patterns"} removed ${excludedPaths.length} ${excludedPaths.length === 1 ? "path" : "paths"} from ranking: ${exclude.patterns.join(", ")}. Run --explain on a file you expected to see if this is why it is absent.` }); } @@ -2332,6 +2560,22 @@ async function readIgnoreFile(repoRoot) { function verifyPlan(report, repo) { const changed = repo.changedFiles; const findings = []; + const fileByPath = new Map(repo.files.map((file) => [file.path, file])); + const plannedPaths = report.contextFiles.map((file) => file.path); + if (plannedPaths.length > 0 && !plannedPaths.some((path) => fileByPath.has(path))) { + const mismatch = { + code: "plan-repository-mismatch", + severity: "error", + paths: plannedPaths.slice(0, 8), + message: `Verification was not attempted: none of the ${plannedPaths.length} planned files exist in ${repo.root}. This plan appears to be for a different repository or revision; check --repo or regenerate the plan against this checkout.` + }; + return { + summary: `None of the ${plannedPaths.length} planned files exist in ${repo.root}; the plan and repository do not match.`, + changedFiles: changed, + findings: [mismatch], + diagnostics: repo.diagnostics + }; + } if (changed.length === 0) { return { summary: "No changes to verify: the diff resolved to zero files.", @@ -2340,8 +2584,7 @@ function verifyPlan(report, repo) { diagnostics: repo.diagnostics }; } - const planned = new Set(report.contextFiles.map((file) => file.path)); - const fileByPath = new Map(repo.files.map((file) => [file.path, file])); + const planned = new Set(plannedPaths); const isTest = (path) => fileByPath.get(path)?.isTest === true; const maintainedStems = new Set(repo.files.filter((file) => file.isSource && !isGeneratedPath(file.path) && !isBackupPath(file.path)).map((file) => moduleStem(file.path))); const tracked = new Set(repo.trackedFiles ?? []); @@ -2363,7 +2606,7 @@ function verifyPlan(report, repo) { message: `${trackedGeneratedEdits.length === 1 ? "A committed generated artifact was" : `${trackedGeneratedEdits.length} committed generated artifacts were`} edited. Confirm the maintained source changed too and the artifact was rebuilt; tracked release artifacts are not treated as discarded edits.` }); } - const unmapped = changed.filter((path) => !planned.has(path) && !isTest(path) && !discardedEdits.includes(path) && fileByPath.get(path)?.isSource !== false); + const unmapped = changed.filter((path) => !planned.has(path) && !isTest(path) && !discardedEdits.includes(path) && !trackedGeneratedEdits.includes(path) && fileByPath.get(path)?.isSource !== false); if (unmapped.length > 0) { findings.push({ code: "unmapped-change", @@ -2381,7 +2624,7 @@ function verifyPlan(report, repo) { message: `The highest-ranked file was not changed (${leading.confidence} confidence). That is expected if it was only read for context, and worth a second look if it was not opened at all.` }); } - const changedSource = changed.filter((path) => !isTest(path) && fileByPath.get(path)?.kind === "code"); + const changedSource = changed.filter((path) => !isTest(path) && !trackedGeneratedEdits.includes(path) && !discardedEdits.includes(path) && fileByPath.get(path)?.kind === "code"); const changedTests = changed.filter(isTest); if (changedSource.length > 0 && changedTests.length === 0) { const suggested = [...new Set(report.testRoutes.flatMap((route) => route.relatedFiles))].filter(isTest); @@ -2398,7 +2641,7 @@ function verifyPlan(report, repo) { for (const risk of newRisks) { findings.push({ code: "new-risk-area", - severity: risk.severity === "low" ? "info" : "warning", + severity: "warning", paths: pathsForRiskArea(risk.area, changed), message: `The change touches ${risk.area}, which the original plan did not flag: ${risk.reason}.` }); @@ -2452,13 +2695,58 @@ function renderVerifyMarkdown(result) { `; } +// packages/core/dist/validate.js +function validateFixMapReport(candidate, label) { + if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate) || !Array.isArray(candidate.contextFiles)) { + return { + success: false, + message: `${label} is not a FixMap JSON report: no contextFiles array.` + }; + } + const contextFiles = candidate.contextFiles; + const record = candidate; + if (record.reportVersion !== void 0 && record.reportVersion !== 1) { + return { + success: false, + message: `${label} uses unsupported reportVersion ${JSON.stringify(record.reportVersion)}; this FixMap release supports reportVersion 1.` + }; + } + if (contextFiles.length === 0 && !(typeof record.summary === "string" && Array.isArray(record.testRoutes) && Array.isArray(record.risks) && Array.isArray(record.changedFiles) && Array.isArray(record.diagnostics))) { + return { + success: false, + message: `${label} has no context files and is missing the complete FixMap report envelope (summary, testRoutes, risks, changedFiles, and diagnostics).` + }; + } + const invalid = contextFiles.findIndex((file) => { + if (typeof file !== "object" || file === null) + return true; + const ranked = file; + if (typeof ranked.path !== "string" || ranked.path.trim().length === 0) + return true; + if (ranked.rank !== void 0 && (!Number.isSafeInteger(ranked.rank) || ranked.rank < 1)) + return true; + if (ranked.score !== void 0 && (typeof ranked.score !== "number" || !Number.isFinite(ranked.score))) + return true; + if (ranked.confidence !== void 0 && ranked.confidence !== "high" && ranked.confidence !== "medium" && ranked.confidence !== "low") + return true; + return false; + }); + if (invalid !== -1) { + return { + success: false, + message: `${label} has an invalid contextFiles entry at index ${invalid}; each entry needs a non-empty string "path", and optional rank, score, and confidence fields must use their documented types.` + }; + } + return { success: true, report: candidate }; +} + // packages/action/src/github.ts var FIXMAP_REPORT_MARKER = ""; var MAX_COMMENT_BODY_CHARS = 65536; var COMMENT_TRUNCATION_FOOTER = "\n\n> Report truncated to fit GitHub's comment size limit. The complete report is in the step summary and the `report` output.\n"; function fitCommentBody(body, limit = MAX_COMMENT_BODY_CHARS) { if (body.length <= limit) return body; - const keep = Math.max(0, limit - COMMENT_TRUNCATION_FOOTER.length); + const keep = Math.max(0, limit - COMMENT_TRUNCATION_FOOTER.length - "\n```".length); const cut = body.slice(0, keep); const lastBreak = cut.lastIndexOf("\n\n"); const trimmed = lastBreak > keep / 2 ? cut.slice(0, lastBreak) : cut; @@ -2510,25 +2798,26 @@ ${input.markdown}`); }; } async function findExistingComment(fetchImpl, commentsUrl, headers, commentAuthor) { - let newest; - for (let page = 1; ; page += 1) { + const maxPages = 50; + for (let page = 1; page <= maxPages; page += 1) { const comments = await requestJson( fetchImpl, - `${commentsUrl}?per_page=100&page=${page}`, + `${commentsUrl}?per_page=100&page=${page}&sort=created&direction=desc`, { headers }, "list pull request comments" ); - const matches = comments.filter( + const match = comments.filter( (comment) => comment.body?.includes(FIXMAP_REPORT_MARKER) && // GitHub logins are case-insensitive, so a config saying "github-actions[bot]" did // not match a comment authored by "GitHub-Actions[bot]" and the Action posted a // second comment beside the one it meant to update. (!commentAuthor || comment.user?.login?.toLowerCase() === commentAuthor.toLowerCase()) - ); - for (const existing of matches) if (!newest || existing.id > newest.id) newest = existing; + ).sort((left, right) => right.id - left.id)[0]; + if (match) return match; if (comments.length < 100) { - return newest; + return void 0; } } + return void 0; } function isPermissionDeniedError(error) { return error instanceof Error && /GitHub returned (401|403|404)\b/.test(error.message); @@ -2547,7 +2836,32 @@ async function requestJson(fetchImpl, url, init, action) { var MAX_API_RESPONSE_CHARS = 1e6; var MAX_ISSUE_BODY_CHARS = 2e4; function parseActionIssueSource(input) { - const trimmed = input.trim(); + let trimmed = input.trim(); + if (/^https?:\/\/[^/\s]*@(?:www\.|api\.)?github\.com\//i.test(trimmed)) { + throw new Error( + "The issue URL contains credentials. Remove the user:token@ prefix and pass the public https://github.com/owner/repository/issues/123 URL; the Action reads public issues anonymously." + ); + } + if (/^https?:\/\/(?:www\.|api\.)?github\.com\//i.test(trimmed)) { + const canonical = new URL(trimmed); + if (canonical.protocol !== "https:") { + throw new Error("GitHub issue input must use https://github.com/owner/repository/issues/123."); + } + if (/%(?:2f|5c|0[0-9a-f]|1[0-9a-f])/i.test(canonical.pathname)) { + throw new Error("GitHub issue URLs must not contain encoded separators or control characters."); + } + canonical.search = ""; + canonical.hash = ""; + if (canonical.hostname.toLowerCase() === "www.github.com") canonical.hostname = "github.com"; + if (canonical.hostname.toLowerCase() === "api.github.com") { + const apiSegments = canonical.pathname.split("/").filter(Boolean); + if (apiSegments.length === 5 && apiSegments[0]?.toLowerCase() === "repos" && apiSegments[3]?.toLowerCase() === "issues") { + canonical.hostname = "github.com"; + canonical.pathname = `/${apiSegments[1]}/${apiSegments[2]}/issues/${apiSegments[4]}`; + } + } + trimmed = canonical.toString(); + } if (/^https?:\/\/[^/\s]*@github\.com\//i.test(trimmed)) { throw new Error( "The issue URL contains credentials. Remove the user:token@ prefix and pass the public https://github.com/owner/repository/issues/123 URL; the Action reads public issues anonymously." @@ -2660,7 +2974,7 @@ async function runAction(env = process.env, dependencies = {}) { const planOnly = [ readInput("limit", env) ? "limit" : "", readInput("exclude", env) ? "exclude" : "", - rawIssue ? "issue" : "" + readInput("issue", env) ? "issue" : "" ].filter(Boolean); if (planOnly.length > 0) { throw new Error( @@ -2688,6 +3002,7 @@ async function runAction(env = process.env, dependencies = {}) { headRef, workingTree, includeUntracked, + useCache: true, limit, exclude }); @@ -2756,16 +3071,17 @@ async function runVerifyMode(context) { `FixMap could not read the plan at "${reportPath}": ${error instanceof Error ? error.message : String(error)}.` ); } - if (!Array.isArray(report.contextFiles)) { - throw new Error(`"${reportPath}" is not a FixMap JSON report: no contextFiles array.`); - } + const loaded = validateFixMapReport(report, `"${reportPath}"`); + if (!loaded.success) throw new Error(loaded.message); + report = loaded.report; const repo = await (context.dependencies.scanRepo ?? scanRepo)({ repoRoot: (context.dependencies.cwd ?? process.cwd)(), diffSpec: context.diffSpec, baseRef: context.baseRef, headRef: context.headRef, workingTree: context.workingTree, - includeUntracked: context.includeUntracked + includeUntracked: context.includeUntracked, + useCache: true }); const diffFailure = repo.diagnostics.find((diagnostic) => diagnostic.code === "diff-unavailable"); if (diffFailure) { @@ -2891,7 +3207,7 @@ function fitStepSummary(markdown, limitBytes = STEP_SUMMARY_LIMIT_BYTES) { if (footer.length >= limitBytes) { throw new Error("GitHub step-summary limit is too small for the FixMap truncation notice."); } - let end = limitBytes - footer.length; + let end = limitBytes - footer.length - Buffer.byteLength("\n```"); while (end > 0 && (bytes[end] & 192) === 128) { end -= 1; } diff --git a/packages/action/package.json b/packages/action/package.json index 91f047f..3be212b 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -1,6 +1,6 @@ { "name": "@fixmap/action", - "version": "0.8.7", + "version": "0.8.8", "description": "GitHub Action wrapper for FixMap pull request reports.", "private": true, "license": "MIT", @@ -11,6 +11,6 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@aryam/fixmap-core": "0.8.7" + "@aryam/fixmap-core": "0.8.8" } } diff --git a/packages/action/src/github.ts b/packages/action/src/github.ts index 3f7259b..d86f5bb 100644 --- a/packages/action/src/github.ts +++ b/packages/action/src/github.ts @@ -16,7 +16,8 @@ const COMMENT_TRUNCATION_FOOTER = export function fitCommentBody(body: string, limit = MAX_COMMENT_BODY_CHARS): string { if (body.length <= limit) return body; - const keep = Math.max(0, limit - COMMENT_TRUNCATION_FOOTER.length); + // Closing an open fence adds four characters, so reserve them before the cut. + const keep = Math.max(0, limit - COMMENT_TRUNCATION_FOOTER.length - "\n```".length); const cut = body.slice(0, keep); // Cutting mid-fence leaves an unterminated block that swallows the footer explaining the // truncation, so fall back to the last paragraph break and close any fence left open. @@ -108,28 +109,29 @@ async function findExistingComment( headers: Record, commentAuthor: string | undefined ): Promise { - let newest: GitHubComment | undefined; - for (let page = 1; ; page += 1) { + const maxPages = 50; + for (let page = 1; page <= maxPages; page += 1) { const comments = await requestJson( fetchImpl, - `${commentsUrl}?per_page=100&page=${page}`, + `${commentsUrl}?per_page=100&page=${page}&sort=created&direction=desc`, { headers }, "list pull request comments" ); - const matches = comments.filter( + const match = comments.filter( (comment) => comment.body?.includes(FIXMAP_REPORT_MARKER) && // GitHub logins are case-insensitive, so a config saying "github-actions[bot]" did // not match a comment authored by "GitHub-Actions[bot]" and the Action posted a // second comment beside the one it meant to update. (!commentAuthor || comment.user?.login?.toLowerCase() === commentAuthor.toLowerCase()) - ); - for (const existing of matches) if (!newest || existing.id > newest.id) newest = existing; + ).sort((left, right) => right.id - left.id)[0]; + if (match) return match; if (comments.length < 100) { - return newest; + return undefined; } } + return undefined; } export function isPermissionDeniedError(error: unknown): boolean { diff --git a/packages/action/src/issue-source.ts b/packages/action/src/issue-source.ts index edf13d2..0a5e9e4 100644 --- a/packages/action/src/issue-source.ts +++ b/packages/action/src/issue-source.ts @@ -13,7 +13,37 @@ const MAX_API_RESPONSE_CHARS = 1_000_000; const MAX_ISSUE_BODY_CHARS = 20_000; export function parseActionIssueSource(input: string): ActionIssueSource | undefined { - const trimmed = input.trim(); + let trimmed = input.trim(); + if (/^https?:\/\/[^/\s]*@(?:www\.|api\.)?github\.com\//i.test(trimmed)) { + throw new Error( + "The issue URL contains credentials. Remove the user:token@ prefix and pass the public " + + "https://github.com/owner/repository/issues/123 URL; the Action reads public issues anonymously." + ); + } + if (/^https?:\/\/(?:www\.|api\.)?github\.com\//i.test(trimmed)) { + const canonical = new URL(trimmed); + if (canonical.protocol !== "https:") { + throw new Error("GitHub issue input must use https://github.com/owner/repository/issues/123."); + } + if (/%(?:2f|5c|0[0-9a-f]|1[0-9a-f])/i.test(canonical.pathname)) { + throw new Error("GitHub issue URLs must not contain encoded separators or control characters."); + } + canonical.search = ""; + canonical.hash = ""; + if (canonical.hostname.toLowerCase() === "www.github.com") canonical.hostname = "github.com"; + if (canonical.hostname.toLowerCase() === "api.github.com") { + const apiSegments = canonical.pathname.split("/").filter(Boolean); + if ( + apiSegments.length === 5 && + apiSegments[0]?.toLowerCase() === "repos" && + apiSegments[3]?.toLowerCase() === "issues" + ) { + canonical.hostname = "github.com"; + canonical.pathname = `/${apiSegments[1]}/${apiSegments[2]}/issues/${apiSegments[4]}`; + } + } + trimmed = canonical.toString(); + } // A credentialed URL failed the bare `^https://github.com/` test and fell through as prose, // so a token pasted into the input was ranked as task text and echoed into the step summary // and the pull request comment. Anything addressing github.com is claimed here and refused diff --git a/packages/action/src/runner.ts b/packages/action/src/runner.ts index 1c5e56c..2c00af8 100644 --- a/packages/action/src/runner.ts +++ b/packages/action/src/runner.ts @@ -6,6 +6,7 @@ import { renderMarkdownReport, renderVerifyMarkdown, scanRepo, + validateFixMapReport, verifyPlan, type FixMapReport, type VerifyResult @@ -70,7 +71,7 @@ export async function runAction( const planOnly = [ readInput("limit", env) ? "limit" : "", readInput("exclude", env) ? "exclude" : "", - rawIssue ? "issue" : "" + readInput("issue", env) ? "issue" : "" ].filter(Boolean); if (planOnly.length > 0) { throw new Error( @@ -108,6 +109,7 @@ export async function runAction( headRef, workingTree, includeUntracked, + useCache: true, limit, exclude }); @@ -199,9 +201,9 @@ async function runVerifyMode(context: VerifyModeContext): Promise { `FixMap could not read the plan at "${reportPath}": ${error instanceof Error ? error.message : String(error)}.` ); } - if (!Array.isArray(report.contextFiles)) { - throw new Error(`"${reportPath}" is not a FixMap JSON report: no contextFiles array.`); - } + const loaded = validateFixMapReport(report, `"${reportPath}"`); + if (!loaded.success) throw new Error(loaded.message); + report = loaded.report; const repo = await (context.dependencies.scanRepo ?? scanRepo)({ repoRoot: (context.dependencies.cwd ?? process.cwd)(), @@ -209,7 +211,8 @@ async function runVerifyMode(context: VerifyModeContext): Promise { baseRef: context.baseRef, headRef: context.headRef, workingTree: context.workingTree, - includeUntracked: context.includeUntracked + includeUntracked: context.includeUntracked, + useCache: true }); const diffFailure = repo.diagnostics.find((diagnostic) => diagnostic.code === "diff-unavailable"); if (diffFailure) { @@ -353,7 +356,8 @@ export function fitStepSummary(markdown: string, limitBytes = STEP_SUMMARY_LIMIT throw new Error("GitHub step-summary limit is too small for the FixMap truncation notice."); } - let end = limitBytes - footer.length; + // trimToBoundary may append "\n```". Reserve those four bytes before cutting. + let end = limitBytes - footer.length - Buffer.byteLength("\n```"); while (end > 0 && (bytes[end]! & 0xc0) === 0x80) { end -= 1; } diff --git a/packages/action/test/github.test.ts b/packages/action/test/github.test.ts index d383c17..8800131 100644 --- a/packages/action/test/github.test.ts +++ b/packages/action/test/github.test.ts @@ -112,6 +112,35 @@ describe("GitHub Action helpers", () => { expect(calls.some((url) => url.includes("page=11"))).toBe(true); }); + it("stops after fifty pages when a proxy repeats full pages", async () => { + const calls: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input); + calls.push(url); + if (url.includes("/comments?")) { + return jsonResponse(Array.from({ length: 100 }, (_, index) => ({ + id: index, + body: "ordinary comment", + user: { login: "contributor" } + }))); + } + expect(init?.method).toBe("POST"); + return jsonResponse({ id: 9001 }, 201); + }; + + const result = await createGitHubClient({ fetchImpl }).upsertPullRequestComment({ + token: "test-token", + owner: "octo", + repo: "demo", + issueNumber: 42, + markdown: "# FixMap Report" + }); + + expect(result).toBe("created"); + expect(calls.filter((url) => url.includes("/comments?")).length).toBe(50); + expect(calls.some((url) => url.includes("page=51"))).toBe(false); + }); + it("reports a useful error when GitHub rejects comment lookup", async () => { const fetchImpl: typeof fetch = async () => new Response("Bad credentials", { status: 401, statusText: "Unauthorized" }); diff --git a/packages/action/test/issue-source.test.ts b/packages/action/test/issue-source.test.ts new file mode 100644 index 0000000..2920a40 --- /dev/null +++ b/packages/action/test/issue-source.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { parseActionIssueSource } from "../src/issue-source.js"; + +describe("Action GitHub issue URL normalization", () => { + it.each([ + "https://www.github.com/Owner/Repository/issues/123?utm_source=test#note", + "https://api.github.com/repos/Owner/Repository/issues/123" + ])("normalizes %s to the public canonical issue", (input) => { + expect(parseActionIssueSource(input)).toEqual(expect.objectContaining({ + owner: "Owner", + repository: "Repository", + number: 123, + displayUrl: "https://github.com/Owner/Repository/issues/123" + })); + }); + + it("still rejects insecure, credentialed, and encoded-separator variants", () => { + expect(() => parseActionIssueSource("http://www.github.com/o/r/issues/1")).toThrow("must use https"); + expect(() => parseActionIssueSource("https://token@api.github.com/repos/o/r/issues/1")).toThrow("credentials"); + expect(() => parseActionIssueSource("https://www.github.com/o%2fr/issues/1")).toThrow("encoded separators"); + }); +}); diff --git a/packages/action/test/runner.test.ts b/packages/action/test/runner.test.ts index f3214a2..8488c51 100644 --- a/packages/action/test/runner.test.ts +++ b/packages/action/test/runner.test.ts @@ -132,6 +132,23 @@ describe("GitHub Action runner", () => { expect(writes[0]?.contents).toContain("changed-file-count=1"); }); + it("does not mistake pull-request event context for an explicit verify issue input", async () => { + const stdout = vi.fn(); + await expect(runAction({ + INPUT_MODE: "verify", + INPUT_REPORT_PATH: "plan.json", + INPUT_DIFF: "main...HEAD", + GITHUB_EVENT_PATH: "event.json" + }, { + readFile: (path) => path === "event.json" + ? JSON.stringify({ pull_request: { number: 7, title: "Fix password reset" } }) + : JSON.stringify(report), + scanRepo: async () => scannedRepo(["src/auth.ts"]), + stdout + })).resolves.toBeUndefined(); + expect(stdout.mock.calls[0]?.[0]).toContain("# FixMap Verification"); + }); + it("says what verify mode needs when report-path is missing", async () => { await expect(runAction({ INPUT_MODE: "verify", INPUT_DIFF: "main...HEAD" }, { stdout: vi.fn() })) .rejects.toThrow("report-path"); @@ -193,6 +210,13 @@ describe("Action input and output guards", () => { expect(body).toContain("truncated to fit GitHub's comment size limit"); }); + it("reserves room for a closing Markdown fence inside the comment limit", () => { + const body = fitCommentBody(`\`\`\`json\n${"x".repeat(500)}`, 180); + + expect(body.length).toBeLessThanOrEqual(180); + expect((body.match(/^```/gm) ?? []).length % 2).toBe(0); + }); + it("leaves a comment that already fits completely alone", () => { const body = `${FIXMAP_REPORT_MARKER}\n# FixMap Report\n`; expect(fitCommentBody(body)).toBe(body); diff --git a/packages/cli/README.md b/packages/cli/README.md index 6741c3e..f25b61b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -41,7 +41,7 @@ fixmap plan --base main --head HEAD --format json --output fixmap-report.json Public GitHub issue, pull request, and repository URL modes are available in the CLI and MCP server for issue-only analysis. FixMap fetches task context anonymously, shallow-clones the default branch into an isolated temporary directory, disables credentials and repository execution surfaces, and removes the checkout before returning. Clone locally to use `--diff`, `--base`, `--head`, or working-tree inputs. -For long task text, use `--issue-file task.md`, `--issue @task.md`, or pipe text to `--issue -`. A one-off `npx -y @aryam/fixmap@latest ...` run is also available, but npm may choose an existing project-local FixMap first. Run `fixmap doctor`, treat its printed running version as authoritative, and update or remove a stale install. For a reproducible clean test, install the exact version into an isolated npm prefix and invoke that prefix's `fixmap` shim directly; the repository README includes complete PowerShell and POSIX commands. +For long task text, use `--issue-file task.md` or pipe text to `--issue -`. A leading `@` in `--issue` is ordinary task text; only the explicit file flag reads from disk. A one-off `npx -y @aryam/fixmap@latest ...` run is also available, but npm may choose an existing project-local FixMap first. Run `fixmap doctor`, treat its printed running version as authoritative, and update or remove a stale install. For a reproducible clean test, install the exact version into an isolated npm prefix and invoke that prefix's `fixmap` shim directly; the repository README includes complete PowerShell and POSIX commands. ## MCP server diff --git a/packages/cli/package.json b/packages/cli/package.json index 6b3d475..0f7936c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aryam/fixmap", - "version": "0.8.7", + "version": "0.8.8", "mcpName": "io.github.aryamthecodebreaker/fixmap", "description": "Local-first CLI and MCP server mapping GitHub issue URLs, tasks, and diffs to ranked files, tests, and risks.", "license": "MIT", @@ -46,7 +46,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@aryam/fixmap-core": "0.8.7", + "@aryam/fixmap-core": "0.8.8", "@modelcontextprotocol/sdk": "1.30.0" }, "engines": { diff --git a/packages/cli/src/cli-runner.ts b/packages/cli/src/cli-runner.ts index f1ff0bf..02a347d 100644 --- a/packages/cli/src/cli-runner.ts +++ b/packages/cli/src/cli-runner.ts @@ -13,6 +13,7 @@ import { verifyPlan, renderMarkdownReport, scanRepo, + validateFixMapReport, type FixMapReport } from "@aryam/fixmap-core"; import { runDoctorChecks, renderDoctorReport, type DoctorReport } from "./doctor.js"; @@ -41,6 +42,7 @@ export type CliOptions = { exclude: string[]; workingTree: boolean; includeUntracked: boolean; + noCache: boolean; unknownArgs: string[]; invalidValues: string[]; }; @@ -69,10 +71,11 @@ Usage: fixmap plan --issue "Fix login" --repo https://github.com/owner/repository fixmap plan --diff main...HEAD fixmap plan --working-tree --include-untracked --limit 12 --exclude "docs/**" - fixmap plan --issue "Fix login" --format json --output current.json --compare previous.json + fixmap plan --issue "Fix login" --format json --output plan.json + fixmap plan --issue "Fix login in auth middleware" --compare plan.json fixmap plan --base main --head HEAD --format json - fixmap verify --report fixmap-report.json --diff main...HEAD - fixmap verify --report fixmap-report.json --working-tree + fixmap verify --report plan.json --diff main...HEAD + fixmap verify --report plan.json --working-tree fixmap doctor --format json fixmap mcp @@ -90,6 +93,7 @@ Options: --head Head ref for diffing (defaults to HEAD) --working-tree Map staged and unstaged changes against HEAD --include-untracked With --working-tree, also include untracked files + --no-cache Bypass the exact git-state repository scan cache --repo Local path or public GitHub HTTPS URL (defaults to current directory) --limit Maximum context files to report (default 8, max 20) --exclude Path pattern to leave out of ranking (repeatable) @@ -192,6 +196,10 @@ export async function runCli(args: string[], dependencies: CliDependencies = {}) writeReport: dependencies.writeReport ?? ((path, contents) => writeFile(path, contents, "utf8")) }); } + if (options.reportPath) { + stderr(withUsageHint("--report is a verify option. Did you mean --output to write this plan to a file?")); + return 1; + } try { options.issueText = loadIssueText(options, dependencies.readIssueFile ?? defaultReadIssueFile); @@ -246,11 +254,12 @@ export async function runCli(args: string[], dependencies: CliDependencies = {}) } try { const repoRoot = options.repo ?? process.cwd(); - const repo = await scanRepo({ repoRoot, diffSpec: options.diffSpec, baseRef: options.baseRef, headRef: options.headRef, workingTree: options.workingTree, includeUntracked: options.includeUntracked }); + const repo = await scanRepo({ repoRoot, diffSpec: options.diffSpec, baseRef: options.baseRef, headRef: options.headRef, workingTree: options.workingTree, includeUntracked: options.includeUntracked, useCache: !options.noCache }); const explanation = explainFile( repo, { issueText: options.issueText, + diffText: repo.diffText, // Without this, a file left out by .fixmapignore would be reported as having // scored below the cutoff — a false answer to the exact question --explain exists // to answer. @@ -281,6 +290,7 @@ export async function runCli(args: string[], dependencies: CliDependencies = {}) headRef: options.headRef, workingTree: options.workingTree, includeUntracked: options.includeUntracked, + useCache: !options.noCache, limit: options.limit, exclude: options.exclude }); @@ -337,7 +347,7 @@ export async function runCli(args: string[], dependencies: CliDependencies = {}) } catch (error) { stderr( `Could not read comparison file "${options.comparePath}": ${error instanceof Error ? error.message : String(error)}\n` + - "Save one first with: fixmap plan --issue \"...\" --format json --output previous.json\n" + "Save one first with: fixmap plan --issue \"...\" --format json --output plan.json\n" ); return 1; } @@ -355,8 +365,14 @@ export async function runCli(args: string[], dependencies: CliDependencies = {}) stderr(`"${options.comparePath}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}\n`); return 1; } - if (!Array.isArray(previous.contextFiles)) { - stderr(`"${options.comparePath}" is valid JSON but not a FixMap report: no contextFiles array.\n`); + const loaded = validateFixMapReport(previous, `"${options.comparePath}"`); + if (!loaded.success) { + stderr(`${loaded.message}\n`); + return 1; + } + previous = loaded.report; + if (unresolvedChangeRequest) { + stderr("The current plan lost its requested diff signal, so it was not compared with the saved plan. Fix the ref and rerun.\n"); return 1; } @@ -383,7 +399,7 @@ export async function runCli(args: string[], dependencies: CliDependencies = {}) stderr(moved === 0 ? "\nComparison complete. Nothing entered, left, moved, or changed confidence — that task edit did not affect the ranking. Try naming a symbol, error string, or path from the file you expect.\n" : "\nComparison complete. Refine the task or inspect the files that entered, moved, or changed confidence, then rerun the plan.\n"); - return 0; + return unresolvedChangeRequest ? 1 : 0; } const rendered = options.format === "json" ? renderJsonReport(report) : renderMarkdownReport(report); @@ -541,6 +557,7 @@ export function parseArgs(args: string[]): CliOptions { let limit: number | undefined; let workingTree = false; let includeUntracked = false; + let noCache = false; const exclude: string[] = []; const unknownArgs: string[] = []; const invalidValues: string[] = []; @@ -649,6 +666,10 @@ export function parseArgs(args: string[]): CliOptions { if (flagCounts.has(arg)) invalidValues.push(`pass ${arg} only once`); flagCounts.set(arg, 1); includeUntracked = true; + } else if (arg === "--no-cache") { + if (flagCounts.has(arg)) invalidValues.push(`pass ${arg} only once`); + flagCounts.set(arg, 1); + noCache = true; } else if (arg === "--output") { consumeValue(); if (value?.trim()) output = value; @@ -675,6 +696,7 @@ export function parseArgs(args: string[]): CliOptions { exclude, workingTree, includeUntracked, + noCache, unknownArgs, invalidValues }; @@ -684,8 +706,7 @@ function loadIssueText( options: CliOptions, read: (path: string | number) => string | Buffer ): string { - const implicitFile = options.issueText.startsWith("@") ? options.issueText.slice(1) : undefined; - const path = options.issueFile ?? implicitFile ?? (options.issueText === "-" ? "-" : undefined); + const path = options.issueFile ?? (options.issueText === "-" ? "-" : undefined); if (!path) { return options.issueText.trim(); } @@ -791,7 +812,7 @@ async function runVerify( } catch (error) { io.stderr( `Could not read "${options.reportPath}": ${error instanceof Error ? error.message : String(error)}\n` + - "Generate one with: fixmap plan --issue \"...\" --format json --output fixmap-report.json\n" + "Generate one with: fixmap plan --issue \"...\" --format json --output plan.json\n" ); return 1; } @@ -801,7 +822,7 @@ async function runVerify( if (/^\s*#\s*FixMap/i.test(reportText)) { io.stderr( `"${options.reportPath}" is a Markdown report. verify --report requires the JSON plan saved with --format json.\n` + - "Generate one with: fixmap plan --issue \"...\" --format json --output fixmap-report.json\n" + "Generate one with: fixmap plan --issue \"...\" --format json --output plan.json\n" ); return 1; } @@ -812,7 +833,7 @@ async function runVerify( } catch (error) { io.stderr( `"${options.reportPath}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}\n` + - "Generate one with: fixmap plan --issue \"...\" --format json --output fixmap-report.json\n" + "Generate one with: fixmap plan --issue \"...\" --format json --output plan.json\n" ); return 1; } @@ -827,6 +848,12 @@ async function runVerify( io.stderr(`"${options.reportPath}" is not a FixMap JSON report: no contextFiles array.\n`); return 1; } + const loaded = validateFixMapReport(report, `"${options.reportPath}"`); + if (!loaded.success) { + io.stderr(`${loaded.message}\n`); + return 1; + } + report = loaded.report; try { const repo = await scanRepo({ @@ -835,7 +862,8 @@ async function runVerify( baseRef: options.baseRef, headRef: options.headRef, workingTree: options.workingTree, - includeUntracked: options.includeUntracked + includeUntracked: options.includeUntracked, + useCache: !options.noCache }); const unresolvedDiff = repo.diagnostics.find((diagnostic) => diagnostic.code === "diff-unavailable"); if (unresolvedDiff) { diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts index f465caa..ff985d8 100644 --- a/packages/cli/src/mcp.ts +++ b/packages/cli/src/mcp.ts @@ -14,6 +14,7 @@ import { renderVerifyMarkdown, resolveExclusions, scanRepo, + validateFixMapReport, verifyPlan, type FixMapReport } from "@aryam/fixmap-core"; @@ -81,8 +82,7 @@ const PLAN_TOOL = { // that no longer exists — and a stale calibration number is worse than none, because an // agent weights its confidence by it. Point at the published evidence instead, which is // regenerated from the recorded results on every release. - "analysis.nextAction carries the single most useful next step for this report — the same " + - "guidance the CLI prints to stderr, which an MCP client never sees. Read it before acting. " + + "analysis.nextAction carries the single most useful next step for this report. Read it before acting. " + "Treat the result as a starting map, not proof the task is valid: check the analysis " + "block before editing, and when it reports unresolved or unverified identifiers, vague " + "task grounding, an incomplete scan, or a clustered ranking, widen the search or ask for " + @@ -291,7 +291,8 @@ export function createFixMapMcpServer( baseRef: args.base, headRef: args.head, workingTree: args.workingTree, - includeUntracked: args.includeUntracked + includeUntracked: args.includeUntracked, + useCache: true }); const diffFailure = repo.diagnostics.find((diagnostic) => diagnostic.code === "diff-unavailable"); if (diffFailure) { @@ -335,7 +336,8 @@ export function createFixMapMcpServer( baseRef: args.base, headRef: args.head, workingTree: args.workingTree, - includeUntracked: args.includeUntracked + includeUntracked: args.includeUntracked, + useCache: true }); const explanation = explainFile( repo, @@ -391,6 +393,7 @@ export function createFixMapMcpServer( headRef: args.head, workingTree: args.workingTree, includeUntracked: args.includeUntracked, + useCache: true, limit: args.limit, exclude: args.exclude }, repositorySourceDependencies); @@ -658,63 +661,7 @@ function loadReportInput(input: unknown, label: string): LoadedReport { } function asFixMapReport(candidate: unknown, label: string): LoadedReport { - if ( - typeof candidate !== "object" || - candidate === null || - Array.isArray(candidate) || - !Array.isArray((candidate as Partial).contextFiles) - ) { - return { - success: false, - message: `${label} must be a FixMap JSON report with a contextFiles array, or a path to one.` - }; - } - - // A contextFiles-only empty object is indistinguishable from a truncated report. A real - // empty plan carries the full report envelope; trimmed non-empty reports remain useful as - // long as every field the comparison reads has the documented type. - const contextFiles = (candidate as FixMapReport).contextFiles; - const record = candidate as Record; - if ( - contextFiles.length === 0 && - !( - typeof record.summary === "string" && - Array.isArray(record.testRoutes) && - Array.isArray(record.risks) && - Array.isArray(record.changedFiles) && - Array.isArray(record.diagnostics) - ) - ) { - return { - success: false, - message: - `${label} has no context files and is missing the complete FixMap report envelope ` + - '(summary, testRoutes, risks, changedFiles, and diagnostics).' - }; - } - - const invalid = contextFiles.findIndex((file) => { - if (typeof file !== "object" || file === null) return true; - const ranked = file as Record; - if (typeof ranked.path !== "string" || ranked.path.trim().length === 0) return true; - if (ranked.rank !== undefined && (!Number.isSafeInteger(ranked.rank) || (ranked.rank as number) < 1)) return true; - if (ranked.score !== undefined && (typeof ranked.score !== "number" || !Number.isFinite(ranked.score))) return true; - if ( - ranked.confidence !== undefined && - ranked.confidence !== "high" && ranked.confidence !== "medium" && ranked.confidence !== "low" - ) return true; - return false; - }); - if (invalid !== -1) { - return { - success: false, - message: - `${label} has an invalid contextFiles entry at index ${invalid}; each entry needs a non-empty string "path", ` + - 'and optional rank, score, and confidence fields must use their documented types.' - }; - } - - return { success: true, report: candidate as FixMapReport }; + return validateFixMapReport(candidate, label); } export async function runMcpServer(): Promise { diff --git a/packages/cli/src/repository-source.ts b/packages/cli/src/repository-source.ts index 2d51bd5..277d751 100644 --- a/packages/cli/src/repository-source.ts +++ b/packages/cli/src/repository-source.ts @@ -28,6 +28,7 @@ export type RepositoryPlanInput = { headRef?: string | undefined; workingTree?: boolean | undefined; includeUntracked?: boolean | undefined; + useCache?: boolean | undefined; limit?: number | undefined; exclude?: string[] | undefined; }; @@ -536,6 +537,7 @@ export async function buildReportForRepository( headRef: input.headRef, workingTree: input.workingTree, includeUntracked: input.includeUntracked, + useCache: input.useCache, limit: input.limit, exclude: input.exclude }); diff --git a/packages/cli/test/cli-runner.test.ts b/packages/cli/test/cli-runner.test.ts index f233207..c79d0e5 100644 --- a/packages/cli/test/cli-runner.test.ts +++ b/packages/cli/test/cli-runner.test.ts @@ -7,7 +7,7 @@ import type { FixMapReport } from "@aryam/fixmap-core"; const report: FixMapReport = { summary: "Found one context file.", - contextFiles: [{ rank: 1, path: "src/index.ts", score: 10, confidence: "medium", reasons: ["path matches task terms"] }], + contextFiles: [{ rank: 1, path: "README.md", score: 10, confidence: "medium", reasons: ["path matches task terms"] }], testRoutes: [], risks: [], changedFiles: [], @@ -140,6 +140,22 @@ describe("CLI argument handling", () => { })); }); + it("treats a leading @ as literal issue text unless --issue-file is explicit", async () => { + const io = capture(); + const buildReport = vi.fn(async () => report); + const readIssueFile = vi.fn(() => "wrong task"); + + expect(await runCli(["plan", "--issue", "@amy fix the reset flow"], { + ...io.dependencies, + buildReport, + readIssueFile + })).toBe(0); + expect(readIssueFile).not.toHaveBeenCalled(); + expect(buildReport).toHaveBeenCalledWith(expect.objectContaining({ + issueText: "@amy fix the reset flow" + })); + }); + it.each([ ["UTF-8 BOM", Buffer.from([0xef, 0xbb, 0xbf, ...Buffer.from("password reset")])], ["UTF-16 LE", Buffer.from([0xff, 0xfe, ...Buffer.from("password reset", "utf16le")])], @@ -177,6 +193,30 @@ describe("CLI argument handling", () => { expect(io.stderr.join("")).toContain("fixmap verify --report report.json"); }); + it("rejects --report in plan mode and points to --output", async () => { + const io = capture(); + const buildReport = vi.fn(async () => report); + + expect(await runCli(["plan", "--issue", "reset fails", "--report", "plan.json"], { + ...io.dependencies, + buildReport + })).toBe(1); + expect(buildReport).not.toHaveBeenCalled(); + expect(io.stderr.join("")).toContain("--report is a verify option"); + expect(io.stderr.join("")).toContain("--output"); + }); + + it("passes --no-cache through as an explicit scan bypass", async () => { + const io = capture(); + const buildReport = vi.fn(async () => report); + + expect(await runCli(["plan", "--issue", "reset fails", "--no-cache"], { + ...io.dependencies, + buildReport + })).toBe(0); + expect(buildReport).toHaveBeenCalledWith(expect.objectContaining({ useCache: false })); + }); + it.each([ ["--repo", ["plan", "--issue", "x", "--repo", ".", "--repo", "examples/tiny-auth-app"]], ["--format", ["plan", "--issue", "x", "--format", "markdown", "--format", "json"]], @@ -376,6 +416,24 @@ describe("CLI argument handling", () => { expect(io.stderr.join("")).toContain("no contextFiles array"); }); + it("does not compare when the requested diff failed to resolve", async () => { + const io = capture(); + const directory = await mkdtemp(join(tmpdir(), "fixmap-compare-diff-failure-")); + const previousPath = join(directory, "plan.json"); + await writeFile(previousPath, JSON.stringify(report), "utf8"); + const failed = structuredClone(report); + failed.diagnostics = [{ code: "diff-unavailable", severity: "warning", message: "missing ref" }]; + + expect(await runCli([ + "plan", "--issue", "reset fails", "--diff", "missing...HEAD", "--compare", previousPath + ], { + ...io.dependencies, + buildReport: vi.fn(async () => failed) + })).toBe(1); + expect(io.stdout).toEqual([]); + expect(io.stderr.join("")).toContain("was not compared"); + }); + it("reports a healthy install and exits zero", async () => { const io = capture(); diff --git a/packages/core/package.json b/packages/core/package.json index 9fd2c60..f818bc8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aryam/fixmap-core", - "version": "0.8.7", + "version": "0.8.8", "description": "Deterministic local-first repository scanner, context ranker, and report renderer for coding agents.", "license": "MIT", "repository": { diff --git a/packages/core/src/explain.ts b/packages/core/src/explain.ts index 28f16fc..50b26c8 100644 --- a/packages/core/src/explain.ts +++ b/packages/core/src/explain.ts @@ -153,6 +153,15 @@ function describeExclusion( "That is usually a sparse or partial checkout — widen the cone to rank it — and otherwise a deletion." }; } + const parentSubmodule = repo.trackedFiles?.find((tracked) => path.startsWith(`${tracked}/`)); + if (parentSubmodule) { + return { + status: "not-scanned", + summary: + `Not scanned: this path is inside the submodule ${parentSubmodule}, which is a separate repository. ` + + `Run FixMap with --repo ${parentSubmodule} to map it.` + }; + } return { status: "not-scanned", summary: "Not scanned: no such path in this repository, or it is ignored by .gitignore." diff --git a/packages/core/src/grounding.ts b/packages/core/src/grounding.ts index a4adfc1..35f963e 100644 --- a/packages/core/src/grounding.ts +++ b/packages/core/src/grounding.ts @@ -136,7 +136,7 @@ export function buildNextAction( return "Verify or correct the unresolved identifiers before editing ranked files."; } if (grounding.unverifiedIdentifiers.length > 0) { - return "Narrow the repository or inspect large unread files before trusting identifier-based recommendations."; + return "Inspect the content-unread diagnostics and make those source files readable before trusting identifier-based recommendations."; } if (grounding.partiallyResolvedIdentifiers.length > 0) { return "Verify the partially matched symbol name in the leading file before editing."; @@ -151,9 +151,12 @@ export function buildNextAction( return "Treat the leading files as a subsystem neighborhood and verify the exact edit point before changing code."; } if (contextFiles[0]) { + const leading = contextFiles.find((file) => + !file.reasons.includes("generated build artifact; maintained source counterpart exists") + ) ?? contextFiles[0]; return hasRoutedTests - ? `Inspect ${contextFiles[0].path} and its routed tests before editing.` - : `Inspect ${contextFiles[0].path} before editing; no related test file was routed.`; + ? `Inspect ${leading.path} and its routed tests before editing.` + : `Inspect ${leading.path} before editing; no related test file was routed.`; } return "Add a concrete repository anchor and rerun FixMap."; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db673dd..e74f184 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,8 @@ export type { LanguageDetection, PrimaryLanguage } from "./languages.js"; export { rankContextFiles } from "./rank.js"; export { buildRiskNotes, buildSummary, buildTestRoutes, pathsForRiskArea, renderJsonReport, renderMarkdownReport } from "./report.js"; export { scanRepo } from "./repo-scan.js"; +export { validateFixMapReport } from "./validate.js"; +export type { ValidatedFixMapReport } from "./validate.js"; export { findGatedTestDiagnostics } from "./test-gates.js"; export type { FixMapInput, diff --git a/packages/core/src/plan.ts b/packages/core/src/plan.ts index 16a4302..15fe245 100644 --- a/packages/core/src/plan.ts +++ b/packages/core/src/plan.ts @@ -9,7 +9,7 @@ import type { FixMapInput, FixMapReport } from "./types.js"; export async function buildFixMapReport( input: Pick< FixMapInput, - "repoRoot" | "issueText" | "diffSpec" | "baseRef" | "headRef" | "workingTree" | "includeUntracked" + "repoRoot" | "issueText" | "diffSpec" | "baseRef" | "headRef" | "workingTree" | "includeUntracked" | "useCache" > & { limit?: number | undefined; exclude?: string[] | undefined } ): Promise { const repo = await scanRepo(input); @@ -24,10 +24,9 @@ export async function buildFixMapReport( if (exclude.patterns.length > 0) { const excludedPaths = repo.files.filter((file) => exclude.excludes(file.path)).map((file) => file.path); if (excludedPaths.length === 0) return report; - const rankablePaths = repo.files.filter((file) => file.isSource && !file.isTest); report.diagnostics.push({ code: "paths-excluded", - severity: rankablePaths.length > 0 && rankablePaths.every((file) => exclude.excludes(file.path)) ? "warning" : "info", + severity: report.contextFiles.length === 0 ? "warning" : "info", message: `${exclude.patterns.length} exclusion ${exclude.patterns.length === 1 ? "pattern" : "patterns"} ` + `removed ${excludedPaths.length} ${excludedPaths.length === 1 ? "path" : "paths"} from ranking: ${exclude.patterns.join(", ")}. ` + diff --git a/packages/core/src/rank.ts b/packages/core/src/rank.ts index e52aa20..05ba2c2 100644 --- a/packages/core/src/rank.ts +++ b/packages/core/src/rank.ts @@ -12,7 +12,7 @@ import { extractTaskSignals, tokenizePath, tokenizeText } from "./signals.js"; import type { RankedFile, RepoMap } from "./types.js"; const DEPLOYMENT_TERMS = [ - "deploy", "deployment", "vercel", "netlify", "docker", "kubernetes", "hosting", "serverless", "production", "404", "500", "502" + "deploy", "deployment", "vercel", "netlify", "docker", "kubernetes", "hosting", "serverless", "production" ]; const LOCKFILES = new Set(["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"]); const AUXILIARY_CODE_DIRS = new Set(["demo", "demos", "example", "examples", "sample", "samples"]); @@ -30,6 +30,7 @@ const PRESENTATION_CODE_PENALTY = 8; const TYPE_DECLARATION_PENALTY = 4; const BACKUP_COPY_PENALTY = 10; const BUNDLED_OUTPUT_PENALTY = 12; +const GENERATED_TWIN_PENALTY = 21; const GENERATED_TWIN_REASON = "generated build artifact; maintained source counterpart exists"; // Bundlers strip newlines; people do not. A file averaging hundreds of characters per // line is machine output, whatever directory it sits in. Repositories commit these — @@ -164,7 +165,9 @@ export function rankContextFiles( } if (isGeneratedPath(file.path) && maintainedStems.has(moduleStem(file.path))) { + score -= GENERATED_TWIN_PENALTY; reasons.push(GENERATED_TWIN_REASON); + reasons.push("generated counterpart deprioritized below maintained source"); } const pathTokens = tokenizePath(file.path); diff --git a/packages/core/src/repo-scan.ts b/packages/core/src/repo-scan.ts index 4bdda84..0197c28 100644 --- a/packages/core/src/repo-scan.ts +++ b/packages/core/src/repo-scan.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; -import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { ALWAYS_IGNORED_DIRS, GENERATED_DIRS, isGeneratedPath } from "./paths.js"; @@ -39,18 +41,36 @@ const SOURCE_EXTENSIONS = new Set([ */ const SFC_EXTENSIONS = new Set([".vue", ".svelte"]); const SFC_SCRIPT_BLOCK = /]*>([\s\S]*?)<\/script>/gi; -const TEST_PATTERNS = [/\.test\./, /\.spec\./, /(^|\/|\\)__tests__(\/|\\)/, /(^|\/|\\)tests?(\/|\\)/]; +const TEST_PATTERNS = [ + /\.test(?:\.|-d\.)/, + /\.spec\./, + /(^|\/|\\)__tests__(\/|\\)/, + /(^|\/|\\)tests?(\/|\\)/, + /_test\.go$/, + /(^|\/|\\)(?:test_[^/\\]+|[^/\\]+_test)\.py$/ +]; const MAX_TEXT_SAMPLE_BYTES = 64_000; const MAX_DIFF_TEXT_CHARS = 200_000; const MAX_SCANNED_FILES = 25_000; const GIT_MAX_BUFFER = 10 * 1024 * 1024; const exec = promisify(execFile); type ScanState = { count: number; limitReported: boolean }; +const SCAN_CACHE_VERSION = 1; + +type CachedScan = { + version: typeof SCAN_CACHE_VERSION; + stateKey: string; + files: RepoFile[]; + trackedFiles: string[]; + packageScripts: PackageScript[]; + packageManager: RepoMap["packageManager"]; + diagnostics: RepoMap["diagnostics"]; +}; export async function scanRepo( input: Pick< FixMapInput, - "repoRoot" | "baseRef" | "headRef" | "diffSpec" | "workingTree" | "includeUntracked" + "repoRoot" | "baseRef" | "headRef" | "diffSpec" | "workingTree" | "includeUntracked" | "useCache" > ): Promise { const repoRoot = resolve(input.repoRoot); @@ -71,9 +91,39 @@ export async function scanRepo( } const diagnostics: RepoMap["diagnostics"] = []; - const files = await listFiles(repoRoot, diagnostics); - const trackedFiles = await listTrackedPaths(repoRoot); - const packageScripts = await readPackageScripts(repoRoot, files, diagnostics); + const cacheLocation = input.useCache ? await buildScanCacheLocation(repoRoot) : undefined; + const cached = cacheLocation ? await readScanCache(cacheLocation) : undefined; + let files: RepoFile[]; + let trackedFiles: string[]; + let packageScripts: PackageScript[]; + let packageManager: RepoMap["packageManager"]; + if (cached) { + files = cached.files; + trackedFiles = cached.trackedFiles; + packageScripts = cached.packageScripts; + packageManager = cached.packageManager; + diagnostics.push(...cached.diagnostics, { + code: "cache-hit", + severity: "info", + message: `Reused the repository scan for the exact current git state (${files.length.toLocaleString()} files). Pass --no-cache to rescan.` + }); + } else { + files = await listFiles(repoRoot, diagnostics); + trackedFiles = await listTrackedPaths(repoRoot); + packageScripts = await readPackageScripts(repoRoot, files, diagnostics); + packageManager = detectPackageManager(files); + if (cacheLocation) { + await writeScanCache(cacheLocation, { + version: SCAN_CACHE_VERSION, + stateKey: cacheLocation.stateKey, + files, + trackedFiles, + packageScripts, + packageManager, + diagnostics: [...diagnostics] + }); + } + } const diffSpec = resolveDiffSpec(input); const diff = input.workingTree ? await readWorkingTree(repoRoot, input.includeUntracked === true, diagnostics) @@ -86,11 +136,98 @@ export async function scanRepo( packageScripts, changedFiles: diff.changedFiles, diffText: diff.diffText, - packageManager: detectPackageManager(files), + packageManager, diagnostics }; } +type ScanCacheLocation = { path: string; stateKey: string }; + +async function buildScanCacheLocation(root: string): Promise { + try { + const [{ stdout: head }, { stdout: status }] = await Promise.all([ + exec("git", ["rev-parse", "HEAD"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }), + exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], { + cwd: root, + maxBuffer: GIT_MAX_BUFFER + }) + ]); + // Untracked files are scanner inputs but are absent from `git diff`. Do not cache that + // state rather than keying it on names alone and serving stale contents after an edit. + if (status.split("\0").some((entry) => entry.startsWith("?? "))) return undefined; + const dirtyDiff = status.length > 0 + ? (await exec("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--", "."], { + cwd: root, + maxBuffer: GIT_MAX_BUFFER + })).stdout + : ""; + const stateKey = hashText([ + String(SCAN_CACHE_VERSION), + resolve(root), + head.trim(), + status, + dirtyDiff + ].join("\0")); + const cacheRoot = process.env.FIXMAP_CACHE_DIR ?? join( + process.env.LOCALAPPDATA ?? process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), + "fixmap", + "scans" + ); + return { + path: join(cacheRoot, `${hashText(resolve(root))}-${stateKey}.json`), + stateKey + }; + } catch { + // Non-git directories deliberately do not cache: they have no cheap exact invalidation key. + return undefined; + } +} + +async function readScanCache(location: ScanCacheLocation): Promise { + try { + const cached = JSON.parse(await readFile(location.path, "utf8")) as Partial; + if ( + cached.version !== SCAN_CACHE_VERSION || + cached.stateKey !== location.stateKey || + !Array.isArray(cached.files) || + !Array.isArray(cached.trackedFiles) || + !Array.isArray(cached.packageScripts) || + !Array.isArray(cached.diagnostics) || + !["npm", "pnpm", "yarn", "bun"].includes(cached.packageManager ?? "") + ) return undefined; + return cached as CachedScan; + } catch { + return undefined; + } +} + +async function writeScanCache(location: ScanCacheLocation, cached: CachedScan): Promise { + try { + await mkdir(dirname(location.path), { recursive: true }); + await writeFile(location.path, `${JSON.stringify(cached)}\n`, { encoding: "utf8", flag: "wx" }); + } catch (error) { + const code = (error as { code?: unknown }).code; + if (code !== "EEXIST") { + // Cache writes are an optimization. A read-only cache directory must not fail a plan. + return; + } + // An interrupted prior write can leave an invalid exact-key file. Replace only that + // FixMap-owned cache entry; never touch a repository path. + const existing = await readScanCache(location); + if (existing) return; + try { + await unlink(location.path); + await writeFile(location.path, `${JSON.stringify(cached)}\n`, { encoding: "utf8", flag: "wx" }); + } catch { + // Another process may have won the replacement race; the current scan is still valid. + } + } +} + +function hashText(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 24); +} + async function listTrackedPaths(root: string): Promise { try { const { stdout } = await exec( @@ -110,22 +247,32 @@ function resolveDiffSpec(input: Pick { const gitPaths = await listGitPaths(root); - if (gitPaths) { - return buildFilesFromPaths(root, gitPaths, diagnostics); - } - - const files = await walkFiles(root, root, diagnostics, { count: 0, limitReported: false }); - return files.sort((a, b) => a.path.localeCompare(b.path)); + const files = gitPaths + ? await buildFilesFromPaths(root, gitPaths.paths, diagnostics, gitPaths.gitLinks) + : (await walkFiles(root, root, diagnostics, { count: 0, limitReported: false })) + .sort((a, b) => a.path.localeCompare(b.path)); + + // These are properties of the scanned files, not of git. Keeping them here makes an + // extracted archive and a checkout report the same content limitations. + reportUnreadContent(diagnostics, files); + reportGeneratedDominance(diagnostics, files); + return files; } -async function listGitPaths(root: string): Promise { +async function listGitPaths(root: string): Promise<{ paths: string[]; gitLinks: Set } | undefined> { try { - const { stdout } = await exec( - "git", - ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], - { cwd: root, maxBuffer: GIT_MAX_BUFFER } - ); - return [...new Set(stdout.split("\0").filter(Boolean))]; + const [{ stdout }, { stdout: staged }] = await Promise.all([ + exec("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { + cwd: root, + maxBuffer: GIT_MAX_BUFFER + }), + exec("git", ["ls-files", "--stage", "-z"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }) + ]); + const gitLinks = new Set(staged.split("\0").flatMap((entry) => { + const match = /^160000\s+[0-9a-f]+\s+\d+\t(.+)$/i.exec(entry); + return match?.[1] ? [normalizePath(match[1])] : []; + })); + return { paths: [...new Set(stdout.split("\0").filter(Boolean))], gitLinks }; } catch { return undefined; } @@ -134,10 +281,12 @@ async function listGitPaths(root: string): Promise { async function buildFilesFromPaths( root: string, paths: string[], - diagnostics: RepoMap["diagnostics"] + diagnostics: RepoMap["diagnostics"], + knownGitLinks = new Set() ): Promise { const results: RepoFile[] = []; const absent: string[] = []; + const gitLinks: string[] = []; // Git can hand back two tracked paths that are one file on disk: a symlink beside its // target, or anything under a Windows junction. `stat` follows both, so each produced an // identically scored row and one module filled two slots in the plan. @@ -161,12 +310,20 @@ async function buildFilesFromPaths( if (isInAlwaysIgnoredDir(relativePath)) { continue; } + if (knownGitLinks.has(relativePath)) { + gitLinks.push(relativePath); + continue; + } const scanned = await toRepoFile(join(root, rawPath), relativePath); if (scanned.status === "absent") { absent.push(relativePath); continue; } + if (scanned.status === "not-a-file") { + gitLinks.push(relativePath); + continue; + } if (scanned.status !== "ok") { continue; } @@ -174,9 +331,11 @@ async function buildFilesFromPaths( const seenIndex = seenRealPaths.get(scanned.realPath); if (seenIndex !== undefined) { const seenFile = results[seenIndex]!; - // Exactly one of the two can be the real file, so a single lstat settles it. This - // runs only on a collision, which is rare by definition. - if (await isSymbolicLink(join(root, seenFile.path))) { + // Looking only at the leaf with lstat misses Windows junctions, where the linked + // object is an ancestor directory. Comparing literal and resolved paths covers both. + const seenIsAlias = !sameFilesystemPath(resolve(root, seenFile.path), scanned.realPath); + const currentIsAlias = !sameFilesystemPath(resolve(root, relativePath), scanned.realPath); + if (seenIsAlias && !currentIsAlias) { linked.push({ path: seenFile.path, target: relativePath }); results[seenIndex] = scanned.file; } else { @@ -190,8 +349,7 @@ async function buildFilesFromPaths( reportAbsentTrackedPaths(diagnostics, absent); reportLinkedDuplicates(diagnostics, linked); - reportUnreadContent(diagnostics, results); - reportGeneratedDominance(diagnostics, results); + reportSkippedSubmodules(diagnostics, gitLinks); return results.sort((a, b) => a.path.localeCompare(b.path)); } @@ -221,20 +379,45 @@ function reportAbsentTrackedPaths(diagnostics: RepoMap["diagnostics"], absent: s /** * A source file whose contents were never read still ranks — on its path alone. That is the - * shape of the `got` miss behind #274: `source/core/index.ts` is 79KB, past the sample + * shape of the `got` miss behind #274: `source/core/index.ts` is 79 kB, past the sample * ceiling, so its entire content signal was silently absent and only an explicit path * mention kept it visible. Naming those files lets a reader see that the ranking for them * rests on the path and nothing else. */ function reportUnreadContent(diagnostics: RepoMap["diagnostics"], files: RepoFile[]): void { - const unread = files.filter((file) => file.isSource && file.textSampleComplete === false); + const unavailable = files.filter((file) => + file.isSource && + file.textSampleComplete === false && + file.textSampleSkipReason !== "too-large" + ); + for (const reason of ["not-text", "unreadable"] as const) { + const affected = unavailable.filter((file) => file.textSampleSkipReason === reason); + if (affected.length === 0) continue; + const sample = affected.slice(0, 3).map((file) => file.path).join(", "); + const prefix = `${affected.length.toLocaleString()} source file${affected.length === 1 ? "" : "s"}`; + diagnostics.push({ + code: "content-unread", + severity: "warning", + message: reason === "not-text" + ? `${prefix} ${affected.length === 1 ? "is" : "are"} not UTF-8 text (for example UTF-16 or binary) and ` + + `rank${affected.length === 1 ? "s" : ""} on path alone: ${sample}${affected.length > 3 ? ", ..." : ""}. Re-save source as UTF-8 to rank its contents.` + : `${prefix} could not be read and rank${affected.length === 1 ? "s" : ""} on path alone: ${sample}${affected.length > 3 ? ", ..." : ""}. Check file permissions and retry.`, + paths: affected.slice(0, 8).map((file) => file.path) + }); + } + + const unread = files.filter((file) => + file.isSource && + file.textSampleComplete === false && + file.textSampleSkipReason === "too-large" + ); if (unread.length === 0) return; const sample = unread .slice() .sort((a, b) => b.sizeBytes - a.sizeBytes) .slice(0, 3) - .map((file) => `${file.path} (${Math.round(file.sizeBytes / 1024).toLocaleString()}KB)`) + .map((file) => `${file.path} (${Math.ceil(file.sizeBytes / 1000).toLocaleString()} kB)`) .join(", "); diagnostics.push({ @@ -243,7 +426,21 @@ function reportUnreadContent(diagnostics: RepoMap["diagnostics"], files: RepoFil message: `${unread.length.toLocaleString()} source file${unread.length === 1 ? "" : "s"} could not be read as text and ` + `rank${unread.length === 1 ? "s" : ""} on path alone — largest: ${sample}` + - `${unread.length > 3 ? ", …" : ""}. Files over ${(MAX_TEXT_SAMPLE_BYTES / 1000).toLocaleString()}KB are not sampled.` + `${unread.length > 3 ? ", …" : ""}. Files over ${(MAX_TEXT_SAMPLE_BYTES / 1000).toLocaleString()} kB are not sampled.`, + paths: unread.slice(0, 8).map((file) => file.path) + }); +} + +function reportSkippedSubmodules(diagnostics: RepoMap["diagnostics"], gitLinks: string[]): void { + if (gitLinks.length === 0) return; + diagnostics.push({ + code: "submodules-skipped", + severity: "info", + message: + `${gitLinks.length.toLocaleString()} git submodule${gitLinks.length === 1 ? " was" : "s were"} not scanned: ` + + `${gitLinks.slice(0, 3).join(", ")}${gitLinks.length > 3 ? ", …" : ""}. ` + + "Submodules are separate repositories; point --repo at one to map its contents.", + paths: gitLinks.slice(0, 8) }); } @@ -375,7 +572,8 @@ async function toRepoFile(absolutePath: string, relativePath: string): Promise { - try { - return (await lstat(absolutePath)).isSymbolicLink(); - } catch { - return false; - } +function sameFilesystemPath(left: string, right: string): boolean { + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; } function isInAlwaysIgnoredDir(relativePath: string): boolean { @@ -462,8 +658,9 @@ async function readPackageScripts(root: string, files: RepoFile[], diagnostics: continue; } - const decoded = decodeManifest(bytes); + let decoded: { text: string; encoding: string } | undefined; try { + decoded = decodeManifest(bytes); const parsed = JSON.parse(decoded.text) as { name?: unknown; scripts?: Record }; const packageDir = normalizePath(dirname(manifest.path)); // The declared workspace name, so a yarn route can address the package the way both @@ -483,7 +680,7 @@ async function readPackageScripts(root: string, files: RepoFile[], diagnostics: `Could not parse ${manifest.path}; scripts from that package were skipped.` + // Encoding is no longer a cause of failure, so naming it here rules it out rather // than sending someone to re-save a file whose real problem is a syntax error. - (decoded.encoding === "utf8" ? "" : ` It was decoded as ${decoded.encoding}, so the problem is the JSON itself, not the encoding.`) + (!decoded || decoded.encoding === "utf8" ? "" : ` It was decoded as ${decoded.encoding}, so the problem is the JSON itself, not the encoding.`) }); } } @@ -503,7 +700,11 @@ function decodeManifest(bytes: Buffer): { text: string; encoding: string } { return { text: bytes.subarray(2).toString("utf16le"), encoding: "UTF-16LE" }; } if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) { - return { text: bytes.subarray(2).swap16().toString("utf16le"), encoding: "UTF-16BE" }; + const body = bytes.subarray(2); + if (body.length % 2 !== 0) { + throw new Error("Truncated UTF-16BE input has an odd byte count"); + } + return { text: Buffer.from(body).swap16().toString("utf16le"), encoding: "UTF-16BE" }; } if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { return { text: bytes.subarray(3).toString("utf8"), encoding: "UTF-8 with a byte order mark" }; @@ -531,20 +732,29 @@ async function readDiff( .filter(Boolean) .map(normalizePath); const untracked = diffSpec.includes("..") ? [] : await listUntrackedPaths(repoRoot); + const changedFiles = [...new Set([...tracked, ...untracked])].sort((a, b) => a.localeCompare(b)); + diagnostics.push({ + code: "diff-resolved", + severity: "info", + message: changedFiles.length === 0 + ? `The diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}" resolved to zero changed files, so results use the task text only. Paths are relative to the working directory; run from the repository root to include changes outside it.` + : `Diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}" resolved ${changedFiles.length} changed ${changedFiles.length === 1 ? "path" : "paths"}.`, + paths: changedFiles.slice(0, 8) + }); return { - changedFiles: [...new Set([...tracked, ...untracked])].sort((a, b) => a.localeCompare(b)), + changedFiles, diffText: diffText.slice(0, MAX_DIFF_TEXT_CHARS) }; } catch (error) { - // git echoes the failing command back, so its own message contains the spec a second - // time. Truncating only the interpolation above would leave the full string in `detail`. - const rawDetail = error instanceof Error ? error.message.split(/\r?\n/)[0] : "unknown git error"; - const detail = truncateForDiagnostic(rawDetail ?? "unknown git error", DIAGNOSTIC_SPEC_LIMIT * 2); + const checkoutState = isMissingGit(error) ? undefined : await describeGitCheckout(repoRoot); + const detail = truncateForDiagnostic(gitErrorDetail(error), DIAGNOSTIC_SPEC_LIMIT * 2); diagnostics.push({ code: "diff-unavailable", severity: "warning", - message: describesMissingRepository(error) + message: checkoutState === "not-repository" ? `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${NOT_A_GIT_CHECKOUT}` + : checkoutState === "no-history" + ? `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ${NO_GIT_HISTORY}` : `Could not resolve git diff "${truncateForDiagnostic(diffSpec, DIAGNOSTIC_SPEC_LIMIT)}": ` + `${detail}. Results use the task text only.` }); @@ -592,13 +802,15 @@ async function readWorkingTree( return { changedFiles, diffText: diffText.slice(0, MAX_DIFF_TEXT_CHARS) }; } catch (error) { - const rawDetail = error instanceof Error ? error.message.split(/\r?\n/)[0] : "unknown git error"; + const checkoutState = isMissingGit(error) ? undefined : await describeGitCheckout(repoRoot); diagnostics.push({ code: "diff-unavailable", severity: "warning", - message: describesMissingRepository(error) + message: checkoutState === "not-repository" ? `Could not read the working tree: ${NOT_A_GIT_CHECKOUT}` - : `Could not read the working tree: ${truncateForDiagnostic(rawDetail ?? "unknown git error", DIAGNOSTIC_SPEC_LIMIT * 2)}. ` + + : checkoutState === "no-history" + ? `Could not read the working tree: ${NO_GIT_HISTORY}` + : `Could not read the working tree: ${truncateForDiagnostic(gitErrorDetail(error), DIAGNOSTIC_SPEC_LIMIT * 2)}. ` + "Results use the task text only." }); return { changedFiles: [], diffText: "" }; @@ -615,17 +827,39 @@ const NOT_A_GIT_CHECKOUT = "this directory is not a git checkout. Ranking still works from the task text; " + "--diff, --base/--head and --working-tree need a repository with history."; +const NO_GIT_HISTORY = + "this repository has no commits yet, so there is nothing to diff against. " + + "Commit the initial work first, or run with --issue alone to rank from the task text."; + /** * `execFile` puts "Command failed: git ..." in `message` and git's own explanation in * `stderr`, so matching on the message alone never saw the reason. Both are checked. */ -function describesMissingRepository(error: unknown): boolean { - const candidate = error as { message?: unknown; stderr?: unknown }; - const text = [ - typeof candidate?.message === "string" ? candidate.message : "", - typeof candidate?.stderr === "string" ? candidate.stderr : "" - ].join("\n"); - return /not a git repository|does not have a commit checked out/i.test(text); +async function describeGitCheckout(root: string): Promise<"not-repository" | "no-history" | undefined> { + try { + const { stdout } = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }); + if (stdout.trim() !== "true") return "not-repository"; + } catch { + return "not-repository"; + } + try { + await exec("git", ["rev-parse", "--verify", "HEAD"], { cwd: root, maxBuffer: GIT_MAX_BUFFER }); + return undefined; + } catch { + return "no-history"; + } +} + +function gitErrorDetail(error: unknown): string { + const candidate = error as { code?: unknown; message?: unknown; stderr?: unknown }; + if (candidate?.code === "ENOENT") return "Git is not installed or is not available on PATH"; + const stderr = typeof candidate?.stderr === "string" ? candidate.stderr : ""; + const message = typeof candidate?.message === "string" ? candidate.message : String(error); + return stderr.split(/\r?\n/).find((line) => line.trim()) ?? message.split(/\r?\n/)[0] ?? "unknown git error"; +} + +function isMissingGit(error: unknown): boolean { + return (error as { code?: unknown })?.code === "ENOENT"; } function detectPackageManager(files: RepoFile[]): RepoMap["packageManager"] { @@ -651,9 +885,9 @@ function classifyFile(path: string, extension: string): RepoFile["kind"] { async function readTextSample( path: string, sizeBytes: number -): Promise<{ text: string; complete: boolean }> { +): Promise<{ text: string; complete: boolean; skipReason?: RepoFile["textSampleSkipReason"] }> { if (sizeBytes > MAX_TEXT_SAMPLE_BYTES) { - return { text: "", complete: false }; + return { text: "", complete: false, skipReason: "too-large" }; } try { @@ -664,11 +898,11 @@ async function readTextSample( // Reporting it as incomplete routes it through the same "content unavailable" handling // as an oversized file. if (bytes.includes(0)) { - return { text: "", complete: false }; + return { text: "", complete: false, skipReason: "not-text" }; } return { text: bytes.toString("utf8"), complete: true }; } catch { - return { text: "", complete: false }; + return { text: "", complete: false, skipReason: "unreadable" }; } } diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index fab11e5..596a4d3 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -47,6 +47,7 @@ export function buildReportFromRepo( const routedTestPaths = [...new Set(testRoutes.flatMap((route) => route.relatedFiles))]; return { + reportVersion: 1, summary: buildSummary(contextFiles.length, testRoutes.length), contextFiles, testRoutes, @@ -56,10 +57,11 @@ export function buildReportFromRepo( ...repo.diagnostics, ...findGatedTestDiagnostics(repo.files, routedTestPaths), ...findMissingTestRouteDiagnostics(repo, contextFiles, testRoutes), - ...findTaskDiagnostics(grounding, ranking), + ...findTaskDiagnostics(repo, grounding, ranking), + ...findTaskPreprocessingDiagnostics(input.issueText ?? ""), ...(grounding.specificity === "vague" ? [] - : findEmptyResultDiagnostics(repo, contextFiles, input.issueText ?? "")) + : findEmptyResultDiagnostics(repo, contextFiles, input.issueText ?? "", input.exclude)) ], analysis: { grounding, @@ -129,6 +131,7 @@ function findMissingTestRouteDiagnostics( } function findTaskDiagnostics( + repo: RepoMap, grounding: TaskGrounding, ranking: RankingShape ): ScanDiagnostic[] { @@ -156,11 +159,17 @@ function findTaskDiagnostics( } if (grounding.unverifiedIdentifiers.length > 0) { + const skipReasons = new Set(repo.files + .filter((file) => file.isSource && file.textSampleComplete === false) + .map((file) => file.textSampleSkipReason)); + const cause = skipReasons.size === 1 && skipReasons.has("too-large") + ? "one or more source files exceeded the text-sampling limit" + : "one or more source files could not be sampled as UTF-8 text"; diagnostics.push({ code: "identifier-unverified", severity: "warning", message: - `Identifier${grounding.unverifiedIdentifiers.length === 1 ? "" : "s"} could not be verified because one or more source files exceeded the text-sampling limit: ` + + `Identifier${grounding.unverifiedIdentifiers.length === 1 ? "" : "s"} could not be verified because ${cause}: ` + `${grounding.unverifiedIdentifiers.join(", ")}. FixMap did not claim that the identifier was absent, and confidence was capped at low without another anchor.` }); } @@ -188,13 +197,37 @@ function findTaskDiagnostics( return diagnostics; } +function findTaskPreprocessingDiagnostics(issueText: string): ScanDiagnostic[] { + const signals = extractTaskSignals({ issueText }); + if (signals.uncheckedChecklistLinesPreserved > 0) { + return [{ + code: "task-checklist-filtered", + severity: "info", + message: + `Preserved ${signals.uncheckedChecklistLinesPreserved} unchecked checklist ` + + `${signals.uncheckedChecklistLinesPreserved === 1 ? "line" : "lines"} because they contained the issue's only substantive task details.` + }]; + } + if (signals.uncheckedChecklistLinesRemoved > 0) { + return [{ + code: "task-checklist-filtered", + severity: "info", + message: + `Removed ${signals.uncheckedChecklistLinesRemoved} unchecked issue-template ` + + `${signals.uncheckedChecklistLinesRemoved === 1 ? "option" : "options"} before ranking; selected checklist items and prose were retained.` + }]; + } + return []; +} + // An empty report is the one result that explains nothing on its own. Say whether the task // text carried no searchable terms or whether the terms simply matched no file, so the // reader knows which end to fix. function findEmptyResultDiagnostics( repo: RepoMap, contextFiles: RankedFile[], - issueText: string + issueText: string, + exclude: PathExcluder | undefined ): ScanDiagnostic[] { if (contextFiles.length > 0 || repo.files.length === 0) { return []; @@ -207,6 +240,25 @@ function findEmptyResultDiagnostics( }); const terms = [...signals.tokens].sort(); + if (exclude?.patterns.length) { + // An empty ranked set can mean the repository lacks the behavior, but it can + // also mean exclusion patterns removed the matching files. Re-rank without + // exclusions so the diagnostic identifies the latter case precisely. + const withoutExclusions = rankContextFiles(repo, { issueText, diffText: repo.diffText }, DEFAULT_CONTEXT_FILE_LIMIT); + const excludedMatches = withoutExclusions.filter((file) => exclude.excludes(file.path)); + if (excludedMatches.length > 0) { + const paths = excludedMatches.map((file) => file.path); + return [{ + code: "no-context-match", + severity: "warning", + message: + `No context files: ${paths.length} matching ${paths.length === 1 ? "file was" : "files were"} removed by exclusion patterns ` + + `(${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}). Remove the pattern or run --explain on one of these paths.`, + paths: paths.slice(0, 8) + }]; + } + } + if (terms.length === 0 && signals.identifiers.size === 0 && signals.fileMentions.size === 0) { return [{ code: "no-task-terms", @@ -542,7 +594,10 @@ export function renderMarkdownReport(report: FixMapReport): string { "", "## Diagnostics", "", - ...listOrEmpty(report.diagnostics.map((diagnostic) => `- **${diagnostic.severity}** ${diagnostic.message}`)) + ...listOrEmpty(report.diagnostics.flatMap((diagnostic) => [ + `- **${diagnostic.severity}** ${diagnostic.message}`, + ...(diagnostic.paths ?? []).slice(0, 8).map((path) => ` - \`${path}\``) + ])) ]; return `${lines.join("\n")}\n`; diff --git a/packages/core/src/signals.ts b/packages/core/src/signals.ts index 0f8ab38..233c307 100644 --- a/packages/core/src/signals.ts +++ b/packages/core/src/signals.ts @@ -181,6 +181,8 @@ export type TaskSignals = { memberMentions: Set; exactFragments: string[]; identifiers: Set; + uncheckedChecklistLinesRemoved: number; + uncheckedChecklistLinesPreserved: number; }; export function extractTaskSignals(input: { @@ -188,7 +190,8 @@ export function extractTaskSignals(input: { diffText?: string | undefined; changedFiles?: string[]; }): TaskSignals { - const issueText = stripUncheckedChecklistLines(input.issueText ?? ""); + const prepared = prepareChecklistText(input.issueText ?? ""); + const issueText = prepared.text; const taskText = [issueText, extractDiffContentLines(input.diffText ?? "")].join("\n"); const tokens = tokenizeText(taskText); @@ -198,15 +201,28 @@ export function extractTaskSignals(input: { fileMentions: extractFileMentions(issueText), memberMentions: extractMemberMentions(issueText), exactFragments: extractExactFragments(taskText), - identifiers: extractIdentifiers(taskText) + identifiers: extractIdentifiers(taskText), + uncheckedChecklistLinesRemoved: prepared.removed, + uncheckedChecklistLinesPreserved: prepared.preserved }; } -function stripUncheckedChecklistLines(text: string): string { - return text - .split(/\r?\n/) - .filter((line) => !/^\s*[-*]\s*\[\s\]\s+/.test(line)) - .join("\n"); +function prepareChecklistText(text: string): { text: string; removed: number; preserved: number } { + const unchecked = /^\s*[-*]\s*\[\s\]\s+/; + const lines = text.split(/\r?\n/); + const removed = lines.filter((line) => unchecked.test(line)); + if (removed.length === 0) return { text, removed: 0, preserved: 0 }; + + const retained = lines.filter((line) => !unchecked.test(line)); + // If the only remaining text is headings/whitespace, the checklist is the issue body, + // not a set of unselected template options. Preserve it instead of erasing the task. + const hasSubstantiveRetainedText = retained.some((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !/^#{1,6}\s+/.test(trimmed); + }); + return hasSubstantiveRetainedText + ? { text: retained.join("\n"), removed: removed.length, preserved: 0 } + : { text, removed: 0, preserved: removed.length }; } export function extractExactFragments(text: string): string[] { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f6bd5cf..206e8c7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,8 +9,12 @@ export type FixMapInput = { workingTree?: boolean | undefined; /** Untracked files are opt-in even in working-tree mode; agent metadata lives there. */ includeUntracked?: boolean | undefined; + /** Reuse an exact git-state scan from the OS cache. Non-git directories never cache. */ + useCache?: boolean | undefined; }; +export type TextSampleSkipReason = "too-large" | "not-text" | "unreadable"; + export type RepoFile = { path: string; extension: string; @@ -20,6 +24,7 @@ export type RepoFile = { kind: "code" | "config" | "documentation" | "other"; textSample: string; textSampleComplete?: boolean; + textSampleSkipReason?: TextSampleSkipReason; }; export type PackageScript = { @@ -37,6 +42,7 @@ export type ScanDiagnostic = { | "scan-limit-reached" | "tracked-paths-absent" | "duplicate-real-path" + | "submodules-skipped" | "repo-root-missing" | "gated-test-skipped" | "remote-issue-fetched" @@ -55,7 +61,10 @@ export type ScanDiagnostic = { | "content-unread" | "generated-paths-dominant" | "paths-excluded" - | "working-tree-diff"; + | "working-tree-diff" + | "diff-resolved" + | "cache-hit" + | "task-checklist-filtered"; message: string; severity: "info" | "warning" | "error"; /** @@ -136,6 +145,8 @@ export type TaskAnalysis = { }; export type FixMapReport = { + /** Machine-output contract. Additive fields do not bump this; breaking changes do. */ + reportVersion?: 1; summary: string; contextFiles: RankedFile[]; testRoutes: TestRoute[]; @@ -152,7 +163,8 @@ export type VerifyFinding = { | "unmapped-change" | "leading-file-untouched" | "no-test-changed" - | "new-risk-area"; + | "new-risk-area" + | "plan-repository-mismatch"; severity: "info" | "warning" | "error"; paths: string[]; message: string; diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts new file mode 100644 index 0000000..13a3ced --- /dev/null +++ b/packages/core/src/validate.ts @@ -0,0 +1,69 @@ +import type { FixMapReport } from "./types.js"; + +export type ValidatedFixMapReport = + | { success: true; report: FixMapReport } + | { success: false; message: string }; + +/** Validate the report fields read by compare and verify without rejecting additive fields. */ +export function validateFixMapReport(candidate: unknown, label: string): ValidatedFixMapReport { + if ( + typeof candidate !== "object" || + candidate === null || + Array.isArray(candidate) || + !Array.isArray((candidate as Partial).contextFiles) + ) { + return { + success: false, + message: `${label} is not a FixMap JSON report: no contextFiles array.` + }; + } + + const contextFiles = (candidate as FixMapReport).contextFiles; + const record = candidate as Record; + if (record.reportVersion !== undefined && record.reportVersion !== 1) { + return { + success: false, + message: `${label} uses unsupported reportVersion ${JSON.stringify(record.reportVersion)}; this FixMap release supports reportVersion 1.` + }; + } + if ( + contextFiles.length === 0 && + !( + typeof record.summary === "string" && + Array.isArray(record.testRoutes) && + Array.isArray(record.risks) && + Array.isArray(record.changedFiles) && + Array.isArray(record.diagnostics) + ) + ) { + return { + success: false, + message: + `${label} has no context files and is missing the complete FixMap report envelope ` + + "(summary, testRoutes, risks, changedFiles, and diagnostics)." + }; + } + + const invalid = contextFiles.findIndex((file) => { + if (typeof file !== "object" || file === null) return true; + const ranked = file as unknown as Record; + if (typeof ranked.path !== "string" || ranked.path.trim().length === 0) return true; + if (ranked.rank !== undefined && (!Number.isSafeInteger(ranked.rank) || (ranked.rank as number) < 1)) return true; + if (ranked.score !== undefined && (typeof ranked.score !== "number" || !Number.isFinite(ranked.score))) return true; + if ( + ranked.confidence !== undefined && + ranked.confidence !== "high" && ranked.confidence !== "medium" && ranked.confidence !== "low" + ) return true; + return false; + }); + if (invalid !== -1) { + return { + success: false, + message: + `${label} has an invalid contextFiles entry at index ${invalid}; each entry needs a non-empty string "path", ` + + "and optional rank, score, and confidence fields must use their documented types." + }; + } + + return { success: true, report: candidate as FixMapReport }; +} diff --git a/packages/core/src/verify.ts b/packages/core/src/verify.ts index 6b15141..c2e0775 100644 --- a/packages/core/src/verify.ts +++ b/packages/core/src/verify.ts @@ -14,6 +14,25 @@ import type { FixMapReport, RepoMap, VerifyFinding, VerifyResult } from "./types export function verifyPlan(report: FixMapReport, repo: RepoMap): VerifyResult { const changed = repo.changedFiles; const findings: VerifyFinding[] = []; + const fileByPath = new Map(repo.files.map((file) => [file.path, file])); + const plannedPaths = report.contextFiles.map((file) => file.path); + + if (plannedPaths.length > 0 && !plannedPaths.some((path) => fileByPath.has(path))) { + const mismatch: VerifyFinding = { + code: "plan-repository-mismatch", + severity: "error", + paths: plannedPaths.slice(0, 8), + message: + `Verification was not attempted: none of the ${plannedPaths.length} planned files exist in ${repo.root}. ` + + "This plan appears to be for a different repository or revision; check --repo or regenerate the plan against this checkout." + }; + return { + summary: `None of the ${plannedPaths.length} planned files exist in ${repo.root}; the plan and repository do not match.`, + changedFiles: changed, + findings: [mismatch], + diagnostics: repo.diagnostics + }; + } if (changed.length === 0) { return { @@ -24,8 +43,7 @@ export function verifyPlan(report: FixMapReport, repo: RepoMap): VerifyResult { }; } - const planned = new Set(report.contextFiles.map((file) => file.path)); - const fileByPath = new Map(repo.files.map((file) => [file.path, file])); + const planned = new Set(plannedPaths); const isTest = (path: string) => fileByPath.get(path)?.isTest === true; // 1. Edits somewhere the next build discards. This is the only finding that is @@ -71,6 +89,7 @@ export function verifyPlan(report: FixMapReport, repo: RepoMap): VerifyResult { !planned.has(path) && !isTest(path) && !discardedEdits.includes(path) && + !trackedGeneratedEdits.includes(path) && fileByPath.get(path)?.isSource !== false ); if (unmapped.length > 0) { @@ -99,7 +118,12 @@ export function verifyPlan(report: FixMapReport, repo: RepoMap): VerifyResult { } // 4. Source moved without any test moving. Test routes name what would exercise it. - const changedSource = changed.filter((path) => !isTest(path) && fileByPath.get(path)?.kind === "code"); + const changedSource = changed.filter((path) => + !isTest(path) && + !trackedGeneratedEdits.includes(path) && + !discardedEdits.includes(path) && + fileByPath.get(path)?.kind === "code" + ); const changedTests = changed.filter(isTest); if (changedSource.length > 0 && changedTests.length === 0) { const suggested = [...new Set(report.testRoutes.flatMap((route) => route.relatedFiles))].filter(isTest); @@ -126,7 +150,7 @@ export function verifyPlan(report: FixMapReport, repo: RepoMap): VerifyResult { for (const risk of newRisks) { findings.push({ code: "new-risk-area", - severity: risk.severity === "low" ? "info" : "warning", + severity: "warning", paths: pathsForRiskArea(risk.area, changed), message: `The change touches ${risk.area}, which the original plan did not flag: ${risk.reason}.` }); diff --git a/packages/core/test/explain.test.ts b/packages/core/test/explain.test.ts index 54e8ac9..340570a 100644 --- a/packages/core/test/explain.test.ts +++ b/packages/core/test/explain.test.ts @@ -48,6 +48,21 @@ describe("explainFile", () => { expect(explanation.reasons.join(" ")).toContain("path matches task terms"); }); + it("uses diff content when explaining a file", () => { + const repo = createRepo([ + file("src/transport.ts", "export function reconnectSocket() { return true; }") + ]); + repo.diffText = "+reconnectSocket();"; + + const explanation = explainFile(repo, { + issueText: "connection fails", + diffText: repo.diffText + }, "src/transport.ts"); + + expect(explanation.status).toBe("ranked"); + expect(explanation.reasons.join(" ")).toContain("reconnectSocket"); + }); + it("reports the score a candidate earned when it fell below the cutoff", () => { const explanation = explainFile(authRepo(), task, "src/billing/invoice.ts"); @@ -76,6 +91,16 @@ describe("explainFile", () => { expect(explanation.summary).toContain("no such path"); }); + it("identifies a path inside a skipped submodule", () => { + const repo = authRepo(); + repo.trackedFiles = ["packages/shared"]; + + const explanation = explainFile(repo, task, "packages/shared/lib/helper.ts"); + + expect(explanation.status).toBe("not-scanned"); + expect(explanation.summary).toContain("inside the submodule packages/shared"); + }); + it("blames the scan limit when one was reached", () => { const repo = authRepo(); repo.diagnostics = [{ code: "scan-limit-reached", severity: "warning", message: "Stopped scanning." }]; diff --git a/packages/core/test/plan.test.ts b/packages/core/test/plan.test.ts index ab4e098..f718c4f 100644 --- a/packages/core/test/plan.test.ts +++ b/packages/core/test/plan.test.ts @@ -61,6 +61,22 @@ describe("buildFixMapReport", () => { expect(diagnostic?.message).toContain("flurbulator"); }); + it("names matching files removed by exclusions instead of blaming repository vocabulary", async () => { + const root = await createAuthFixture(); + + const report = await buildFixMapReport({ + repoRoot: root, + issueText: "password reset emails fail", + exclude: ["src/auth/**"] + }); + + expect(report.contextFiles).toEqual([]); + const diagnostic = report.diagnostics.find((entry) => entry.code === "no-context-match"); + expect(diagnostic?.message).toContain("removed by exclusion patterns"); + expect(diagnostic?.paths).toContain("src/auth/reset-password.ts"); + expect(report.diagnostics.find((entry) => entry.code === "paths-excluded")?.severity).toBe("warning"); + }); + it("does not let a giant task token become a giant diagnostic", async () => { const root = await createAuthFixture(); // A pasted blob with no spaces, which used to travel verbatim into the message and diff --git a/packages/core/test/rank.test.ts b/packages/core/test/rank.test.ts index 6d82457..a6c2efb 100644 --- a/packages/core/test/rank.test.ts +++ b/packages/core/test/rank.test.ts @@ -227,6 +227,26 @@ describe("rankContextFiles", () => { expect(ranked.flatMap((file) => file.reasons).join(" ")).not.toMatch(/\bnot\b|\bdoe\b/); }); + it("does not treat a bare HTTP status as deployment evidence", () => { + const repo: RepoMap = { + root: "/repo", + packageScripts: [], + changedFiles: [], + diffText: "", + packageManager: "npm", + diagnostics: [], + files: [ + { path: "vercel.json", extension: ".json", sizeBytes: 20, isSource: true, isTest: false, kind: "config", textSample: "{}" }, + { path: "src/http/account.ts", extension: ".ts", sizeBytes: 100, isSource: true, isTest: false, kind: "code", textSample: "return response.status(404).json({ error: 'account missing' });" } + ] + }; + + const ranked = rankContextFiles(repo, { issueText: "account lookup returns 404" }); + + expect(ranked[0]?.path).toBe("src/http/account.ts"); + expect(ranked.flatMap((file) => file.reasons)).not.toContain("root configuration for a deployment-related task"); + }); + it("ranks files explicitly named in the task, including test files", () => { const repo: RepoMap = { root: "/repo", @@ -815,6 +835,34 @@ describe("rankContextFiles", () => { expect(ranked[0]?.reasons).toContain("generated build artifact; maintained source counterpart exists"); }); + it("keeps an edited generated twin visible but below its maintained source", () => { + const file = (path: string, extension: string) => ({ + path, + extension, + sizeBytes: 100, + isSource: true, + isTest: false, + kind: "code" as const, + textSample: "export function resetPassword() { return 'token'; }" + }); + const repo: RepoMap = { + root: "/repo", + packageScripts: [], + changedFiles: ["dist/app.js"], + diffText: "+export function resetPassword() { return 'wrong'; }", + packageManager: "npm", + diagnostics: [], + files: [file("src/app.ts", ".ts"), file("dist/app.js", ".js")] + }; + + const ranked = rankContextFiles(repo, { issueText: "resetPassword returns the wrong value" }); + + expect(ranked[0]?.path).toBe("src/app.ts"); + expect(ranked.map((entry) => entry.path)).toContain("dist/app.js"); + expect(ranked.find((entry) => entry.path === "dist/app.js")?.reasons) + .toContain("generated counterpart deprioritized below maintained source"); + }); + it("still credits a task term that most of a focused repository mentions", () => { // A term shared by half the files is normally boilerplate, but in a small // single-purpose repository it is the subject: chalk mentions "color" everywhere. diff --git a/packages/core/test/repo-scan.test.ts b/packages/core/test/repo-scan.test.ts index 0b2fcdb..54db167 100644 --- a/packages/core/test/repo-scan.test.ts +++ b/packages/core/test/repo-scan.test.ts @@ -75,6 +75,22 @@ describe("scanRepo", () => { ]); }); + it("recognizes Go, Python, and TypeScript declaration test naming conventions", async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-test-patterns-")); + for (const path of ["handler_test.go", "test_reset.py", "reset_test.py", "types.test-d.ts"]) { + await writeFile(join(root, path), "test reset handler\n"); + } + + const repo = await scanRepo({ repoRoot: root }); + + expect(repo.files.filter((file) => file.isTest).map((file) => file.path).sort()).toEqual([ + "handler_test.go", + "reset_test.py", + "test_reset.py", + "types.test-d.ts" + ]); + }); + it("discovers workspace scripts and the package manager", async () => { const root = await mkdtemp(join(tmpdir(), "fixmap-workspace-")); await mkdir(join(root, "apps", "api"), { recursive: true }); @@ -343,6 +359,22 @@ describe("scanRepo", () => { expect(weird?.textSample).toBe(""); expect(weird?.textSampleComplete).toBe(false); + expect(weird?.textSampleSkipReason).toBe("not-text"); + const diagnostic = repo.diagnostics.find((entry) => entry.code === "content-unread"); + expect(diagnostic?.message).toContain("not UTF-8 text"); + expect(diagnostic?.message).not.toContain("Files over"); + expect(diagnostic?.paths).toContain("src/weird.ts"); + }); + + it("uses one decimal unit and rounds oversized samples up at the boundary", async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-size-unit-")); + await writeFile(join(root, "history.ts"), "x".repeat(64_001)); + + const repo = await scanRepo({ repoRoot: root }); + const diagnostic = repo.diagnostics.find((entry) => entry.code === "content-unread"); + + expect(diagnostic?.message).toContain("history.ts (65 kB)"); + expect(diagnostic?.message).toContain("Files over 64 kB"); }); // A sparse checkout lists paths in the index that are not on disk. Dropping them silently @@ -392,4 +424,133 @@ describe("scanRepo", () => { const diagnostic = repo.diagnostics.find((entry) => entry.code === "duplicate-real-path"); expect(diagnostic?.message).toContain("link.ts -> real-src/reset.ts"); }); + + it("collapses files reached through a Windows junction ancestor", { timeout: 30_000 }, async () => { + if (process.platform !== "win32") return; + const root = await mkdtemp(join(tmpdir(), "fixmap-junction-")); + await mkdir(join(root, "real-src"), { recursive: true }); + await writeFile(join(root, "real-src", "reset.ts"), "export const resetPassword = 1;\n"); + await symlink(join(root, "real-src"), join(root, "alias-src"), "junction"); + await exec("git", ["init", "-b", "main"], { cwd: root }); + await exec("git", ["config", "user.email", "test@example.com"], { cwd: root }); + await exec("git", ["config", "user.name", "Test User"], { cwd: root }); + await exec("git", ["add", "-A"], { cwd: root }); + + const repo = await scanRepo({ repoRoot: root }); + + expect(repo.files.filter((file) => file.path.endsWith("reset.ts"))).toHaveLength(1); + expect(repo.diagnostics.find((entry) => entry.code === "duplicate-real-path")?.message) + .toContain("alias-src/reset.ts -> real-src/reset.ts"); + }); + + it("turns a truncated UTF-16BE manifest into a diagnostic instead of throwing", async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-manifest-odd-be-")); + await writeFile(join(root, "package.json"), Buffer.from([0xfe, 0xff, 0x00])); + + const repo = await scanRepo({ repoRoot: root }); + + expect(repo.packageScripts).toEqual([]); + expect(repo.diagnostics.find((entry) => entry.code === "package-json-invalid")?.message) + .toContain("Could not parse package.json"); + }); + + it("reports a successfully resolved empty diff", { timeout: 30_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-empty-diff-")); + await writeFile(join(root, "index.ts"), "export const value = 1;\n"); + await exec("git", ["init", "-b", "main"], { cwd: root }); + await exec("git", ["config", "user.email", "test@example.com"], { cwd: root }); + await exec("git", ["config", "user.name", "Test User"], { cwd: root }); + await exec("git", ["add", "."], { cwd: root }); + await exec("git", ["commit", "-m", "initial"], { cwd: root }); + + const repo = await scanRepo({ repoRoot: root, diffSpec: "HEAD...HEAD" }); + + expect(repo.changedFiles).toEqual([]); + expect(repo.diagnostics.find((entry) => entry.code === "diff-resolved")?.message) + .toContain("resolved to zero changed files"); + }); + + it("distinguishes an unborn repository from a non-repository", { timeout: 30_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-unborn-")); + await writeFile(join(root, "index.ts"), "export const value = 1;\n"); + await exec("git", ["init", "-b", "main"], { cwd: root }); + + const repo = await scanRepo({ repoRoot: root, workingTree: true }); + + expect(repo.diagnostics.find((entry) => entry.code === "diff-unavailable")?.message) + .toContain("no commits yet"); + }); + + it("reports when Git is unavailable instead of calling the directory a non-repository", async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-no-git-")); + await writeFile(join(root, "index.ts"), "export const value = 1;\n"); + const previousPath = process.env.PATH; + process.env.PATH = join(root, "missing-bin"); + try { + const repo = await scanRepo({ repoRoot: root, diffSpec: "HEAD~1...HEAD" }); + const message = repo.diagnostics.find((entry) => entry.code === "diff-unavailable")?.message; + expect(message).toContain("Git is not installed or is not available on PATH"); + expect(message).not.toContain("not a git checkout"); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }); + + it("reuses an exact clean or dirty scan but invalidates when tracked contents change", { timeout: 30_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), "fixmap-cache-repo-")); + const cacheRoot = await mkdtemp(join(tmpdir(), "fixmap-cache-store-")); + const previousCache = process.env.FIXMAP_CACHE_DIR; + process.env.FIXMAP_CACHE_DIR = cacheRoot; + try { + await writeFile(join(root, "index.ts"), "export const value = 'one';\n"); + await exec("git", ["init", "-b", "main"], { cwd: root }); + await exec("git", ["config", "user.email", "test@example.com"], { cwd: root }); + await exec("git", ["config", "user.name", "Test User"], { cwd: root }); + await exec("git", ["add", "."], { cwd: root }); + await exec("git", ["commit", "-m", "initial"], { cwd: root }); + + await scanRepo({ repoRoot: root, useCache: true }); + const cleanHit = await scanRepo({ repoRoot: root, useCache: true }); + expect(cleanHit.diagnostics.map((entry) => entry.code)).toContain("cache-hit"); + + await writeFile(join(root, "index.ts"), "export const value = 'two';\n"); + const firstDirty = await scanRepo({ repoRoot: root, useCache: true }); + expect(firstDirty.diagnostics.map((entry) => entry.code)).not.toContain("cache-hit"); + expect(firstDirty.files[0]?.textSample).toContain("two"); + expect((await scanRepo({ repoRoot: root, useCache: true })).diagnostics.map((entry) => entry.code)) + .toContain("cache-hit"); + + await writeFile(join(root, "index.ts"), "export const value = 'three';\n"); + const secondDirty = await scanRepo({ repoRoot: root, useCache: true }); + expect(secondDirty.diagnostics.map((entry) => entry.code)).not.toContain("cache-hit"); + expect(secondDirty.files[0]?.textSample).toContain("three"); + } finally { + if (previousCache === undefined) delete process.env.FIXMAP_CACHE_DIR; + else process.env.FIXMAP_CACHE_DIR = previousCache; + await rm(cacheRoot, { recursive: true, force: true }); + } + }); + + it("names skipped git submodules and leaves their contents to the nested repository", { timeout: 30_000 }, async () => { + const child = await mkdtemp(join(tmpdir(), "fixmap-submodule-child-")); + const root = await mkdtemp(join(tmpdir(), "fixmap-submodule-parent-")); + await writeFile(join(child, "helper.ts"), "export const helper = 1;\n"); + await exec("git", ["init", "-b", "main"], { cwd: child }); + await exec("git", ["config", "user.email", "test@example.com"], { cwd: child }); + await exec("git", ["config", "user.name", "Test User"], { cwd: child }); + await exec("git", ["add", "."], { cwd: child }); + await exec("git", ["commit", "-m", "child"], { cwd: child }); + await exec("git", ["init", "-b", "main"], { cwd: root }); + await exec("git", ["config", "user.email", "test@example.com"], { cwd: root }); + await exec("git", ["config", "user.name", "Test User"], { cwd: root }); + await exec("git", ["-c", "protocol.file.allow=always", "submodule", "add", child, "packages/shared"], { cwd: root }); + await exec("git", ["commit", "-am", "submodule"], { cwd: root }); + + const repo = await scanRepo({ repoRoot: root }); + const diagnostic = repo.diagnostics.find((entry) => entry.code === "submodules-skipped"); + + expect(repo.files.map((file) => file.path)).not.toContain("packages/shared/helper.ts"); + expect(diagnostic?.paths).toEqual(["packages/shared"]); + }); }); diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts index f5d7211..2af5643 100644 --- a/packages/core/test/report.test.ts +++ b/packages/core/test/report.test.ts @@ -48,6 +48,31 @@ describe("report rendering", () => { ); }); + it("renders diagnostic paths and marks new JSON reports with version 1", () => { + const repo: RepoMap = { + root: "/repo", + files: [{ + path: "src/auth.ts", extension: ".ts", sizeBytes: 20, isSource: true, + isTest: false, kind: "code", textSample: "export const resetPassword = true" + }], + packageScripts: [], + changedFiles: [], + diffText: "", + packageManager: "npm", + diagnostics: [{ + code: "content-unread", + severity: "warning", + message: "One path could not be sampled.", + paths: ["src/large.ts"] + }] + }; + + const report = buildReportFromRepo(repo, { issueText: "resetPassword fails" }); + + expect(report.reportVersion).toBe(1); + expect(renderMarkdownReport(report)).toContain(" - `src/large.ts`"); + }); + it("routes nearby tests by path overlap and adds risk notes", () => { const repo: RepoMap = { root: "/repo", diff --git a/packages/core/test/signals.test.ts b/packages/core/test/signals.test.ts index 09fce9b..7d6939a 100644 --- a/packages/core/test/signals.test.ts +++ b/packages/core/test/signals.test.ts @@ -168,6 +168,18 @@ describe("extractTaskSignals", () => { expect(signals.exactFragments).not.toContain("@eslint/core"); expect(signals.exactFragments).toContain("@eslint/config-helpers"); + expect(signals.uncheckedChecklistLinesRemoved).toBe(1); + }); + + it("preserves unchecked lines when they are the issue's only substantive details", () => { + const signals = extractTaskSignals({ + issueText: "## Tasks\n- [ ] resetPassword returns the wrong token\n- [ ] sendMail rejects silently" + }); + + expect(signals.identifiers).toContain("resetPassword"); + expect(signals.identifiers).toContain("sendMail"); + expect(signals.uncheckedChecklistLinesPreserved).toBe(2); + expect(signals.uncheckedChecklistLinesRemoved).toBe(0); }); it("stays linear on a long unbroken run instead of backtracking quadratically", () => { diff --git a/packages/core/test/validate.test.ts b/packages/core/test/validate.test.ts new file mode 100644 index 0000000..196bbe9 --- /dev/null +++ b/packages/core/test/validate.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { validateFixMapReport } from "../src/validate.js"; + +const envelope = { + reportVersion: 1, + summary: "No matches.", + contextFiles: [], + testRoutes: [], + risks: [], + changedFiles: [], + diagnostics: [] +}; + +describe("validateFixMapReport", () => { + it("accepts a complete empty report and legacy reports without a marker", () => { + expect(validateFixMapReport(envelope, "report").success).toBe(true); + const legacy = Object.fromEntries( + Object.entries(envelope).filter(([key]) => key !== "reportVersion") + ); + expect(validateFixMapReport(legacy, "report").success).toBe(true); + }); + + it("rejects unsupported report versions", () => { + const result = validateFixMapReport({ ...envelope, reportVersion: 2 }, "report"); + + expect(result.success).toBe(false); + if (!result.success) expect(result.message).toContain("unsupported reportVersion 2"); + }); + + it("rejects truncated empty report-shaped objects", () => { + const result = validateFixMapReport({ reportVersion: 1, contextFiles: [] }, "report"); + + expect(result.success).toBe(false); + if (!result.success) expect(result.message).toContain("complete FixMap report envelope"); + }); +}); diff --git a/packages/core/test/verify.test.ts b/packages/core/test/verify.test.ts index 6c2d564..b1b1d3e 100644 --- a/packages/core/test/verify.test.ts +++ b/packages/core/test/verify.test.ts @@ -76,6 +76,18 @@ describe("verifyPlan", () => { severity: "warning" })); expect(result.findings.map((entry) => entry.code)).not.toContain("edit-in-generated-location"); + expect(result.findings.map((entry) => entry.code)).not.toContain("unmapped-change"); + expect(result.findings.map((entry) => entry.code)).not.toContain("no-test-changed"); + }); + + it("refuses to verify a plan whose paths do not exist in this repository", () => { + const result = verifyPlan(planFor("other-repo/src/auth.ts"), repoWith(["src/auth/reset-password.ts"])); + + expect(result.findings).toEqual([expect.objectContaining({ + code: "plan-repository-mismatch", + severity: "error" + })]); + expect(result.summary).toContain("plan and repository do not match"); }); it("names files the change needed that the plan never ranked", () => { diff --git a/scripts/evaluate.mjs b/scripts/evaluate.mjs index ecb56f8..6521fb8 100644 --- a/scripts/evaluate.mjs +++ b/scripts/evaluate.mjs @@ -2,36 +2,85 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import { rankContextFiles, scanRepo } from "../packages/core/dist/index.js"; +import { wilsonInterval } from "./lib/wilson.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const cases = JSON.parse(await readFile(join(repoRoot, "benchmarks", "cases.json"), "utf8")); +// Reject the empty array before computing rates: 0 / 0 is NaN, and every +// threshold comparison with NaN is false, which would turn the gate into a pass. +if (!Array.isArray(cases) || cases.length === 0) { + throw new Error( + `benchmarks/cases.json must contain at least one case; found ${Array.isArray(cases) ? 0 : typeof cases}.` + ); +} const repo = await scanRepo({ repoRoot }); +// Do not let the ranker read its answer sheet. Every task is stored verbatim in +// this file, so including it would reward benchmark leakage rather than ranking. +const evaluationRepo = { + ...repo, + files: repo.files.filter((file) => file.path !== "benchmarks/cases.json") +}; const results = cases.map((benchmark) => { - const ranked = rankContextFiles(repo, { issueText: benchmark.task }, 3); + const ranked = rankContextFiles(evaluationRepo, { issueText: benchmark.task }, 5); const paths = ranked.map((file) => file.path); - const hit = benchmark.expected.some((expected) => paths.includes(expected)); - return { task: benchmark.task, expected: benchmark.expected, top3: paths, hit }; + return { + issue: benchmark.issue ?? null, + task: benchmark.task, + expected: benchmark.expected, + top5: paths, + top1: benchmark.expected.includes(paths[0]), + top3: benchmark.expected.some((expected) => paths.slice(0, 3).includes(expected)), + top5Hit: benchmark.expected.some((expected) => paths.includes(expected)) + }; }); -const hits = results.filter((result) => result.hit).length; -const top1Hits = results.filter((result) => result.expected.includes(result.top3[0])).length; -const top3HitRate = hits / results.length; -const top1HitRate = top1Hits / results.length; +function scoreCohort(cohort, floors) { + if (cohort.length === 0) { + throw new Error("Every evaluation cohort must contain at least one case."); + } + const score = (key) => { + const hits = cohort.filter((result) => result[key]).length; + return { + hits, + of: cohort.length, + rate: Number((hits / cohort.length).toFixed(3)), + interval95: wilsonInterval(hits, cohort.length) + }; + }; + return { + cases: cohort.length, + top1: score("top1"), + top3: score("top3"), + top5: score("top5Hit"), + floors + }; +} + +const legacyFloors = { top1: 0.5, top3: 0.8, top5: 0.8 }; +// These 23 title-only, path-unmentioned cases are a distinct single-repository +// regression cohort. Their v0.8.7 measurement was much harder than the original +// eight cases, so report and gate them separately instead of pooling the rates. +const fixMapIssueFloors = { top1: 0.3, top3: 0.75, top5: 0.85 }; +const baseline = scoreCohort(results.filter((result) => result.issue === null), legacyFloors); +const fixMapIssues = scoreCohort(results.filter((result) => result.issue !== null), fixMapIssueFloors); const summary = { cases: results.length, - hits, - top1Hits, - top1HitRate: Number(top1HitRate.toFixed(3)), - top3HitRate: Number(top3HitRate.toFixed(3)), - thresholds: { top1: 0.5, top3: 0.8 }, + cohorts: { baseline, fixMapIssues }, results }; process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); -if (top1HitRate < summary.thresholds.top1 || top3HitRate < summary.thresholds.top3) { +const failedCohorts = Object.entries(summary.cohorts).filter(([, cohort]) => + cohort.top1.rate < cohort.floors.top1 || + cohort.top3.rate < cohort.floors.top3 || + cohort.top5.rate < cohort.floors.top5 +); +if (failedCohorts.length > 0) { process.stderr.write( - `FixMap evaluation failed: top-1 ${(top1HitRate * 100).toFixed(1)}%, top-3 ${(top3HitRate * 100).toFixed(1)}%.\n` + `FixMap evaluation failed: ${failedCohorts.map(([name, cohort]) => + `${name} top-1=${cohort.top1.rate}, top-3=${cohort.top3.rate}, top-5=${cohort.top5.rate}` + ).join("; ")}.\n` ); process.exit(1); } diff --git a/scripts/render-demo.mjs b/scripts/render-demo.mjs index 8e6a0e3..71644bf 100644 --- a/scripts/render-demo.mjs +++ b/scripts/render-demo.mjs @@ -35,7 +35,15 @@ const COLORS = { const run = spawnSync( process.execPath, - [join(repoRoot, "packages", "cli", "dist", "cli.js"), "plan", "--issue", "password reset emails fail", "--repo", join(repoRoot, "examples", "tiny-auth-app")], + [ + join(repoRoot, "packages", "cli", "dist", "cli.js"), + "plan", + "--issue", + "password reset emails fail", + "--repo", + join(repoRoot, "examples", "tiny-auth-app"), + "--no-cache" + ], { encoding: "utf8" } ); if (run.status !== 0) { diff --git a/scripts/render-honest-examples.mjs b/scripts/render-honest-examples.mjs index 2de5482..d97a15d 100644 --- a/scripts/render-honest-examples.mjs +++ b/scripts/render-honest-examples.mjs @@ -39,7 +39,9 @@ const examples = [ ]; for (const example of examples) { - const report = await buildFixMapReport({ repoRoot: fixture, issueText: example.task }); + // Generated evidence must not depend on whether this machine has scanned the + // fixture before. Cache behavior has its own tests; these files pin report behavior. + const report = await buildFixMapReport({ repoRoot: fixture, issueText: example.task, useCache: false }); const body = renderMarkdownReport(report); const document = [ ``, diff --git a/server.json b/server.json index 486ee87..955c4c1 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/aryamthecodebreaker/FixMap", "source": "github" }, - "version": "0.8.7", + "version": "0.8.8", "packages": [ { "registryType": "npm", "identifier": "@aryam/fixmap", - "version": "0.8.7", + "version": "0.8.8", "transport": { "type": "stdio" },