From 6040d40105a18916fdc19a91fe162df39005bbd4 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 29 Jul 2026 23:32:49 +0800 Subject: [PATCH 1/5] feat(harness): support monorepo workspace topology Introduce a frozen workspace-topology contract and thread package scope through evidence collection, provider sessions, inherited assets, findings, rendering, and repair routing. Implements docs/specs/2026-07-25-monorepo-workspace-support.md. The change keeps legacy findings readable, fails closed on incomplete or cross-package ownership, and was validated with npm test, npm run pack:verify, direct doc-link checks, and preview endpoint smoke tests. --- .../2026-07-25-monorepo-workspace-support.md | 483 ++++++++++++--- scripts/agent-customize/core/items.mjs | 2 +- scripts/agent-lint/index.mjs | 100 ++- scripts/better-harness-cli/cli.mjs | 1 + scripts/better-harness-cli/registry.mjs | 7 + .../coding-agent-practices/asset-baseline.mjs | 110 +++- scripts/coding-agent-practices/inventory.mjs | 22 +- scripts/core-change-watch/change-drift.mjs | 66 +- scripts/core-change-watch/common.mjs | 337 +++++++++- scripts/core-change-watch/core-candidates.mjs | 60 +- scripts/core-change-watch/diff-impact.mjs | 42 +- scripts/core-change-watch/evidence-pack.mjs | 38 +- .../core-change-watch/git-history-profile.mjs | 57 +- scripts/core-change-watch/project-profile.mjs | 83 ++- .../evidence-bundle/agent-customize.mjs | 2 + .../evidence-bundle/contract.mjs | 47 +- .../evidence-bundle/index.mjs | 26 +- .../evidence-bundle/project-harness.mjs | 15 +- .../evidence-bundle/session-evidence.mjs | 2 + .../harness-analysis/fluency-dimensions.mjs | 6 +- .../learning-capture-evidence.mjs | 25 +- .../harness-analysis/practice-findings.mjs | 77 ++- .../harness-analysis/record-fix-output.mjs | 48 +- scripts/harness-analysis/render-report.mjs | 38 +- .../renderers/better-harness.mjs | 6 +- .../harness-analysis/repair-findings-json.mjs | 4 +- .../harness-analysis/report-data-schema.mjs | 25 +- scripts/harness-analysis/report-run.mjs | 2 + .../harness-analysis/report-source/source.mjs | 4 + scripts/harness-analysis/task-loop-report.mjs | 21 +- .../task-loop-repository-evidence.mjs | 46 +- scripts/harness-analysis/task-loop-source.mjs | 53 +- scripts/harness-analysis/validate-canvas.mjs | 12 +- scripts/npm-package/verify-pack.mjs | 12 + scripts/session-analysis/platforms/claude.mjs | 113 +++- scripts/session-analysis/platforms/codex.mjs | 188 ++++-- scripts/session-analysis/platforms/cursor.mjs | 80 ++- scripts/session-analysis/platforms/qoder.mjs | 244 ++++++-- scripts/session-analysis/provider-runner.mjs | 377 ++++++++++- scripts/session-analysis/workspace-match.mjs | 399 ++++++++++++ scripts/workspace-topology/cli.mjs | 98 +++ scripts/workspace-topology/contract.mjs | 276 +++++++++ scripts/workspace-topology/finding-target.mjs | 132 ++++ scripts/workspace-topology/index.mjs | 181 ++++++ scripts/workspace-topology/inventory.mjs | 308 +++++++++ scripts/workspace-topology/manifests.mjs | 356 +++++++++++ skills/better-harness/SKILL.md | 8 +- .../references/finding-bound-fix.md | 46 +- test/agent-asset-baseline.test.mjs | 105 +++- test/agent-lint-monorepo.test.mjs | 172 ++++++ test/better-harness-cli.test.mjs | 32 +- test/better-harness-evidence-bundle.test.mjs | 119 +++- test/better-harness-skill.test.mjs | 14 + test/core-change-watch-scope.test.mjs | 318 ++++++++++ .../harness-help.txt | 2 + .../scripts-refactor-contract/root-help.txt | 7 +- test/harness-canvas-validation.test.mjs | 2 + test/harness-findings-repair.test.mjs | 14 + test/harness-report-render-cli.test.mjs | 180 ++++++ test/host-plugin-artifact.test.mjs | 6 + test/maturity-models.test.mjs | 4 +- test/plugin-manifests.test.mjs | 2 + test/scripts-refactor-contract.test.mjs | 6 +- test/session-workspace-match.test.mjs | 303 +++++++++ test/session-workspace-provider.test.mjs | 584 ++++++++++++++++++ test/task-loop-report.test.mjs | 235 ++++++- test/task-loop-source.test.mjs | 5 +- test/workspace-topology.test.mjs | 422 +++++++++++++ 68 files changed, 6728 insertions(+), 489 deletions(-) create mode 100644 scripts/session-analysis/workspace-match.mjs create mode 100644 scripts/workspace-topology/cli.mjs create mode 100644 scripts/workspace-topology/contract.mjs create mode 100644 scripts/workspace-topology/finding-target.mjs create mode 100644 scripts/workspace-topology/index.mjs create mode 100644 scripts/workspace-topology/inventory.mjs create mode 100644 scripts/workspace-topology/manifests.mjs create mode 100644 test/agent-lint-monorepo.test.mjs create mode 100644 test/core-change-watch-scope.test.mjs create mode 100644 test/session-workspace-match.test.mjs create mode 100644 test/session-workspace-provider.test.mjs create mode 100644 test/workspace-topology.test.mjs diff --git a/docs/specs/2026-07-25-monorepo-workspace-support.md b/docs/specs/2026-07-25-monorepo-workspace-support.md index 5718cb3..31b4a62 100644 --- a/docs/specs/2026-07-25-monorepo-workspace-support.md +++ b/docs/specs/2026-07-25-monorepo-workspace-support.md @@ -3,112 +3,411 @@ ## Traceability - Spec ID: `2026-07-25-monorepo-workspace-support` -- Status: Draft -- Spike target: representative VS Code-fork monorepo (single git root, no +- Status: Implemented and locally validated. +- Implementation branch: `feat/monorepo-workspace-topology` +- Story: unavailable; this is a maintainer-requested product correctness change. +- AI involvement: Codex implemented the change and used independent delegated + reviews; all conclusions below are backed by local source and test evidence. +- Spike target: representative VS Code-fork monorepo with one Git root, no `package.json#workspaces`, convention packages under `extensions/*`, - `src/product/**`, `native/*`, `cli`, and `build`) + `src/product`, `native/*`, `cli`, and tracked source under `build/`. ## Intent -`/better-harness` currently treats `--workspace ` as one flat project. On a monorepo -the three evidence lanes each degrade in a different way: the asset lane misses nested -instruction files or counts build-artifact copies, the session lane cannot bridge repo-root -sessions to a package target, and the project lane silently widens package targets to whole-repo -git history. This spec records the spike evidence and defines the smallest implementation that -makes monorepo targets first-class. +`/better-harness` currently treats `--workspace ` as one flat project. +On a monorepo the asset, session, project, and lead-report paths can disagree +about the analyzed target. That can omit inherited Agent assets, include ignored +artifact copies, attribute sibling-package activity to the target package, widen +Git evidence to the entire repository, or route a repair to the wrong owner. -## Spike Evidence (2026-07-25, against a representative editor monorepo) +Make workspace topology a frozen, versioned foundation of every Evidence Bundle. +One bundle still analyzes one requested target, but every collector and the final +lead report must use the same target identity, path scope, ownership coordinates, +coverage state, and truncation state. -Ground truth in the reviewed target: tracked `AGENTS.md` guides outside the root -under `extensions/*`, `src/product/**`, `native/*`, and `.ci/`, plus root -`AGENTS.md`/`CLAUDE.md` and root `.agents/`, `.claude/`, and `.cursor/` assets. -Gitignored copies exist under `.build/extensions/*` and `out*/`; `build/` is tracked source. +## Spike Evidence -| # | Probe | Observed | Gap | -|---|-------|----------|-----| -| E1 | `asset-baseline --workspace ` (qajaq provider) | `entrypoints: 1, documents: 1` | All nested `AGENTS.md` invisible: `collectNestedEntrypoints` returns `[]` when `provider === "qajaq"` (`scripts/agent-lint/index.mjs`) | -| E2 | `discoverAgentEntrypoints` (codex provider) | 53 entrypoints, including `out/**`, `out-build/**`, `.build/**` copies | Nested walk is not gitignore-aware; `DEFAULT_EXCLUDED_DIRS` is a hardcoded list that misses `.build`/`out*` yet wrongly excludes the tracked source dir `build/` | -| E3 | `asset-baseline --workspace /extensions/assistant` | `skills: 0, agents: 0`, only the local `AGENTS.md` | Package targets lose all inherited root assets (`.agents/skills`, `.claude/agents`, root `AGENTS.md`); no "inherited from git root" scope | -| E4 | Session matching, all four platforms | `resolved === workspace \|\| resolved.startsWith(workspace + sep)` | Package target misses sessions whose `cwd` is the repo root (the common agent launch point); root target mixes every package's sessions with no segmentation | -| E5 | `core-change-watch` lanes | `resolveRepoRoot` = `git rev-parse --show-toplevel`, no path filter | A package target silently profiles whole-monorepo history; findings attribute repo-wide churn to the package | -| E6 | `agent-lint --scan-children` | Direct children only, one level, not manifest-aware, not wired into `asset-baseline`/`evidence-bundle`/SKILL.md | Existing multi-project mode cannot reach `extensions/` from root and is unreachable from the skill pipeline | +The 2026-07-25 spike found: -Walk safety: `listFiles` in `agent-lint` has no file-count cap; on this repo the codex-provider -walk traverses `out*`/`.build` artifact trees (tens of thousands of files) before filtering. +| ID | Probe | Observed gap | +|----|-------|--------------| +| E1 | `asset-baseline --workspace ` with Qoder | Nested `AGENTS.md` files were not represented in the effective asset baseline. | +| E2 | `discoverAgentEntrypoints` with Codex | The filesystem walk included ignored `.build/**` and `out*/**` copies, skipped tracked `build/**` source, and had no file cap. | +| E3 | Package-target asset baseline | Root instructions and provider assets disappeared because owner routes outside the requested workspace were discarded. | +| E4 | Session matching on all four providers | Matching was based on direct CWD prefixes. Package targets missed root-CWD sessions, while admitting a whole mixed session would contaminate package facts. | +| E5 | `core-change-watch` | `resolveRepoRoot` widened tracked files, history, diff, drift, and companion candidates to the whole repository. | +| E6 | Evidence Bundle lead | Specialist lanes and the lead analyzer independently rescanned the same flat workspace, so fixing only the lanes would not fix the final report. | +| E7 | Finding validation | Durable finding contracts did not contain a structured target/package route, while rejecting unsupported fields. | + +The representative repository contains tracked guides below `extensions/*`, +`src/product/**`, `native/*`, and `.ci/`; root `AGENTS.md`/`CLAUDE.md` and root +provider assets; ignored copies below `.build/**` and `out*/**`; and tracked +source below `build/**`. + +## Contract Decisions + +### Public owner + +`scripts/workspace-topology/` is an atomic public capability. It owns: + +- `index.mjs`: public programmatic resolver and validators; +- `contract.mjs`: versioned runtime shape and normalization; +- `cli.mjs`: parser-safe diagnostic CLI; +- capability-local discovery helpers and tests. + +Consumers import only `scripts/workspace-topology/index.mjs`; they do not import +private discovery helpers. Evidence Bundle resolves topology once and freezes it +in the context. Direct diagnostic commands call the same public resolver when a +validated frozen topology was not supplied. + +### Canonical path coordinates + +- All Git-backed `route` values are Git-root-relative POSIX paths. +- `"."` denotes the Git root. +- `requestedWorkspace` and `gitRoot` are normalized absolute paths. +- `target.route` is the exact requested route. +- `target.memberRoute` is the owning member boundary when one exists. +- `localRoute` is relative to the requested workspace and is never substituted + for an owner route. +- Containment uses path segments and real paths; sibling prefixes, `..`, and + symlink escapes do not match. + +### Workspace topology + +The version 1 shape is: + +```text +kind: better-harness.workspace-topology +schemaVersion: 1 +status: complete | partial +requestedWorkspace +gitRoot: absolute-path | null +target: + kind: repo-root | workspace-member | repo-subtree | standalone + route: git-root-relative route | "." + memberRoute: git-root-relative route | null + memberMatch: exact | descendant | none +members: + items[]: + route + kind: manifest | convention + discoveredBy[] + manifestRoute: route | null + total + omitted + truncated +instructionScopes: + items[]: + route + provider + activation: effective | candidate + total + omitted + truncated +discovery: + inventoryMode: git | filesystem-fallback + ignoreMode: git-index | static + tracked + untracked + scanned + omitted + truncated + warnings[] +``` + +`analysisScope` is derived and frozen next to topology in the Evidence Bundle +context: + +```text +kind: repo | path +route +pathspecs[] +``` + +Lane observations such as `inheritedAssets`, `ignoredArtifacts`, +`pathScopedHistory`, and `sessionWorkspaceMatch` do not belong in structural +topology. Each lane reports a `scopeAttribution` and its own coverage envelope +against the frozen topology. + +### Member discovery + +Member discovery is deterministic: + +1. Prefer explicit workspace manifests: + `package.json#workspaces`, `pnpm-workspace.yaml`, `go.work`, and Cargo + `[workspace].members`. +2. Expand only existing paths contained by the Git root. Apply manifest + exclusions where the manifest format supports them. +3. A nested package manifest not already covered by an explicit workspace can + provide a convention member. +4. Tracked instruction files are instruction scopes, not package boundaries. + They provide a package fallback only inside recognized package containers + (`packages/*`, `apps/*`, `extensions/*`, `plugins/*`, `services/*`, + `native/*`, `src/product`, and the exact root `cli`) or a directory with a + package manifest. Tracked `build/**` remains source but is not a member + boundary unless a manifest establishes it. +5. Hidden operational directories such as `.ci` remain instruction scopes. +6. Normalize duplicates, prefer manifest evidence over convention evidence, and + use the longest containing member route for target ownership. +7. A Git subdirectory without a member boundary is `repo-subtree`, not + `standalone`. +8. `members.items` is stable-sorted. The target member is always retained when + bounded output truncates other members. + +### Inventory and ignored/generated classification + +In a Git repository, discovery uses tracked files plus untracked, non-ignored +files (`git ls-files --cached --others --exclude-standard`). Inventory records +tracked versus untracked provenance. A tracked path is not classified as +generated solely because a path segment is named `build`, `dist`, or `out`. +Ignored files are excluded from normal evidence. + +Filesystem fallback preserves static exclusions and emits +`inventoryMode: "filesystem-fallback"`. Every bounded collection reports stable +ordering, `total`, `omitted`, and `truncated`. If truncation can hide a member, +instruction owner, or target activity, normal-depth bundles fail closed and +quick bundles are at least partial. + +### Agent asset activation + +Discovery and activation are separate: + +- every tracked instruction asset may be inventoried with an owner route; +- `activation: "effective"` requires provider-specific semantics; +- unsupported or unverified nested instructions remain `candidate` and cannot + be presented as active inherited policy. + +The initial capability matrix follows the current provider contracts: + +| Provider | Effective instruction ancestry | +|----------|--------------------------------| +| Codex | Root-to-target `AGENTS.md` ancestry. | +| Claude | Ancestor `CLAUDE.md`; descendant instructions remain on-demand/candidate until file activity establishes their scope. | +| Cursor | Scoped `.cursor/rules`; nested `AGENTS.md` remains candidate unless the installed provider contract proves activation. | +| Qoder | Root project `AGENTS.md` and project Rules; nested `AGENTS.md` remains candidate unless the installed provider contract proves activation. | + +Assets preserve both ownership and applicability: + +```text +originRoute +originScope: project | inherited +effectiveTarget +localRoute +activation +``` + +### Session attribution + +Session bridging is fail-closed: + +1. Direct package CWD matching keeps existing behavior and is labelled + `direct-cwd`. +2. Only a session whose CWD is exactly `gitRoot` can become a root-CWD + candidate for a package target. +3. Provider discovery includes both package and Git-root transcript identities + before session selection. +4. A bounded preflight extracts only trusted file/tool path facts before + selection. Prompt prose is not treated as path evidence. +5. Root-CWD candidates are included only when they contain positive target + activity, contain no sibling-package activity, and were not truncated. +6. Hydrated events are checked again. A mixed, ambiguous, or newly truncated + root-CWD session is omitted as a whole; version 1 does not slice events into + package-owned facts. +7. Diagnostics expose aggregate omitted counts without leaking absolute paths + or session identifiers. + +### Durable findings and repair routing + +Agent Work Loop report contract version 26 introduces the durable finding +target: + +```text +target: + kind: repo-root | workspace-member | repo-subtree | standalone + packageRoute: route | null + ownerRoute: route | null +``` + +Existing report contracts 1–25 without `target` remain readable. Version 26 or +newer package-scoped findings must carry `target` with a non-null `ownerRoute`; +validators reject partial topology and any package, kind, owner, or workspace +mismatch. A present `target`, including `null`, is never treated as legacy. +Renderers and finding-bound repair flows preserve the structure rather than +reconstructing it from prose. For structured findings, `Project` +`actualOutput.path` is Git-root-relative (standalone-workspace-relative when no +Git root exists), so an inherited root owner remains openable without `..`. ## Acceptance Scenarios -- **AC1 (topology resolution):** Given any `--workspace`, the evidence-bundle context freezes a - `topology` object: `gitRoot`, `targetKind` (`repo-root` | `package` | `standalone`), - `packageRelPath` (when `targetKind: package`), and bounded `memberPackages[]` discovered from - workspace manifests (`package.json#workspaces`, `pnpm-workspace.yaml`, `go.work`, Cargo - workspace) plus tracked nested instruction files as a convention fallback. The representative - editor monorepo (no manifest) resolves `repo-root` with `extensions/*`, `src/product`, and - `native/*` members. -- **AC2 (nested asset discovery):** Root-target asset baseline on the representative monorepo reports all tracked - nested `AGENTS.md` guides for every provider, each tagged with a `packageRoute`; the qajaq - early-return in `collectNestedEntrypoints` is removed. -- **AC3 (gitignore-aware walk):** Nested discovery excludes gitignored paths (via - `git ls-files`/`check-ignore` when a git root exists, falling back to the static exclude list), - no longer skips tracked dirs that merely share a name with build outputs, and enforces a file - cap with an explicit `truncated` marker. -- **AC4 (inherited scope):** A package target reports git-root assets (root instruction files, - provider dirs) with `scope: "inherited"`, distinct from package-local `project` assets, without - double counting when the target is the root itself. -- **AC5 (session bridging):** For a package target, sessions with `cwd` at the git root are - included when session facts show file activity under `packageRelPath`, labelled - `workspaceMatch: "root-cwd"`; direct-cwd matching is unchanged. Root targets stay inclusive. -- **AC6 (scoped project history):** For a package target, `core-change-watch` git commands are - path-scoped (`git log -- ` etc.); repo-root behavior is unchanged. -- **AC7 (skill contract):** SKILL.md Step 1 resolves and states the target kind; findings that - originate from one package carry its route in the finding target so repairs land in the right - package. +- **AC1 — Topology resolution:** Root, member, repository-subtree, and + standalone targets resolve to the versioned topology shape with canonical + routes and deterministic ownership. +- **AC2 — Member discovery:** Supported workspace manifests and convention + fallback produce stable, de-duplicated members with provenance. Instruction + scopes such as `.ci/AGENTS.md` do not create false packages. +- **AC3 — Bounded Git-aware inventory:** Tracked and untracked non-ignored + files are considered; ignored copies are excluded; every cap reports + `total`, `omitted`, and `truncated`; filesystem fallback is explicit. +- **AC4 — Asset ownership and activation:** Root-target discovery inventories + nested guides with owner routes. Package-target baselines retain the ordered + root-to-target inheritance chain without double counting and distinguish + effective from candidate assets. +- **AC5 — Session bridging:** All four providers retain direct-CWD behavior, + discover root-CWD candidates before selection, include target-only activity as + `root-cwd`, and omit mixed, ambiguous, or truncated candidates from package + facts. +- **AC6 — Scoped project evidence:** Package and repo-subtree targets apply one + literal path scope to project profile, history, core candidates, diff, + untracked files, drift companions, recommendations, and returned paths. +- **AC7 — Tracked source classification:** Tracked `build/**` source remains + eligible while ignored `.build/**` and `out*/**` copies remain excluded. +- **AC8 — Frozen composition:** Evidence Bundle resolves topology once. Session, + project, customize, and lead analysis consume the same validated topology and + analysis scope without independently widening it. +- **AC9 — Finding route:** Package-scoped findings preserve a structured route + through candidate projection, validation, report rendering, persistence, and + finding-bound repair. +- **AC10 — Coverage propagation:** Missing or truncated topology/session/asset + ownership evidence makes normal bundles failed and quick bundles partial; no + collector claims complete coverage after a relevant cap. +- **AC11 — Cross-platform CLI:** `workspace-topology --workspace --json` + is parser-safe and behaves consistently on Windows, macOS, and Linux path + conventions. +- **AC12 — Skill contract:** `/better-harness` Step 1 states the resolved target + kind and route, and instructs repairs to use the finding owner route. ## Non-goals -- No multi-package fan-out report (one bundle per member package) in this iteration; the unit of - analysis stays one target, now correctly scoped. -- No new provider surfaces, no Memory/authority model changes. -- No git submodule or nested-git-repo topology support. -- No change to `findings.json` schema fields; package routes reuse existing target/route strings. - -## Plan - -1. **Topology owner** — add `scripts/harness-analysis/workspace-topology.mjs`: git root probe, - manifest parsing, convention fallback (tracked nested instruction files / `package.json` - markers), bounded and gitignore-aware; freeze the result into - `evidence-bundle/contract.mjs` context. (AC1) -2. **Asset lane** — in `agent-lint`: drop the qajaq nested early-return, route walks through the - topology's ignore rules and file cap, tag entrypoints with `packageRoute`; in - `asset-baseline`/`agent-customize`: collect git-root primitives as `inherited` for package - targets. Reuse `--scan-children` internals where they fit, driven by `memberPackages` instead - of direct-child listing. (AC2, AC3, AC4) -3. **Session lane** — extend the shared `isWorkspaceMatch` in the four platform collectors with - the root-cwd bridge, using existing file-read facts; keep the label on the session summary so - downstream facts stay auditable. (AC5) -4. **Project lane** — thread `packageRelPath` from bundle context into - `core-change-watch/common.mjs` git invocations as a pathspec. (AC6) -5. **Skill + docs** — update `skills/better-harness/SKILL.md` Step 1 scope resolution and the - relevant references; regenerate the doc-link graph. (AC7) -6. **Tests** — fixture monorepo under `test/fixtures/` mirroring the representative shape (nested - guides, gitignored artifact copies, tracked `build/` source dir); node tests per AC; forward - test re-runs the E1/E2/E3 probes and asserts the corrected counts. - -## Risks - -- Gitignore-aware walking must not regress non-git targets; fallback path keeps today's behavior. -- Root-cwd session bridging can over-match when file facts are sparse; the bridge only fires on - positive file-path evidence, never on absence. -- Larger nested inventories may push `MAX_BASELINE_OWNER_ROUTES`/finding caps; caps stay, with - `omitted` counts already in the contract. +- No automatic fan-out report across every member; one bundle analyzes one + requested target. +- No event-level splitting of mixed root-CWD sessions in version 1. +- No new Coding Agent provider and no Memory/authority-model change. +- No nested Git repository, submodule ownership, or cross-repository topology. +- No general-purpose package-manager implementation beyond member discovery. +- No mutation of ignored artifacts and no inference of active provider rules + from file names alone. + +## Plan and Tasks + +### Slice 0 — Contract and fixture + +- Tighten this spec and remove stale provider naming. +- Add a representative temporary-Git fixture builder covering manifests, + convention members, nested instructions, ignored copies, tracked + `build/**`, noisy siblings, and special-character routes. +- Add topology contract and CLI tests before consumer integration. + +### Slice 1 — Public topology capability + +- Add `scripts/workspace-topology/{index,contract,cli}.mjs` and narrow discovery + helpers. +- Resolve real paths, Git root, inventory, manifests, convention members, + instruction scopes, target identity, coverage, and analysis scope. +- Register a maintainer/advanced CLI route only if the root CLI registry + contract permits a diagnostic capability without broadening workflow help. + +### Slice 2 — Static evidence integration + +- Resolve and freeze topology in `evidence-bundle`. +- Pass the same context to session, project, customize, and lead analysis. +- Make Agent asset discovery Git-aware and bounded. +- Preserve origin/effective routes and inherited ancestry in inventory and + baseline contracts. +- Thread analysis scope through every `core-change-watch` analyzer and make + tracked provenance override basename-only generated heuristics. + +### Slice 3 — Session attribution + +- Add a shared workspace-match/qualification module. +- Expand provider source discovery to package and Git-root transcript + identities. +- Run bounded target-path preflight before selection and recheck after + hydration. +- Omit mixed/ambiguous root-CWD sessions and propagate privacy-safe diagnostics. + +### Slice 4 — Finding and workflow routing + +- Version the finding target contract with backward-compatible readers. +- Preserve target through specialist candidate projection, final report, + renderer, persistence, and finding-bound repair. +- Update `skills/better-harness/SKILL.md` and routed references. +- Regenerate `docs/better-harness-doc-links.mmd`. + +### Slice 5 — Verification and readiness + +- Run AC-focused tests and all existing capability tests. +- Run `npm test`, `npm run pack:verify`, and `npm run preview`. +- Smoke-test `http://localhost:58575/health` and `/canvas-module.js`; inspect + browser console/page errors when report rendering changes. +- Run Change Traceability Review in Review Readiness mode over the full local + diff, including generated files and staged/unstaged separation. + +## Test and Review Evidence + +| AC | Required automated evidence | +|----|-----------------------------| +| AC1–AC3, AC11 | `test/workspace-topology.test.mjs`, including root/member/subtree/standalone, manifest variants, ignored/untracked files, cap boundaries, symlink escape, special characters, and CLI JSON. | +| AC4 | Agent lint, agent customize, and asset baseline tests with root/intermediate/package assets, candidate activation, owner routes, and no duplicates. | +| AC5 | Shared matcher tests plus four-provider root-CWD fixtures covering direct, target-only, no-target, mixed, truncated, and post-hydration foreign activity. | +| AC6–AC7 | Real temporary Git repository and command-spy tests covering history, diff, untracked, rename boundary, companions, noisy sibling, and tracked `build/**`. | +| AC8, AC10 | Evidence Bundle dependency-spy tests proving one topology resolution, object identity/binding, lead propagation, and partial/failed coverage. | +| AC9 | Finding schema, report projection, renderer, persistence, and wrong-package rejection tests. | +| AC12 | Skill contract assertions and doc-link graph validation. | + +Baseline and final commands: + +```bash +node --test test/workspace-topology.test.mjs +node --test test/agent-lint.test.mjs test/agent-asset-baseline.test.mjs +node --test test/core-change-watch.test.mjs +node --test test/session-workspace-match.test.mjs test/session-analysis-providers.test.mjs test/session-analysis.test.mjs +node --test test/better-harness-evidence-bundle.test.mjs +node --test test/doc-link-graph.test.mjs +npm test +npm run pack:verify +npm run preview +``` + +## Risks and Fail-closed Rules + +- **Large repository inventory:** stream or bound discovery, stable-sort retained + output, and surface omitted counts. Never silently claim complete coverage. +- **Provider storage drift:** isolate provider transcript-root expansion behind + provider tests; unknown layouts remain unavailable rather than guessed. +- **Sparse path facts:** root-CWD bridging requires positive trusted activity. + Sparse, ambiguous, mixed, or truncated evidence is omitted. +- **Path traversal and platform differences:** resolve real paths, use + segment-safe containment, preserve argv arrays, and test Windows path + normalization without shell strings. +- **Schema migration:** accept legacy findings for reading, emit the new target + only when topology proves ownership, and reject cross-target repair. +- **Performance regression:** topology is resolved once per bundle and reused; + direct diagnostic commands remain independently runnable. +- **Spec drift:** implementation slices must map changed modules and tests back + to AC ids during Review Readiness. ## Evidence Log -- E1–E3, E6 probe commands and outputs captured in the 2026-07-25 spike session - (`asset-baseline` runs against the representative root and `extensions/assistant`; - `discoverAgentEntrypoints` with codex provider returned 53 entrypoints, 42 from - gitignored trees). -- E4/E5 from source: `scripts/session-analysis/platforms/{claude,codex,cursor,qajaq}.mjs` - prefix match; `scripts/core-change-watch/common.mjs` `resolveRepoRoot`. -- Representative monorepo ground truth: `git check-ignore` confirms `.build/`, `out*` ignored; - `git ls-files build` confirms `build/` tracked; `package.json` has no `workspaces` field. +- 2026-07-25: initial representative-monorepo spike recorded E1–E5. +- 2026-07-29: source audit added E6/E7 and found that provider session matchers + are private, root transcript discovery precedes hydration, project scoping is + distributed across analyzers, tracked `build/**` is still filtered, and the + lead path independently rescans the workspace. +- 2026-07-29: three independent reviews all returned `fail` with uncleared + P1/P2 findings. Their common blockers were root-CWD session contamination, + missing durable owner routes, incomplete Git scoping, tracked-build + misclassification, ambiguous member fallback, and missing truncation + propagation. +- 2026-07-29 baseline: targeted evidence tests passed `101/101`; they validate + existing flat-workspace behavior but do not cover AC1–AC12. +- 2026-07-29 implementation: AC-focused topology, scope, render, repair, asset, + session, and bundle tests passed `156/156`. After syncing `origin/main`, the + conflict-focused suite passed `34/34` and the full suite passed `924/924`. +- 2026-07-29 packaging and runtime: `npm run pack:verify` passed with 316 npm + entries and 342 runtime-zip entries; preview `/health` returned `ok` and + `/canvas-module.js` returned the transformed report module. The direct doc + graph and Better Harness skill checks passed `18/18`. +- 2026-07-29 Review Readiness: Story is explicitly unavailable, this Spec maps + AC1–AC12 to local tests, risk and AI markers are visible, `git diff --check` + passes, no generated doc graph is stale, and the local diff remains one + monorepo-support change without an unrelated staged/unstaged split. diff --git a/scripts/agent-customize/core/items.mjs b/scripts/agent-customize/core/items.mjs index af53428..7e575b3 100644 --- a/scripts/agent-customize/core/items.mjs +++ b/scripts/agent-customize/core/items.mjs @@ -719,7 +719,7 @@ function allowedSourceScopes(scopeKind = "user", tab = "plugins") { return tab === "mcps" ? new Set(["team", "dashboard"]) : new Set(["team"]); } if (scopeKind === "workspace" || scopeKind === "project") { - return new Set(["project"]); + return new Set(["project", "inherited"]); } return new Set(["user", "plugin"]); } diff --git a/scripts/agent-lint/index.mjs b/scripts/agent-lint/index.mjs index 205e768..e111441 100644 --- a/scripts/agent-lint/index.mjs +++ b/scripts/agent-lint/index.mjs @@ -6,6 +6,7 @@ import { parseFrontmatter } from "../agent-customize/core/items.mjs"; import { enrichFindingWithRecommendation } from "../findings-recommend.mjs"; import { isDirectory, pathExists } from "../session-analysis/fs.mjs"; import { normalizeWorkspace } from "../session-analysis/paths.mjs"; +import { ownerRouteForPath, routeContains } from "../workspace-topology/index.mjs"; import { reviewHostInstructions } from "./host-instructions.mjs"; import { reviewHookAssets } from "./hook-review.mjs"; @@ -480,9 +481,81 @@ async function collectNestedEntrypoints(workspace, maxEntrypointDepth, provider) })); } +function topologyScopeOwnerRoute(route) { + if (route === ".claude/CLAUDE.md" || route === ".github/copilot-instructions.md") return "."; + for (const marker of ["/.claude/rules/", "/.cursor/rules/", "/.qoder/rules/"]) { + const normalized = `/${route}`; + const index = normalized.indexOf(marker); + if (index !== -1) { + return normalized.slice(1, index) || "."; + } + } + const owner = path.posix.dirname(route); + return owner === "." ? "." : owner; +} + +function topologyScopeSourceKind(route) { + const base = path.posix.basename(route); + if (route === ".github/copilot-instructions.md") return "copilot-instructions"; + if (route.includes("/.claude/rules/") || route.startsWith(".claude/rules/")) return "claude-rule"; + if (route.includes("/.cursor/rules/") || route.startsWith(".cursor/rules/")) return "cursor-rule"; + if (route.includes("/.qoder/rules/") || route.startsWith(".qoder/rules/")) return "qoder-rule"; + if (base === "AGENTS.md") return route === "AGENTS.md" ? "agents-md" : "nested-agent-guide"; + if (base === "CLAUDE.md") return route === "CLAUDE.md" ? "claude-md" : "nested-agent-guide"; + if (base === "CLAUDE.local.md") return "claude-local"; + return "nested-agent-guide"; +} + +function topologyScopeApplies(topology, scope) { + if (topology.target.route === ".") return true; + const ownerRoute = topologyScopeOwnerRoute(scope.route); + return routeContains(ownerRoute, topology.target.route) + || routeContains(topology.target.route, ownerRoute); +} + +async function topologyEntrypoints(topology, provider) { + const workspace = normalizeWorkspace(topology.gitRoot ?? topology.requestedWorkspace); + const selected = (topology.instructionScopes?.items ?? []) + .filter((scope) => !provider || scope.provider === provider) + .filter((scope) => topologyScopeApplies(topology, scope)); + const grouped = new Map(); + + for (const scope of selected) { + const current = grouped.get(scope.route); + const providers = [...new Set([...(current?.providers ?? []), scope.provider])].sort(); + grouped.set(scope.route, { + route: scope.route, + providers, + activation: current && (current.activation !== "effective" || scope.activation !== "effective") + ? "candidate" + : scope.activation, + }); + } + + const entrypoints = []; + for (const scope of [...grouped.values()].sort((left, right) => left.route.localeCompare(right.route))) { + const filePath = path.join(workspace, ...scope.route.split("/")); + if (!await pathExists(filePath)) continue; + const sourceKind = topologyScopeSourceKind(scope.route); + entrypoints.push({ + path: filePath, + relativePath: scope.route, + sourceKind, + nested: sourceKind === "nested-agent-guide", + activation: scope.activation, + packageRoute: ownerRouteForPath(topology, scope.route), + ...(provider ? { provider } : { providers: scope.providers }), + }); + } + return entrypoints; +} + export async function discoverAgentEntrypoints(options = {}) { - const workspace = normalizeWorkspace(options.workspace); + const topology = options.topology; const provider = options.provider ? String(options.provider).toLowerCase() : undefined; + if (topology) return topologyEntrypoints(topology, provider); + + const workspace = normalizeWorkspace(options.workspace); const maxEntrypointDepth = Number(options.maxEntrypointDepth ?? options["max-entrypoint-depth"] ?? 4); const entrypoints = []; @@ -537,7 +610,9 @@ function summarizeEntrypoints(entrypoints) { } export async function collectAgentInstructionGraph(options = {}) { - const workspace = normalizeWorkspace(options.workspace); + const workspace = options.topology + ? normalizeWorkspace(options.topology.gitRoot ?? options.topology.requestedWorkspace) + : normalizeWorkspace(options.workspace); const maxReferenceDepth = Number(options.maxReferenceDepth ?? options["max-reference-depth"] ?? 0); const entrypoints = await discoverAgentEntrypoints({ ...options, workspace }); const queue = entrypoints.map((entrypoint) => ({ ...entrypoint, filePath: entrypoint.path, depth: 0 })); @@ -556,6 +631,10 @@ export async function collectAgentInstructionGraph(options = {}) { const parsed = await parseFile(current.filePath, workspace, { sourceKind: current.sourceKind, entrypoint: current.depth === 0, + ...(current.activation ? { activation: current.activation } : {}), + ...(current.packageRoute ? { packageRoute: current.packageRoute } : {}), + ...(current.provider ? { provider: current.provider } : {}), + ...(current.providers ? { providers: current.providers } : {}), }); const references = []; for (const link of parsed.links) { @@ -734,6 +813,8 @@ function finding(id, severity, evidence, remediation, options = {}) { "assetName", "scope", "sourceLabel", + "packageRoute", + "ownerRoute", ]) { if (options[key] !== undefined) { result[key] = options[key]; @@ -1576,18 +1657,29 @@ async function singleWorkspacePayload(options = {}) { : options.profile === PROFILE_AGENT_ASSETS_REVIEW ? await applyAgentAssetsReviewProfile(graph, options) : { findings: [], manifestEvidence: [], assetInventory: undefined }; + const findings = profileResult.findings.map((item) => { + if (!options.topology || typeof item?.file !== "string") return item; + const route = normalizeSlash(item.file); + if (!route || path.isAbsolute(route) || route === ".." || route.startsWith("../")) return item; + const packageRoute = ownerRouteForPath(options.topology, route); + return { + ...item, + packageRoute, + ownerRoute: packageRoute, + }; + }); return { kind: "agent-lint", profile: options.profile, summary: { ...summarizeGraph(graph), - ...summarizeFindings(profileResult.findings), + ...summarizeFindings(findings), }, graph, manifestEvidence: profileResult.manifestEvidence, hostInstructionReview: profileResult.hostInstructionReview, assetInventory: profileResult.assetInventory, - findings: profileResult.findings, + findings, }; } diff --git a/scripts/better-harness-cli/cli.mjs b/scripts/better-harness-cli/cli.mjs index 0a3dd0e..705086e 100644 --- a/scripts/better-harness-cli/cli.mjs +++ b/scripts/better-harness-cli/cli.mjs @@ -65,6 +65,7 @@ const GROUP_EXAMPLES = { "harness": [ { audience: "workflow", text: "better-harness harness analyze --workspace . --language en --format json" }, { audience: "workflow", text: "better-harness harness checkup --phase scan --provider qoder --workspace . --json" }, + { audience: "advanced", text: "better-harness harness workspace-topology --workspace . --json" }, { audience: "maintainer", text: "better-harness harness source --workspace . --source /report.source.json --language en" }, { audience: "advanced", text: "better-harness harness render --findings /findings.json --mode qoder-canvas --out .qoder/better-harness --target . --validate --json" }, { audience: "advanced", text: "better-harness harness preview-canvas /report.canvas.tsx --open" }, diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 7aba164..e1bd964 100644 --- a/scripts/better-harness-cli/registry.mjs +++ b/scripts/better-harness-cli/registry.mjs @@ -169,6 +169,13 @@ const COMMANDS = [ summary: "Collect the three specialist lanes and lead evidence in one frozen-context bundle.", description: "Return versioned Session Evidence, Project Harness, and Agent Customize envelopes with explicit lane status and unchanged diagnostic commands.", }, + { + name: "workspace-topology", + audience: "advanced", + script: "workspace-topology/cli.mjs", + summary: "Resolve the Git-aware workspace target and member topology.", + description: "Report the canonical repository target, workspace members, instruction scopes, bounded inventory coverage, and path-scoped analysis contract without mutating the workspace.", + }, { name: "analyze", audience: "workflow", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index fb117cf..1f6da55 100644 --- a/scripts/coding-agent-practices/asset-baseline.mjs +++ b/scripts/coding-agent-practices/asset-baseline.mjs @@ -28,7 +28,7 @@ const OWNER_KIND_RANK = Object.freeze({ agents: 7, workflows: 8, }); -const OWNER_SCOPE_RANK = Object.freeze({ workspace: 0, project: 0, user: 1, plugin: 2 }); +const OWNER_SCOPE_RANK = Object.freeze({ workspace: 0, project: 0, inherited: 1, user: 2, plugin: 3 }); function text(value, limit = 320) { return String(value ?? "").replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit); @@ -67,6 +67,7 @@ function compactFindings(findings = []) { items: ordered.slice(0, MAX_BASELINE_FINDINGS).map(compactFinding), total: ordered.length, omitted: Math.max(0, ordered.length - MAX_BASELINE_FINDINGS), + truncated: ordered.length > MAX_BASELINE_FINDINGS, }; } @@ -91,7 +92,9 @@ function ownerRoutes(inventory, workspace) { name, version: text(item?.version, 32), owner: text(item?.pluginName ?? item?.ownerName ?? item?.sourceLabel, 96), - route: workspaceRoute(item?.path ?? item?.filePath, workspace), + route: text(item?.originRoute, 180) + || workspaceRoute(item?.path ?? item?.filePath, workspace), + effectiveTarget: text(item?.effectiveTarget, 180), }).filter(([, value]) => value !== undefined && value !== "")); const key = [route.kind, route.scope, route.name, route.version, route.owner, route.route].join(":"); if (!routes.has(key)) routes.set(key, route); @@ -125,6 +128,7 @@ function ownerRoutes(inventory, workspace) { items: selected, total: ordered.length, omitted: Math.max(0, ordered.length - MAX_BASELINE_OWNER_ROUTES), + truncated: ordered.length > MAX_BASELINE_OWNER_ROUTES, }; } @@ -197,6 +201,81 @@ function available(data) { return { status: "available", data }; } +function inheritedWorkspaceRoots(topology, workspace) { + if (!topology?.gitRoot + || !new Set(["workspace-member", "repo-subtree"]).has(topology.target?.kind)) { + return []; + } + const relative = path.relative(topology.gitRoot, workspace); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return []; + const parts = relative.split(path.sep).filter(Boolean); + const roots = [topology.gitRoot]; + for (let index = 1; index < parts.length; index += 1) { + roots.push(path.join(topology.gitRoot, ...parts.slice(0, index))); + } + return roots; +} + +function rawItemPath(item) { + return item?.evidence?.path ?? item?.filePath ?? item?.rootPath; +} + +function inheritedItem(item, topology) { + const filePath = rawItemPath(item); + const relative = filePath ? path.relative(topology.gitRoot, path.resolve(filePath)) : ""; + const originRoute = relative + && !relative.startsWith("..") + && !path.isAbsolute(relative) + ? relative.split(path.sep).join("/") + : undefined; + return { + ...item, + scope: "inherited", + originScope: "inherited", + originRoute, + effectiveTarget: topology.target.route, + }; +} + +function mergeInheritedInventories(projectInventory, inheritedInventories, topology) { + const manage = Object.fromEntries( + Object.entries(projectInventory.manage ?? {}).map(([collection, items]) => [collection, [...items]]), + ); + for (const inventory of inheritedInventories) { + for (const [collection, items] of Object.entries(inventory.manage ?? {})) { + const target = manage[collection] ?? []; + for (const item of items) { + if (item?.scope !== "project") continue; + const inherited = inheritedItem(item, topology); + const key = [ + inherited.id, + inherited.kind, + inherited.name ?? inherited.displayName ?? inherited.label, + rawItemPath(inherited), + ].join(":"); + if (!target.some((candidate) => [ + candidate.id, + candidate.kind, + candidate.name ?? candidate.displayName ?? candidate.label, + rawItemPath(candidate), + ].join(":") === key)) { + target.push(inherited); + } + } + manage[collection] = target; + } + } + return { + ...projectInventory, + manage, + diagnostics: { + ...(projectInventory.diagnostics ?? {}), + inheritedWorkspaceCount: inheritedInventories.length, + inheritedTargetRoute: topology.target.route, + }, + }; +} + export async function collectAssetBaseline(options = {}, dependencies = {}) { const provider = options.provider ?? options.platform ?? "qoder"; if (!PROVIDERS.has(provider)) { @@ -224,6 +303,20 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { let rawInventory; try { rawInventory = await collectRawInventory(common); + const inheritedRoots = inheritedWorkspaceRoots(options.topology, workspace); + if (inheritedRoots.length > 0) { + const inheritedInventories = []; + for (const inheritedWorkspace of inheritedRoots) { + inheritedInventories.push(await collectRawInventory({ + ...common, + workspace: inheritedWorkspace, + includeUserHome: false, + includeGlobalHooks: false, + includeMemories: false, + })); + } + rawInventory = mergeInheritedInventories(rawInventory, inheritedInventories, options.topology); + } } catch (error) { const failed = unavailable(error, "inventory"); return { @@ -262,10 +355,19 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { } const envelopes = { lint: lintEnvelope, inventory: inventoryEnvelope, integrity: integrityEnvelope }; const availableCount = Object.values(envelopes).filter((envelope) => envelope.status === "available").length; + const truncatedStages = [ + lintEnvelope.data?.findings?.truncated ? "lint-findings" : null, + inventoryEnvelope.data?.ownerRoutes?.truncated ? "inventory-owner-routes" : null, + integrityEnvelope.data?.findings?.truncated ? "integrity-findings" : null, + ].filter(Boolean); return { kind: ASSET_BASELINE_KIND, schemaVersion: ASSET_BASELINE_SCHEMA_VERSION, - status: availableCount === 3 ? "complete" : availableCount === 0 ? "failed" : "partial", + status: availableCount === 3 && truncatedStages.length === 0 + ? "complete" + : availableCount === 0 + ? "failed" + : "partial", scope: { provider, workspace, includeUserHome, includeMemories }, envelopes, diagnostics: { @@ -273,6 +375,8 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) { compact: true, findingLimitPerEnvelope: MAX_BASELINE_FINDINGS, ownerRouteLimit: MAX_BASELINE_OWNER_ROUTES, + inheritedWorkspaceCount: rawInventory?.diagnostics?.inheritedWorkspaceCount ?? 0, + truncatedStages, }, }; } diff --git a/scripts/coding-agent-practices/inventory.mjs b/scripts/coding-agent-practices/inventory.mjs index 353ba1e..1ef0132 100644 --- a/scripts/coding-agent-practices/inventory.mjs +++ b/scripts/coding-agent-practices/inventory.mjs @@ -485,6 +485,9 @@ function customizeItem(item) { sourceKind: item.sourceKind, precedence: item.precedence, scope: item.scope, + originScope: item.originScope, + originRoute: item.originRoute, + effectiveTarget: item.effectiveTarget, pluginId: item.pluginId, pluginName: item.pluginName, pluginEnabled: item.pluginEnabled, @@ -562,6 +565,21 @@ async function buildConfiguredAssetSurfaces(inventory, scope) { } } + for (const [collection, type, label] of surfaceTypes) { + const items = scopeItems(inventory, collection, "inherited"); + if (items.length > 0) { + surfaces.push(customizeSurface({ + provider, + group: "Inherited project assets", + scope: "inherited", + type, + label: `Inherited ${provider} ${label}`, + basePath: projectBase, + items, + })); + } + } + const projectWorkflows = await workflowItems(projectBase); if (projectWorkflows.length > 0) { surfaces.push(customizeSurface({ @@ -746,10 +764,12 @@ function practiceCoverageRows(surfaces, scope) { const scopes = [...new Set(matchedSurfaces.map((surface) => { if (surface.group === "Plugin/marketplace assets" || surface.scope === "plugin") return "Plugin"; if (surface.scope === "user") return "Global"; + if (surface.scope === "inherited") return "Inherited"; return "Project"; }))]; const paths = [...new Set([...uniqueItems.values()] - .map((item) => boundedReportPath(item.path ?? item.filePath ?? item.rootPath, scope.workspace)) + .map((item) => item.originRoute + ?? boundedReportPath(item.path ?? item.filePath ?? item.rootPath, scope.workspace)) .filter(Boolean))].slice(0, 12); rows.push({ surface: surfaceName, scopes, count: uniqueItems.size, paths }); } diff --git a/scripts/core-change-watch/change-drift.mjs b/scripts/core-change-watch/change-drift.mjs index 94d7765..95c3618 100644 --- a/scripts/core-change-watch/change-drift.mjs +++ b/scripts/core-change-watch/change-drift.mjs @@ -10,12 +10,17 @@ import { git, isCli, isDependencyOrGenerated, + isPathInAnalysisScope, languageFor, listTrackedFiles, + listUntrackedFiles, + literalGitPathspec, option, parseArgs, parseNumstat, - resolveRepoRoot, + publicAnalysisScope, + resolveAnalysisScopeForOptions, + scopePathspecArgs, toPosix, unique, writeJsonResult, @@ -65,23 +70,19 @@ const ERROR_PATH_RE = /(^|\/)(errors?|exceptions?|contracts?\/errors?)(\/|$)|err const CLI_PATH_RE = /(^|\/)(cli|cmd|commands?|bin)(\/|$)|(^|\/)(parser|args?|options?|command)\.(ts|tsx|js|mjs|cjs|go|java|py|php|rb|rs)$/i; const CLI_HUNK_RE = /\b(commander|yargs|argparse|cobra|clap|picocli|option|argument|flag|subcommand|--[a-z0-9][a-z0-9-]*)\b/i; -function changedFiles(repoRoot, baseRef, ignorePatterns) { +function changedFiles(repoRoot, baseRef, ignorePatterns, analysisScope) { const output = git( repoRoot, - ["diff", "--numstat", "--no-ext-diff", "--find-renames", baseRef, "--"], - { allowFailure: true }, + ["diff", "--numstat", "--no-ext-diff", "--find-renames", baseRef, ...scopePathspecArgs(analysisScope)], ); - const trackedFiles = parseNumstat(output); - const files = [...trackedFiles, ...untrackedFiles(repoRoot)] + const trackedFiles = parseNumstat(output, analysisScope); + const files = [...trackedFiles, ...untrackedFiles(repoRoot, analysisScope)] .filter((file) => !isDependencyOrGenerated(file.filePath)); return applyIgnorePatterns(files, ignorePatterns, (file) => file.filePath); } -function untrackedFiles(repoRoot) { - const output = git(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z"], { allowFailure: true }); - return output - .split("\0") - .filter(Boolean) +function untrackedFiles(repoRoot, analysisScope) { + return listUntrackedFiles(repoRoot, analysisScope) .map((filePath) => ({ filePath, added: 0, @@ -97,22 +98,23 @@ function diffText(repoRoot, baseRef, filePath) { if (filePath.startsWith(".") || filePath.includes("..")) { return ""; } - if (git(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", filePath], { allowFailure: true }).trim()) { + const literalPathspec = literalGitPathspec(filePath); + if (git(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", literalPathspec]).trim()) { try { return readFileSync(path.join(repoRoot, filePath), "utf8"); } catch { return ""; } } - return git(repoRoot, ["diff", "--no-ext-diff", baseRef, "--", filePath], { allowFailure: true }); + return git(repoRoot, ["diff", "--no-ext-diff", baseRef, "--", literalPathspec]); } function changedSet(files) { return new Set(files.map((file) => file.filePath)); } -function trackedCandidates(repoRoot, predicate, limit = 8) { - return listTrackedFiles(repoRoot) +function trackedCandidates(repoRoot, analysisScope, predicate, limit = 8) { + return listTrackedFiles(repoRoot, analysisScope) .filter((filePath) => !isDependencyOrGenerated(filePath)) .filter(predicate) .slice(0, limit); @@ -169,7 +171,7 @@ function ruleFinding({ driftType, triggerFiles, candidateCompanionFiles, evidenc }; } -function buildFindings(repoRoot, baseRef, files) { +function buildFindings(repoRoot, baseRef, files, analysisScope) { const findings = []; const changed = changedSet(files); const docsChanged = companionChanged(files, (filePath) => docsCandidatePredicate(filePath)); @@ -183,7 +185,7 @@ function buildFindings(repoRoot, baseRef, files) { findings.push(ruleFinding({ driftType: "public-api-docs", triggerFiles: apiChanges, - candidateCompanionFiles: trackedCandidates(repoRoot, docsCandidatePredicate), + candidateCompanionFiles: trackedCandidates(repoRoot, analysisScope, docsCandidatePredicate), evidence: "Changed public API-like source without changed documentation files.", })); } @@ -193,7 +195,7 @@ function buildFindings(repoRoot, baseRef, files) { findings.push(ruleFinding({ driftType: "schema-tests", triggerFiles: schemaChanges, - candidateCompanionFiles: trackedCandidates(repoRoot, testCandidatePredicate), + candidateCompanionFiles: trackedCandidates(repoRoot, analysisScope, testCandidatePredicate), evidence: "Changed schema or migration files without changed test files.", })); } @@ -203,7 +205,7 @@ function buildFindings(repoRoot, baseRef, files) { findings.push(ruleFinding({ driftType: "ui-story-snapshot", triggerFiles: uiChanges, - candidateCompanionFiles: trackedCandidates(repoRoot, storyCandidatePredicate), + candidateCompanionFiles: trackedCandidates(repoRoot, analysisScope, storyCandidatePredicate), evidence: "Changed UI source without changed stories or snapshots.", })); } @@ -213,7 +215,7 @@ function buildFindings(repoRoot, baseRef, files) { findings.push(ruleFinding({ driftType: "config-setup-docs", triggerFiles: configChanges, - candidateCompanionFiles: trackedCandidates(repoRoot, docsCandidatePredicate), + candidateCompanionFiles: trackedCandidates(repoRoot, analysisScope, docsCandidatePredicate), evidence: "Changed setup or configuration files without changed README/docs files.", })); } @@ -223,7 +225,11 @@ function buildFindings(repoRoot, baseRef, files) { findings.push(ruleFinding({ driftType: "error-contract", triggerFiles: errorChanges, - candidateCompanionFiles: trackedCandidates(repoRoot, (filePath) => testCandidatePredicate(filePath) && /contract|error|api/i.test(filePath)), + candidateCompanionFiles: trackedCandidates( + repoRoot, + analysisScope, + (filePath) => testCandidatePredicate(filePath) && /contract|error|api/i.test(filePath), + ), evidence: "Changed error-code-like source without changed contract/error test files.", })); } @@ -233,7 +239,7 @@ function buildFindings(repoRoot, baseRef, files) { findings.push(ruleFinding({ driftType: "cli-help-docs", triggerFiles: cliChanges, - candidateCompanionFiles: trackedCandidates(repoRoot, cliHelpCandidatePredicate), + candidateCompanionFiles: trackedCandidates(repoRoot, analysisScope, cliHelpCandidatePredicate), evidence: "Changed CLI parser or command-like source without changed help docs or help tests.", })); } @@ -245,11 +251,17 @@ function buildFindings(repoRoot, baseRef, files) { } export async function analyzeChangeDrift(options = {}) { - const repoRoot = resolveRepoRoot(options.cwd ?? process.env.QODER_CWD ?? process.cwd()); + const analysisScope = resolveAnalysisScopeForOptions(options); + const repoRoot = analysisScope.repoRoot; const baseRef = options.baseRef ?? "HEAD"; const diffFiles = options.changedFiles - ? applyIgnorePatterns(options.changedFiles, options.ignore, (file) => file.filePath ?? file.path) - : changedFiles(repoRoot, baseRef, options.ignore); + ? applyIgnorePatterns( + options.changedFiles.filter((file) => + isPathInAnalysisScope(file.filePath ?? file.path, analysisScope)), + options.ignore, + (file) => file.filePath ?? file.path, + ) + : changedFiles(repoRoot, baseRef, options.ignore, analysisScope); const files = diffFiles.items.map((file) => ({ ...file, filePath: toPosix(file.filePath ?? file.path), @@ -257,7 +269,7 @@ export async function analyzeChangeDrift(options = {}) { role: file.role ?? fileRoleFor(file.filePath ?? file.path), supporting: file.supporting ?? fileRoleFor(file.filePath ?? file.path) !== "source", })); - const findings = buildFindings(repoRoot, baseRef, files); + const findings = buildFindings(repoRoot, baseRef, files, analysisScope); const typeCounts = {}; for (const finding of findings) { typeCounts[finding.driftType] = (typeCounts[finding.driftType] ?? 0) + 1; @@ -268,6 +280,7 @@ export async function analyzeChangeDrift(options = {}) { kind: "change-drift", status: findings.length > 0 ? "advisory" : "ok", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), baseRef, filters: diffFiles.filters, summary: { @@ -292,6 +305,7 @@ export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); const result = await analyzeChangeDrift({ cwd: option(args, "cwd"), + packageRelPath: option(args, "package-rel-path"), baseRef: option(args, "base-ref", option(args, "base", "HEAD")), ignore: option(args, "ignore"), }); diff --git a/scripts/core-change-watch/common.mjs b/scripts/core-change-watch/common.mjs index 2131166..4341e69 100644 --- a/scripts/core-change-watch/common.mjs +++ b/scripts/core-change-watch/common.mjs @@ -1,10 +1,10 @@ import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; export const DEFAULT_MAX_COMMITS = 500; export const DEFAULT_HISTORY_WINDOWS_DAYS = [30, 90, 180]; @@ -64,7 +64,6 @@ const GENERATED_OR_DEPENDENCY = new Set([ ".next", ".qoder", ".turbo", - "build", "coverage", "dist", "node_modules", @@ -241,7 +240,7 @@ export function applyIgnorePatterns(items, patterns = [], pathFor = (item) => it }; } -export function git(cwd, args, { allowFailure = false, timeout = 20_000 } = {}) { +export function git(cwd, args, { timeout = 20_000 } = {}) { const result = spawnSync("git", args, { cwd, encoding: "utf8", @@ -251,10 +250,15 @@ export function git(cwd, args, { allowFailure = false, timeout = 20_000 } = {}) }); if (result.status !== 0) { - if (allowFailure) { - return ""; - } - throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + const error = Object.assign( + new Error(`git ${args.join(" ")} failed: ${String(result.stderr ?? "").trim()}`), + { + code: result.error?.code === "ETIMEDOUT" ? "GIT_COMMAND_TIMEOUT" : "GIT_COMMAND_FAILED", + command: Object.freeze(["git", ...args]), + exitStatus: result.status, + }, + ); + throw error; } return result.stdout; @@ -264,11 +268,313 @@ export function resolveRepoRoot(cwd = process.cwd()) { return path.resolve(git(path.resolve(cwd), ["rev-parse", "--show-toplevel"]).trim()); } -export function listTrackedFiles(repoRoot) { - return git(repoRoot, ["ls-files", "-z"], { allowFailure: true }) +function analysisScopeError(message, code = "INVALID_ANALYSIS_SCOPE") { + return Object.assign(new Error(message), { code }); +} + +function canonicalAbsolutePath(value) { + const resolved = path.resolve(value); + let existingAncestor = resolved; + const missingSegments = []; + while (!existsSync(existingAncestor)) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + return resolved; + } + missingSegments.unshift(path.basename(existingAncestor)); + existingAncestor = parent; + } + return path.resolve(realpathSync(existingAncestor), ...missingSegments); +} + +function unwrapAnalysisScope(value) { + if (value && typeof value === "object" && Object.hasOwn(value, "analysisScope")) { + return value.analysisScope; + } + return value; +} + +function normalizeRepoRelativePath(value, { + allowRoot = true, + label = "repository-relative path", + code = "INVALID_ANALYSIS_SCOPE", +} = {}) { + const raw = toPosix(value ?? ""); + if (raw.includes("\0")) { + throw analysisScopeError(`${label} must not contain NUL bytes`, code); + } + if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) { + throw analysisScopeError(`${label} must be relative to the Git root: ${raw}`, code); + } + + const withoutLeadingDot = raw.replace(/^(?:\.\/)+/u, ""); + if (withoutLeadingDot === "" || withoutLeadingDot === ".") { + if (allowRoot) { + return "."; + } + throw analysisScopeError(`${label} must identify a path below the Git root`, code); + } + + if (withoutLeadingDot.split("/").includes("..")) { + throw analysisScopeError(`${label} must not traverse outside its scope: ${raw}`, code); + } + + const normalized = path.posix.normalize(withoutLeadingDot).replace(/\/+$/u, ""); + if (path.posix.isAbsolute(normalized) + || normalized === ".." + || normalized.startsWith("../")) { + throw analysisScopeError(`${label} must not traverse outside its scope: ${raw}`, code); + } + return normalized || "."; +} + +function routeForTarget(repoRoot, targetRoot) { + const relative = path.relative(repoRoot, targetRoot); + if (relative === "") { + return "."; + } + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw analysisScopeError( + `analysis target must stay inside the Git root: ${targetRoot}`, + "INVALID_PACKAGE_SCOPE", + ); + } + return normalizeRepoRelativePath(relative, { + label: "analysis target route", + code: "INVALID_PACKAGE_SCOPE", + }); +} + +function canonicalAnalysisScope(value) { + const scope = unwrapAnalysisScope(value); + if (scope === undefined || scope === null) { + return { kind: "repo", route: ".", pathspecs: [] }; + } + if (typeof scope !== "object" || Array.isArray(scope)) { + throw analysisScopeError("analysis scope must be an object"); + } + + const routeValue = scope.route ?? scope.packageRelPath + ?? (scope.kind === "repo" || scope.targetKind === "repo-root" ? "." : undefined); + if (routeValue === undefined || routeValue === null) { + throw analysisScopeError("analysis scope route is required"); + } + const route = normalizeRepoRelativePath(routeValue, { label: "analysis scope route" }); + const inferredKind = route === "." ? "repo" : "path"; + const kind = scope.kind ?? inferredKind; + if (!new Set(["repo", "path"]).has(kind)) { + throw analysisScopeError(`unsupported analysis scope kind: ${kind}`); + } + if (kind !== inferredKind) { + throw analysisScopeError(`analysis scope kind ${kind} is incompatible with route ${route}`); + } + + const pathspecs = kind === "repo" ? [] : [literalGitPathspec(route)]; + if (scope.pathspecs !== undefined) { + if (!Array.isArray(scope.pathspecs) + || scope.pathspecs.length !== pathspecs.length + || scope.pathspecs.some((item, index) => item !== pathspecs[index])) { + throw analysisScopeError(`analysis scope pathspecs do not match route ${route}`); + } + } + return { kind, route, pathspecs }; +} + +export function literalGitPathspec(filePath) { + const normalized = normalizeRepoRelativePath(filePath, { + allowRoot: false, + label: "Git pathspec route", + code: "INVALID_PACKAGE_SCOPE", + }); + return `:(top,literal)${normalized}`; +} + +export function publicAnalysisScope(analysisScope) { + const scope = canonicalAnalysisScope(analysisScope); + return Object.freeze({ + kind: scope.kind, + route: scope.route, + pathspecs: Object.freeze([...scope.pathspecs]), + }); +} + +export function resolveAnalysisScope(options = {}) { + const normalizedOptions = typeof options === "string" ? { cwd: options } : options; + if (!normalizedOptions || typeof normalizedOptions !== "object" || Array.isArray(normalizedOptions)) { + throw analysisScopeError("analysis scope options must be an object"); + } + + const discoveryCwd = canonicalAbsolutePath( + normalizedOptions.cwd + ?? normalizedOptions.targetRoot + ?? normalizedOptions.repoRoot + ?? process.cwd(), + ); + const repoRoot = canonicalAbsolutePath(normalizedOptions.repoRoot ?? resolveRepoRoot(discoveryCwd)); + const explicitRoute = normalizedOptions.route ?? normalizedOptions.packageRelPath; + const targetRoot = explicitRoute === undefined + ? canonicalAbsolutePath(normalizedOptions.targetRoot ?? discoveryCwd) + : canonicalAbsolutePath(path.resolve(repoRoot, ...normalizeRepoRelativePath(explicitRoute, { + label: "analysis target route", + code: "INVALID_PACKAGE_SCOPE", + }).split("/"))); + const route = routeForTarget(repoRoot, targetRoot); + + if (explicitRoute !== undefined) { + const normalizedExplicitRoute = normalizeRepoRelativePath(explicitRoute, { + label: "analysis target route", + code: "INVALID_PACKAGE_SCOPE", + }); + if (normalizedExplicitRoute !== route) { + throw analysisScopeError( + `analysis target route ${normalizedExplicitRoute} does not match target ${targetRoot}`, + "INVALID_PACKAGE_SCOPE", + ); + } + } + if (normalizedOptions.targetRoot !== undefined + && routeForTarget(repoRoot, canonicalAbsolutePath(normalizedOptions.targetRoot)) !== route) { + throw analysisScopeError( + "analysis target route and targetRoot identify different paths", + "INVALID_PACKAGE_SCOPE", + ); + } + + const publicScope = publicAnalysisScope({ + kind: route === "." ? "repo" : "path", + route, + }); + return Object.freeze({ + ...publicScope, + repoRoot, + targetRoot, + }); +} + +export function resolveAnalysisScopeForOptions(options = {}) { + const supplied = unwrapAnalysisScope(options.analysisScope); + const resolved = resolveAnalysisScope({ + cwd: options.cwd ?? process.env.QODER_CWD ?? process.cwd(), + repoRoot: supplied?.repoRoot ?? options.repoRoot, + targetRoot: supplied?.targetRoot ?? options.targetRoot, + route: supplied?.route ?? options.packageRelPath, + }); + if (supplied !== undefined && supplied !== null) { + assertCompatibleAnalysisScope(supplied, resolved, "provided analysis scope"); + } + return resolved; +} + +export function isPathInAnalysisScope(filePath, analysisScope) { + let normalized; + try { + normalized = normalizeRepoRelativePath(filePath, { label: "analyzed file path" }); + } catch { + return false; + } + const scope = canonicalAnalysisScope(analysisScope); + return scope.kind === "repo" + || normalized === scope.route + || normalized.startsWith(`${scope.route}/`); +} + +export function toAnalysisRelativePath(filePath, analysisScope) { + const normalized = normalizeRepoRelativePath(filePath, { label: "analyzed file path" }); + const scope = canonicalAnalysisScope(analysisScope); + if (!isPathInAnalysisScope(normalized, scope)) { + throw analysisScopeError( + `path ${normalized} is outside analysis scope ${scope.route}`, + "PATH_OUTSIDE_ANALYSIS_SCOPE", + ); + } + if (scope.kind === "repo") { + return normalized; + } + return normalized === scope.route ? "." : normalized.slice(scope.route.length + 1); +} + +export function fromAnalysisRelativePath(filePath, analysisScope) { + const normalized = normalizeRepoRelativePath(filePath, { + label: "analysis-relative path", + code: "PATH_OUTSIDE_ANALYSIS_SCOPE", + }); + const scope = canonicalAnalysisScope(analysisScope); + if (scope.kind === "repo" || normalized === ".") { + return normalized === "." && scope.kind === "path" ? scope.route : normalized; + } + return path.posix.join(scope.route, normalized); +} + +export function scopePathspecArgs(analysisScope) { + const scope = canonicalAnalysisScope(analysisScope); + return ["--", ...scope.pathspecs]; +} + +export function assertCompatibleAnalysisScope(actual, expected, owner = "analysis input") { + const expectedScope = canonicalAnalysisScope(expected); + const actualValue = unwrapAnalysisScope(actual); + if ((actualValue === undefined || actualValue === null) && expectedScope.kind === "repo") { + return true; + } + if (actualValue === undefined || actualValue === null) { + throw analysisScopeError( + `${owner} is missing analysis scope ${expectedScope.route}`, + "ANALYSIS_SCOPE_MISMATCH", + ); + } + + const actualScope = canonicalAnalysisScope(actualValue); + const actualRepoRoot = actualValue.repoRoot; + const expectedValue = unwrapAnalysisScope(expected); + const expectedRepoRoot = expectedValue?.repoRoot; + const sameRepoRoot = actualRepoRoot === undefined || expectedRepoRoot === undefined + || canonicalAbsolutePath(actualRepoRoot) === canonicalAbsolutePath(expectedRepoRoot); + if (!sameRepoRoot + || actualScope.kind !== expectedScope.kind + || actualScope.route !== expectedScope.route) { + throw analysisScopeError( + `${owner} analysis scope ${actualScope.route} does not match ${expectedScope.route}`, + "ANALYSIS_SCOPE_MISMATCH", + ); + } + return true; +} + +export function listTrackedFiles(repoRoot, analysisScope = null) { + const resolvedRepoRoot = canonicalAbsolutePath(repoRoot); + const scopeValue = unwrapAnalysisScope(analysisScope); + if (scopeValue?.repoRoot !== undefined + && canonicalAbsolutePath(scopeValue.repoRoot) !== resolvedRepoRoot) { + throw analysisScopeError( + `tracked-file Git root ${resolvedRepoRoot} does not match analysis scope Git root ${scopeValue.repoRoot}`, + "ANALYSIS_SCOPE_MISMATCH", + ); + } + return git(resolvedRepoRoot, ["ls-files", "-z", ...scopePathspecArgs(scopeValue)]) .split("\0") .filter(Boolean) - .map(toPosix); + .map(toPosix) + .filter((filePath) => isPathInAnalysisScope(filePath, scopeValue)); +} + +export function listUntrackedFiles(repoRoot, analysisScope = null) { + const resolvedRepoRoot = canonicalAbsolutePath(repoRoot); + const scopeValue = unwrapAnalysisScope(analysisScope); + if (scopeValue?.repoRoot !== undefined + && canonicalAbsolutePath(scopeValue.repoRoot) !== resolvedRepoRoot) { + throw analysisScopeError( + `untracked-file Git root ${resolvedRepoRoot} does not match analysis scope Git root ${scopeValue.repoRoot}`, + "ANALYSIS_SCOPE_MISMATCH", + ); + } + return git( + resolvedRepoRoot, + ["ls-files", "--others", "--exclude-standard", "-z", ...scopePathspecArgs(scopeValue)], + ) + .split("\0") + .filter(Boolean) + .map(toPosix) + .filter((filePath) => isPathInAnalysisScope(filePath, scopeValue)); } export function languageFor(filePath) { @@ -363,6 +669,11 @@ export function analysisDirectoryFor(filePath) { return directoryOf(filePath, 2); } +export function analysisDirectoryForScope(filePath, analysisScope) { + const localPath = toAnalysisRelativePath(filePath, analysisScope); + return fromAnalysisRelativePath(analysisDirectoryFor(localPath), analysisScope); +} + export function parentDirectories(filePath, maxDepth = 3) { const parts = toPosix(filePath).split("/"); const max = Math.min(maxDepth, Math.max(1, parts.length - 1)); @@ -420,7 +731,7 @@ export function normalizeRenamePath(filePath) { .replace(/[{}]/g, ""); } -export function parseNumstat(output) { +export function parseNumstat(output, analysisScope = null) { const files = []; for (const line of output.split(/\r?\n/)) { if (!line.trim()) { @@ -429,7 +740,7 @@ export function parseNumstat(output) { const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); const filePath = normalizeRenamePath(pathParts.join("\t")); - if (!filePath) { + if (!filePath || !isPathInAnalysisScope(filePath, analysisScope)) { continue; } diff --git a/scripts/core-change-watch/core-candidates.mjs b/scripts/core-change-watch/core-candidates.mjs index 7d20758..125dd29 100644 --- a/scripts/core-change-watch/core-candidates.mjs +++ b/scripts/core-change-watch/core-candidates.mjs @@ -4,8 +4,9 @@ import { DEFAULT_MAX_COMMITS, SCHEMA_VERSION, addCount, - analysisDirectoryFor, + analysisDirectoryForScope, applyIgnorePatterns, + assertCompatibleAnalysisScope, compactReasonList, isCli, isDependencyOrGenerated, @@ -18,9 +19,11 @@ import { option, parseArgs, positiveInt, - resolveRepoRoot, + publicAnalysisScope, + resolveAnalysisScopeForOptions, scoreToConfidence, sortedCounts, + toAnalysisRelativePath, writeJsonResult, } from "./common.mjs"; import { analyzeGitHistoryProfile } from "./git-history-profile.mjs"; @@ -66,8 +69,8 @@ function isNonCoreCandidatePath(candidatePath) { )); } -function candidateForFile(map, filePath) { - const directory = analysisDirectoryFor(filePath); +function candidateForFile(map, filePath, analysisScope) { + const directory = analysisDirectoryForScope(filePath, analysisScope); const item = map.get(directory) ?? { path: directory, score: 0, @@ -89,9 +92,10 @@ function candidateForFile(map, filePath) { return item; } -function applyPathSignals(candidate) { +function applyPathSignals(candidate, analysisScope) { + const localPath = toAnalysisRelativePath(candidate.path, analysisScope); for (const signal of CORE_PATH_PATTERNS) { - if (signal.pattern.test(candidate.path)) { + if (signal.pattern.test(localPath)) { candidate.score += signal.score; candidate.reasons.push(signal.reason); } @@ -151,8 +155,9 @@ function applyProfileSignals(candidate, profile) { } } -function candidateHasSource(candidate, pattern) { - return candidate.evidence.sourceFiles.some((filePath) => pattern.test(filePath)); +function candidateHasSource(candidate, pattern, analysisScope) { + return candidate.evidence.sourceFiles.some((filePath) => + pattern.test(toAnalysisRelativePath(filePath, analysisScope))); } function frameworkManifestDirectory(evidence) { @@ -164,7 +169,7 @@ function frameworkManifestDirectory(evidence) { return parts.length <= 1 ? "." : parts.slice(0, -1).join("/"); } -function applyFrameworkSignals(candidate, profile) { +function applyFrameworkSignals(candidate, profile, analysisScope) { const frameworks = new Set((profile.frameworks ?? []).map((framework) => framework.name)); for (const framework of profile.frameworks ?? []) { for (const evidence of framework.evidence ?? []) { @@ -176,36 +181,48 @@ function applyFrameworkSignals(candidate, profile) { } } for (const signal of FRAMEWORK_PATH_SIGNALS) { - if (frameworks.has(signal.framework) && signal.pattern.test(candidate.path)) { + if (frameworks.has(signal.framework) + && signal.pattern.test(toAnalysisRelativePath(candidate.path, analysisScope))) { candidate.score += signal.score; candidate.reasons.push(signal.reason); } } - if (frameworks.has("django") && candidateHasSource(candidate, /(^|\/)(models|views|urls|serializers|services)\.py$/)) { + if (frameworks.has("django") + && candidateHasSource(candidate, /(^|\/)(models|views|urls|serializers|services)\.py$/, analysisScope)) { candidate.score += 20; candidate.reasons.push("django app module files"); } - if (frameworks.has("nestjs") && candidateHasSource(candidate, /(^|\/).*\.(module|service|controller)\.ts$/)) { + if (frameworks.has("nestjs") + && candidateHasSource(candidate, /(^|\/).*\.(module|service|controller)\.ts$/, analysisScope)) { candidate.score += 18; candidate.reasons.push("nestjs module/service/controller files"); } } export async function analyzeCoreCandidates(options = {}) { - const repoRoot = resolveRepoRoot(options.cwd ?? process.env.QODER_CWD ?? process.cwd()); + const analysisScope = resolveAnalysisScopeForOptions(options); + const repoRoot = analysisScope.repoRoot; const allowedLanguages = new Set(normalizeLanguages(options.languages)); const maxCandidates = positiveInt(options.maxCandidates, 30); - const rawTrackedFiles = listTrackedFiles(repoRoot); + const rawTrackedFiles = listTrackedFiles(repoRoot, analysisScope); const filteredTrackedFiles = applyIgnorePatterns(rawTrackedFiles, options.ignore); const trackedFiles = filteredTrackedFiles.items; - const profile = options.profile ?? await analyzeProjectProfile({ cwd: repoRoot, languages: [...allowedLanguages], ignore: options.ignore }); + const profile = options.profile ?? await analyzeProjectProfile({ + cwd: repoRoot, + analysisScope, + languages: [...allowedLanguages], + ignore: options.ignore, + }); const history = options.history ?? await analyzeGitHistoryProfile({ cwd: repoRoot, + analysisScope, languages: [...allowedLanguages], maxCommits: positiveInt(options.maxCommits, DEFAULT_MAX_COMMITS), ignore: options.ignore, }); + assertCompatibleAnalysisScope(profile, analysisScope, "project profile"); + assertCompatibleAnalysisScope(history, analysisScope, "history profile"); const candidates = new Map(); for (const filePath of trackedFiles) { @@ -216,19 +233,22 @@ export async function analyzeCoreCandidates(options = {}) { if (!allowedLanguages.has(language)) { continue; } - if (isNonCoreCandidatePath(analysisDirectoryFor(filePath))) { + if (isNonCoreCandidatePath(toAnalysisRelativePath( + analysisDirectoryForScope(filePath, analysisScope), + analysisScope, + ))) { continue; } - candidateForFile(candidates, filePath); + candidateForFile(candidates, filePath, analysisScope); } for (const candidate of candidates.values()) { candidate.score += Math.min(10, candidate.files); applyDensitySignals(candidate); - applyPathSignals(candidate); + applyPathSignals(candidate, analysisScope); applyHistorySignals(candidate, history); applyProfileSignals(candidate, profile); - applyFrameworkSignals(candidate, profile); + applyFrameworkSignals(candidate, profile, analysisScope); } const resultCandidates = [...candidates.values()] @@ -257,6 +277,7 @@ export async function analyzeCoreCandidates(options = {}) { kind: "core-candidates", status: "ok", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), filters: filteredTrackedFiles.filters, candidates: resultCandidates, summary: { @@ -271,6 +292,7 @@ export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); const result = await analyzeCoreCandidates({ cwd: option(args, "cwd"), + packageRelPath: option(args, "package-rel-path"), languages: option(args, "languages"), maxCommits: positiveInt(option(args, "max-commits"), DEFAULT_MAX_COMMITS), maxCandidates: positiveInt(option(args, "max-candidates"), 30), diff --git a/scripts/core-change-watch/diff-impact.mjs b/scripts/core-change-watch/diff-impact.mjs index 50c4473..f82053b 100644 --- a/scripts/core-change-watch/diff-impact.mjs +++ b/scripts/core-change-watch/diff-impact.mjs @@ -8,18 +8,22 @@ import { SCHEMA_VERSION, applyIgnorePatterns, analysisDirectoryFor, + assertCompatibleAnalysisScope, git, isCli, isDependencyOrGenerated, isSupportingFile, fileRoleFor, languageFor, + listUntrackedFiles, normalizeLanguages, option, parseArgs, parseNumstat, positiveInt, - resolveRepoRoot, + publicAnalysisScope, + resolveAnalysisScopeForOptions, + scopePathspecArgs, writeJsonResult, } from "./common.mjs"; import { analyzeCoreCandidates } from "./core-candidates.mjs"; @@ -39,14 +43,14 @@ function severityFor(score) { return "low"; } -function changedFiles(repoRoot, baseRef, ignorePatterns) { +function changedFiles(repoRoot, baseRef, ignorePatterns, analysisScope) { const output = git( repoRoot, - ["diff", "--numstat", "--no-ext-diff", "--find-renames", baseRef, "--"], - { allowFailure: true }, + ["diff", "--numstat", "--no-ext-diff", "--find-renames", baseRef, ...scopePathspecArgs(analysisScope)], ); - const trackedFiles = parseNumstat(output); - const files = [...trackedFiles, ...untrackedFiles(repoRoot)].filter((file) => !isDependencyOrGenerated(file.filePath)); + const trackedFiles = parseNumstat(output, analysisScope); + const files = [...trackedFiles, ...untrackedFiles(repoRoot, analysisScope)] + .filter((file) => !isDependencyOrGenerated(file.filePath)); return applyIgnorePatterns(files, ignorePatterns, (file) => file.filePath); } @@ -59,11 +63,8 @@ function textLineCount(repoRoot, filePath) { } } -function untrackedFiles(repoRoot) { - const output = git(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z"], { allowFailure: true }); - return output - .split("\0") - .filter(Boolean) +function untrackedFiles(repoRoot, analysisScope) { + return listUntrackedFiles(repoRoot, analysisScope) .map((filePath) => ({ filePath, added: textLineCount(repoRoot, filePath), @@ -93,25 +94,36 @@ function coreHitForChangedFile(file, core) { } export async function analyzeDiffImpact(options = {}) { - const repoRoot = resolveRepoRoot(options.cwd ?? process.env.QODER_CWD ?? process.cwd()); + const analysisScope = resolveAnalysisScopeForOptions(options); + const repoRoot = analysisScope.repoRoot; const baseRef = options.baseRef ?? "HEAD"; const allowedLanguages = new Set(normalizeLanguages(options.languages)); - const profile = options.profile ?? await analyzeProjectProfile({ cwd: repoRoot, languages: [...allowedLanguages], ignore: options.ignore }); + const profile = options.profile ?? await analyzeProjectProfile({ + cwd: repoRoot, + analysisScope, + languages: [...allowedLanguages], + ignore: options.ignore, + }); const history = options.history ?? await analyzeGitHistoryProfile({ cwd: repoRoot, + analysisScope, languages: [...allowedLanguages], maxCommits: positiveInt(options.maxCommits, DEFAULT_MAX_COMMITS), ignore: options.ignore, }); const core = options.core ?? await analyzeCoreCandidates({ cwd: repoRoot, + analysisScope, languages: [...allowedLanguages], maxCommits: positiveInt(options.maxCommits, DEFAULT_MAX_COMMITS), profile, history, ignore: options.ignore, }); - const diffFiles = changedFiles(repoRoot, baseRef, options.ignore); + assertCompatibleAnalysisScope(profile, analysisScope, "project profile"); + assertCompatibleAnalysisScope(history, analysisScope, "history profile"); + assertCompatibleAnalysisScope(core, analysisScope, "core analysis"); + const diffFiles = changedFiles(repoRoot, baseRef, options.ignore, analysisScope); const files = diffFiles.items.filter((file) => file.role !== "source" || allowedLanguages.has(file.language)); const coreHits = []; const hotHits = []; @@ -189,6 +201,7 @@ export async function analyzeDiffImpact(options = {}) { kind: "diff-impact", status: attentionRequired ? "attention-required" : "ok", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), baseRef, filters: diffFiles.filters, score: normalizedScore, @@ -222,6 +235,7 @@ export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); const result = await analyzeDiffImpact({ cwd: option(args, "cwd"), + packageRelPath: option(args, "package-rel-path"), languages: option(args, "languages"), baseRef: option(args, "base-ref", option(args, "base", "HEAD")), maxCommits: positiveInt(option(args, "max-commits"), DEFAULT_MAX_COMMITS), diff --git a/scripts/core-change-watch/evidence-pack.mjs b/scripts/core-change-watch/evidence-pack.mjs index cdfdbba..f8c56d4 100644 --- a/scripts/core-change-watch/evidence-pack.mjs +++ b/scripts/core-change-watch/evidence-pack.mjs @@ -13,7 +13,8 @@ import { option, parseArgs, positiveInt, - resolveRepoRoot, + publicAnalysisScope, + resolveAnalysisScopeForOptions, writeJsonResult, } from "./common.mjs"; import { analyzeChangeDrift } from "./change-drift.mjs"; @@ -269,29 +270,34 @@ function buildAgentGuidance(history, core, filters) { }; } -function buildEvidenceSources({ baseRef, maxCommits, historyWindows, noHistory }) { +function buildEvidenceSources({ baseRef, maxCommits, historyWindows, noHistory, analysisScope }) { + const scopeSuffix = analysisScope.pathspecs.length > 0 + ? ` -- ${analysisScope.pathspecs.join(" ")}` + : " --"; return { boundary: "static-local-git-and-file-analysis", numericSources: [ { claim: "tracked file, language, manifest, source-root, entrypoint, and framework counts", source: "projectProfile", - command: "git ls-files -z plus local manifest/path inspection", + command: `git ls-files -z${scopeSuffix} plus local manifest/path inspection`, }, { claim: "hot files, hot paths, co-change paths, and history windows", source: "historyProfile", - command: noHistory ? "UNVERIFIED: history scan skipped by --no-history" : `git log -${maxCommits} --numstat --format=commit... --`, + command: noHistory + ? "UNVERIFIED: history scan skipped by --no-history" + : `git log -${maxCommits} --numstat --format=commit...${scopeSuffix}`, }, { claim: "changed files, line counts, inferred core candidate hits, hot hits, and companion hits", source: "diffImpact", - command: `git diff --numstat --no-ext-diff --find-renames ${baseRef} --`, + command: `git diff --numstat --no-ext-diff --find-renames ${baseRef}${scopeSuffix}`, }, { claim: "changed API, schema, UI, config, error, and CLI surfaces with missing companion evidence", source: "changeDrift", - command: `git diff --numstat --no-ext-diff --find-renames ${baseRef} -- plus git ls-files -z`, + command: `git diff --numstat --no-ext-diff --find-renames ${baseRef}${scopeSuffix} plus git ls-files -z${scopeSuffix}`, }, { claim: "history day windows", @@ -438,7 +444,8 @@ function buildReviewMatrix(projectProfile, historyProfile, coreAnalysis, diffImp } export async function buildEvidencePack(options = {}) { - const repoRoot = resolveRepoRoot(options.cwd ?? process.env.QODER_CWD ?? process.cwd()); + const analysisScope = resolveAnalysisScopeForOptions(options); + const repoRoot = analysisScope.repoRoot; const languages = normalizeLanguages(options.languages); const maxCommits = positiveInt(options.maxCommits, DEFAULT_MAX_COMMITS); const maxCandidates = positiveInt(options.maxCandidates, 30); @@ -447,6 +454,7 @@ export async function buildEvidencePack(options = {}) { const historyWindows = normalizeHistoryWindows(options.historyWindows); const projectProfile = await analyzeProjectProfile({ cwd: repoRoot, + analysisScope, languages, ignore: options.ignore, measureSourceLines: Boolean(options.measureSourceLines), @@ -457,6 +465,7 @@ export async function buildEvidencePack(options = {}) { kind: "git-history-profile", status: "skipped", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), filters: applyIgnorePatterns([], options.ignore).filters, hotFiles: [], supportingHotFiles: [], @@ -477,9 +486,17 @@ export async function buildEvidencePack(options = {}) { })), range: { maxCommits: 0, analyzedCommits: 0, newest: null, oldest: null }, } - : await analyzeGitHistoryProfile({ cwd: repoRoot, languages, maxCommits, historyWindows, ignore: options.ignore }); + : await analyzeGitHistoryProfile({ + cwd: repoRoot, + analysisScope, + languages, + maxCommits, + historyWindows, + ignore: options.ignore, + }); const coreAnalysis = await analyzeCoreCandidates({ cwd: repoRoot, + analysisScope, languages, maxCommits, maxCandidates, @@ -489,6 +506,7 @@ export async function buildEvidencePack(options = {}) { }); const diffImpact = await analyzeDiffImpact({ cwd: repoRoot, + analysisScope, languages, baseRef: options.baseRef ?? "HEAD", maxCommits, @@ -499,6 +517,7 @@ export async function buildEvidencePack(options = {}) { }); const changeDrift = await analyzeChangeDrift({ cwd: repoRoot, + analysisScope, baseRef: options.baseRef ?? "HEAD", ignore: options.ignore, changedFiles: diffImpact.changedFiles, @@ -510,6 +529,7 @@ export async function buildEvidencePack(options = {}) { kind: "core-change-watch-evidence-pack", status: "ok", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), generatedAt: new Date().toISOString(), summary: { reviewRecommended: diffImpact.reviewRecommended, @@ -540,6 +560,7 @@ export async function buildEvidencePack(options = {}) { maxCommits, historyWindows: historyProfile.historyWindows.map((item) => item.days), noHistory: Boolean(options.noHistory), + analysisScope: publicAnalysisScope(analysisScope), }), projectProfile, historyProfile, @@ -558,6 +579,7 @@ export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); const result = await buildEvidencePack({ cwd: option(args, "cwd"), + packageRelPath: option(args, "package-rel-path"), languages: option(args, "languages"), baseRef: option(args, "base-ref", option(args, "base", "HEAD")), maxCommits: positiveInt(option(args, "max-commits"), DEFAULT_MAX_COMMITS), diff --git a/scripts/core-change-watch/git-history-profile.mjs b/scripts/core-change-watch/git-history-profile.mjs index e5bd4f0..6540d20 100644 --- a/scripts/core-change-watch/git-history-profile.mjs +++ b/scripts/core-change-watch/git-history-profile.mjs @@ -4,7 +4,7 @@ import { DEFAULT_MAX_COMMITS, SCHEMA_VERSION, addCount, - analysisDirectoryFor, + analysisDirectoryForScope, applyIgnorePatterns, git, isCli, @@ -17,12 +17,14 @@ import { parseArgs, parseNumstat, positiveInt, - resolveRepoRoot, + publicAnalysisScope, + resolveAnalysisScopeForOptions, + scopePathspecArgs, sortedCounts, writeJsonResult, } from "./common.mjs"; -function parseLogWithNumstat(output) { +function parseLogWithNumstat(output, analysisScope) { const commits = []; let current = null; @@ -44,7 +46,7 @@ function parseLogWithNumstat(output) { continue; } - current.files.push(...parseNumstat(line)); + current.files.push(...parseNumstat(line, analysisScope)); } return commits; @@ -86,7 +88,7 @@ function sortedHotFiles(map, limit) { .slice(0, limit); } -function collectHistoryStats(commits, allowedLanguages) { +function collectHistoryStats(commits, allowedLanguages, analysisScope) { const fileStats = new Map(); const supportingFileStats = new Map(); const pathStats = new Map(); @@ -102,14 +104,14 @@ function collectHistoryStats(commits, allowedLanguages) { for (const file of primaryFiles) { incrementFileStats(fileStats, file, commit); - const directory = analysisDirectoryFor(file.filePath); + const directory = analysisDirectoryForScope(file.filePath, analysisScope); directories.add(directory); addCount(languageStats, file.language ?? "other", 1); } for (const file of supportingFiles) { incrementFileStats(supportingFileStats, file, commit); - addCount(supportingPathStats, analysisDirectoryFor(file.filePath), 1); + addCount(supportingPathStats, analysisDirectoryForScope(file.filePath, analysisScope), 1); } for (const directory of directories) { @@ -134,12 +136,12 @@ function collectHistoryStats(commits, allowedLanguages) { }; } -function historyWindows(commits, allowedLanguages, daysList) { +function historyWindows(commits, allowedLanguages, daysList, analysisScope) { const nowSeconds = Math.floor(Date.now() / 1000); return daysList.map((days) => { const sinceSeconds = nowSeconds - days * 24 * 60 * 60; const windowCommits = commits.filter((commit) => commit.timestamp >= sinceSeconds); - const stats = collectHistoryStats(windowCommits, allowedLanguages); + const stats = collectHistoryStats(windowCommits, allowedLanguages, analysisScope); const trend = trendForWindow(days, windowCommits.length); return { @@ -219,19 +221,36 @@ function applyCommitIgnores(commits, ignorePatterns) { } export async function analyzeGitHistoryProfile(options = {}) { - const repoRoot = resolveRepoRoot(options.cwd ?? process.env.QODER_CWD ?? process.cwd()); + const analysisScope = resolveAnalysisScopeForOptions(options); + const repoRoot = analysisScope.repoRoot; const maxCommits = positiveInt(options.maxCommits, DEFAULT_MAX_COMMITS); const windowDays = normalizeHistoryWindows(options.historyWindows); const allowedLanguages = new Set(normalizeLanguages(options.languages)); - const output = git( - repoRoot, - ["log", `-${maxCommits}`, "--numstat", "--format=commit%x1f%H%x1f%ct%x1f%s", "--"], - { allowFailure: true, timeout: 30_000 }, - ); - const parsedCommits = parseLogWithNumstat(output); + let output; + try { + output = git( + repoRoot, + ["log", `-${maxCommits}`, "--numstat", "--format=commit%x1f%H%x1f%ct%x1f%s", ...scopePathspecArgs(analysisScope)], + { timeout: 30_000 }, + ); + } catch (error) { + // An unborn repository is a valid empty history. Every other Git failure, + // including a malformed scope/pathspec, remains a hard coverage failure. + try { + git(repoRoot, ["rev-parse", "--verify", "HEAD"]); + } catch (headError) { + if (headError?.code === "GIT_COMMAND_FAILED") { + output = ""; + } else { + throw error; + } + } + if (output === undefined) throw error; + } + const parsedCommits = parseLogWithNumstat(output, analysisScope); const filteredHistory = applyCommitIgnores(parsedCommits, options.ignore); const commits = filteredHistory.commits; - const stats = collectHistoryStats(commits, allowedLanguages); + const stats = collectHistoryStats(commits, allowedLanguages, analysisScope); const maxHotFiles = positiveInt(options.maxHotFiles, 40); return { @@ -239,6 +258,7 @@ export async function analyzeGitHistoryProfile(options = {}) { kind: "git-history-profile", status: "ok", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), filters: filteredHistory.filters, range: { maxCommits, @@ -247,7 +267,7 @@ export async function analyzeGitHistoryProfile(options = {}) { oldest: commits.at(-1)?.shortSha ?? null, }, confidence: historyConfidence(commits, maxCommits), - historyWindows: historyWindows(commits, allowedLanguages, windowDays), + historyWindows: historyWindows(commits, allowedLanguages, windowDays, analysisScope), languageTouchCounts: sortedCounts(stats.languageStats, 12).map((item) => ({ language: item.name, touches: item.count, @@ -273,6 +293,7 @@ export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); const result = await analyzeGitHistoryProfile({ cwd: option(args, "cwd"), + packageRelPath: option(args, "package-rel-path"), languages: option(args, "languages"), maxCommits: positiveInt(option(args, "max-commits"), DEFAULT_MAX_COMMITS), maxHotFiles: positiveInt(option(args, "max-hot-files"), 40), diff --git a/scripts/core-change-watch/project-profile.mjs b/scripts/core-change-watch/project-profile.mjs index 819ac0e..a80812c 100644 --- a/scripts/core-change-watch/project-profile.mjs +++ b/scripts/core-change-watch/project-profile.mjs @@ -7,6 +7,7 @@ import { SCHEMA_VERSION, addCount, analysisDirectoryFor, + analysisDirectoryForScope, applyIgnorePatterns, compactReasonList, fileRoleFor, @@ -19,9 +20,12 @@ import { normalizeLanguages, option, parseArgs, + publicAnalysisScope, readJsonFile, - resolveRepoRoot, + resolveAnalysisScopeForOptions, sortedCounts, + fromAnalysisRelativePath, + toAnalysisRelativePath, toPosix, writeJsonResult, } from "./common.mjs"; @@ -133,10 +137,10 @@ function entryScore(filePath) { return 0; } -function corePathHint(filePath) { - const directory = analysisDirectoryFor(filePath); - if (/(^|\/)(core|auth|security|permission|permissions|crypto|session|payment|billing|domain|service|api)(\/|$)/i.test(directory)) { - return directory; +function corePathHint(filePath, analysisScope) { + const localDirectory = analysisDirectoryFor(toAnalysisRelativePath(filePath, analysisScope)); + if (/(^|\/)(core|auth|security|permission|permissions|crypto|session|payment|billing|domain|service|api)(\/|$)/i.test(localDirectory)) { + return fromAnalysisRelativePath(localDirectory, analysisScope); } return ""; } @@ -206,11 +210,12 @@ function firstMarkdownHeading(text) { return ""; } -function rootPackageIdentity(repoRoot, fileSet) { - if (!fileSet.has("package.json")) { +function rootPackageIdentity(repoRoot, fileSet, analysisScope) { + const manifestPath = fromAnalysisRelativePath("package.json", analysisScope); + if (!fileSet.has(manifestPath)) { return null; } - const manifest = readJsonFile(repoRoot, "package.json"); + const manifest = readJsonFile(repoRoot, manifestPath); if (!manifest || typeof manifest !== "object") { return null; } @@ -220,16 +225,19 @@ function rootPackageIdentity(repoRoot, fileSet) { evidence: [], }; if (identity.name) { - identity.evidence.push("package.json:name"); + identity.evidence.push(`${manifestPath}:name`); } if (identity.description) { - identity.evidence.push("package.json:description"); + identity.evidence.push(`${manifestPath}:description`); } return identity.name || identity.description ? identity : null; } -function rootReadmeTitle(repoRoot, trackedFiles) { - const readmePath = trackedFiles.find((filePath) => /^readme(?:\.[^.]+)?$/i.test(path.posix.basename(filePath)) && !filePath.includes("/")); +function rootReadmeTitle(repoRoot, trackedFiles, analysisScope) { + const readmePath = trackedFiles.find((filePath) => { + const local = toAnalysisRelativePath(filePath, analysisScope); + return /^readme(?:\.[^.]+)?$/i.test(path.posix.basename(local)) && !local.includes("/"); + }); if (!readmePath) { return { title: "", path: "" }; } @@ -359,10 +367,10 @@ function sourceLineStatus(totals) { return "unavailable"; } -function projectIdentity(repoRoot, trackedFiles) { +function projectIdentity(repoRoot, trackedFiles, analysisScope) { const fileSet = new Set(trackedFiles); - const packageIdentity = rootPackageIdentity(repoRoot, fileSet); - const readme = rootReadmeTitle(repoRoot, trackedFiles); + const packageIdentity = rootPackageIdentity(repoRoot, fileSet, analysisScope); + const readme = rootReadmeTitle(repoRoot, trackedFiles, analysisScope); const evidence = []; if (packageIdentity?.evidence?.length) { @@ -373,7 +381,7 @@ function projectIdentity(repoRoot, trackedFiles) { } return { - name: packageIdentity?.name || readme.title || path.basename(repoRoot), + name: packageIdentity?.name || readme.title || path.basename(analysisScope.targetRoot), description: packageIdentity?.description || "", readmeTitle: readme.title, evidence: compactReasonList(evidence, 8), @@ -391,13 +399,17 @@ function isPathUnderScope(filePath, scope) { return normalizedScope === "." || normalized === normalizedScope || normalized.startsWith(`${normalizedScope}/`); } -function agentInstructionFiles(trackedFiles) { +function agentInstructionFiles(trackedFiles, analysisScope) { return trackedFiles .filter((filePath) => path.posix.basename(toPosix(filePath)) === AGENT_INSTRUCTION_FILE) .filter((filePath) => !isDependencyOrGenerated(filePath)) .map((filePath) => ({ path: toPosix(filePath), - scope: instructionScope(filePath), + scope: fromAnalysisRelativePath( + instructionScope(toAnalysisRelativePath(filePath, analysisScope)), + analysisScope, + ), + targetRoot: instructionScope(toAnalysisRelativePath(filePath, analysisScope)) === ".", })) .sort((a, b) => a.path.localeCompare(b.path)); } @@ -424,9 +436,9 @@ function agentInstructionStatus(count, suggestedMinimum) { return "adequate"; } -function analyzeAgentInstructions({ trackedFiles, sourceRecords, sourceRoots, totals }) { - const files = agentInstructionFiles(trackedFiles); - const nestedScopes = files.filter((item) => item.scope !== ".").map((item) => item.scope); +function analyzeAgentInstructions({ trackedFiles, sourceRecords, sourceRoots, totals, analysisScope }) { + const files = agentInstructionFiles(trackedFiles, analysisScope); + const nestedScopes = files.filter((item) => !item.targetRoot).map((item) => item.scope); let sourceFilesUnderNestedInstructions = 0; const uncoveredSourceDirs = new Map(); @@ -439,7 +451,7 @@ function analyzeAgentInstructions({ trackedFiles, sourceRecords, sourceRoots, to } } - const rootCount = files.filter((item) => item.scope === ".").length; + const rootCount = files.filter((item) => item.targetRoot).length; const nestedCount = files.length - rootCount; const suggestedMinimum = suggestedInstructionMinimum(totals, sourceRoots); const suggestedAdditional = Math.max(0, suggestedMinimum - files.length); @@ -466,7 +478,7 @@ function analyzeAgentInstructions({ trackedFiles, sourceRecords, sourceRoots, to count: files.length, rootCount, nestedCount, - files, + files: files.map(({ targetRoot: _targetRoot, ...file }) => file), status, suggestedMinimum, suggestedAdditional, @@ -489,8 +501,9 @@ function buildProjectInfo({ sourceRoots, entryCandidates, totals, + analysisScope, }) { - const identity = projectIdentity(repoRoot, trackedFiles); + const identity = projectIdentity(repoRoot, trackedFiles, analysisScope); return { ...identity, primaryLanguages: languages.slice(0, 5).map((item) => ({ @@ -519,10 +532,11 @@ function buildProjectInfo({ } export async function analyzeProjectProfile(options = {}) { - const repoRoot = resolveRepoRoot(options.cwd ?? process.env.QODER_CWD ?? process.cwd()); + const analysisScope = resolveAnalysisScopeForOptions(options); + const repoRoot = analysisScope.repoRoot; const allowedLanguages = new Set(normalizeLanguages(options.languages)); const measureSourceLines = shouldMeasureSourceLines(options); - const rawTrackedFiles = listTrackedFiles(repoRoot); + const rawTrackedFiles = listTrackedFiles(repoRoot, analysisScope); const filteredTrackedFiles = applyIgnorePatterns(rawTrackedFiles, options.ignore); const trackedFiles = filteredTrackedFiles.items; const analysisTrackedFiles = trackedFiles.filter((filePath) => !isDependencyOrGenerated(filePath)); @@ -561,7 +575,7 @@ export async function analyzeProjectProfile(options = {}) { } if (!isSourceFile(normalized)) { - const score = entryScore(normalized); + const score = entryScore(toAnalysisRelativePath(normalized, analysisScope)); if (score > 0 && existsSync(path.join(repoRoot, normalized))) { entryCandidates.push({ path: normalized, @@ -578,7 +592,7 @@ export async function analyzeProjectProfile(options = {}) { continue; } - const score = entryScore(normalized); + const score = entryScore(toAnalysisRelativePath(normalized, analysisScope)); if (score > 0 && existsSync(path.join(repoRoot, normalized))) { entryCandidates.push({ path: normalized, @@ -613,7 +627,7 @@ export async function analyzeProjectProfile(options = {}) { path: normalized, language, lines, - directory: analysisDirectoryFor(normalized), + directory: analysisDirectoryForScope(normalized, analysisScope), }); } @@ -627,12 +641,13 @@ export async function analyzeProjectProfile(options = {}) { stats.files += 1; stats.sourceFiles += isPrimarySource ? 1 : 0; stats.testFiles += isTest ? 1 : 0; - addCount(stats.roots, analysisDirectoryFor(normalized), 1); + addCount(stats.roots, analysisDirectoryForScope(normalized, analysisScope), 1); languageStats.set(language, stats); if (isPrimarySource) { - addCount(sourceRoots, normalized.split("/")[0] ?? ".", 1); - const hint = corePathHint(normalized); + const localRoot = toAnalysisRelativePath(normalized, analysisScope).split("/")[0] ?? "."; + addCount(sourceRoots, fromAnalysisRelativePath(localRoot, analysisScope), 1); + const hint = corePathHint(normalized, analysisScope); addCount(coreHints, hint, hint ? 1 : 0); } @@ -657,6 +672,7 @@ export async function analyzeProjectProfile(options = {}) { kind: "project-profile", status: "ok", repoRoot, + analysisScope: publicAnalysisScope(analysisScope), filters: filteredTrackedFiles.filters, projectInfo: buildProjectInfo({ repoRoot, @@ -667,12 +683,14 @@ export async function analyzeProjectProfile(options = {}) { sourceRoots: sortedSourceRoots, entryCandidates: sortedEntryCandidates, totals, + analysisScope, }), agentInstructions: analyzeAgentInstructions({ trackedFiles, sourceRecords, sourceRoots: sortedSourceRoots, totals, + analysisScope, }), languages, manifests: sortedManifests, @@ -688,6 +706,7 @@ export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); const result = await analyzeProjectProfile({ cwd: option(args, "cwd"), + packageRelPath: option(args, "package-rel-path"), languages: option(args, "languages"), ignore: option(args, "ignore"), measureSourceLines: Boolean(args["measure-source-lines"]), diff --git a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs index 737d91d..bf1b170 100644 --- a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs +++ b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs @@ -12,6 +12,8 @@ export async function collectAgentCustomize(context, options = {}, dependencies provider: context.provider, workspace: context.workspace, language: context.language, + topology: context.topology, + analysisScope: context.analysisScope, "include-user-home": context.authority.includeUserHome, "include-memories": context.authority.includeMemories, ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), diff --git a/scripts/harness-analysis/evidence-bundle/contract.mjs b/scripts/harness-analysis/evidence-bundle/contract.mjs index 1b6438f..e9b385d 100644 --- a/scripts/harness-analysis/evidence-bundle/contract.mjs +++ b/scripts/harness-analysis/evidence-bundle/contract.mjs @@ -1,7 +1,10 @@ +import { existsSync, realpathSync } from "node:fs"; import path from "node:path"; +import { validateWorkspaceTopology } from "../../workspace-topology/index.mjs"; + export const EVIDENCE_BUNDLE_KIND = "better-harness.evidence-bundle"; -export const EVIDENCE_BUNDLE_SCHEMA_VERSION = 1; +export const EVIDENCE_BUNDLE_SCHEMA_VERSION = 2; export const EVIDENCE_LANE_NAMES = Object.freeze([ "sessionEvidence", "projectHarness", @@ -60,20 +63,50 @@ export function freezeEvidenceBundleContext(options = {}, now = new Date()) { code: "INVALID_EVIDENCE_WINDOW", }); } - return { - workspace: path.resolve(String(options.workspace)), + const topology = options.topology; + const analysisScope = options.analysisScope; + const requestedWorkspace = path.resolve(String(options.workspace)); + const canonicalWorkspace = existsSync(requestedWorkspace) + ? realpathSync(requestedWorkspace) + : requestedWorkspace; + if (topology !== undefined) { + validateWorkspaceTopology(topology); + if (canonicalWorkspace !== topology.requestedWorkspace) { + throw Object.assign(new Error("workspace does not match the frozen topology target"), { + code: "EVIDENCE_WORKSPACE_TOPOLOGY_MISMATCH", + }); + } + if (!analysisScope || !new Set(["repo", "path"]).has(analysisScope.kind)) { + throw Object.assign(new Error("topology requires a valid analysisScope"), { + code: "INVALID_EVIDENCE_ANALYSIS_SCOPE", + }); + } + if (analysisScope.route !== topology.target.route + || !Array.isArray(analysisScope.pathspecs)) { + throw Object.assign(new Error("analysisScope is not bound to topology target"), { + code: "EVIDENCE_ANALYSIS_SCOPE_MISMATCH", + }); + } + } else if (analysisScope !== undefined) { + throw Object.assign(new Error("analysisScope requires topology"), { + code: "MISSING_EVIDENCE_TOPOLOGY", + }); + } + return Object.freeze({ + workspace: topology?.requestedWorkspace ?? canonicalWorkspace, provider, language: String(options.language ?? "en"), depth, - window: { since, until }, + window: Object.freeze({ since, until }), evidenceLimit: positiveLimit(options["evidence-limit"] ?? options.evidenceLimit, depth === "quick" ? 3 : 5), - authority: { + authority: Object.freeze({ includeUserHome: enabled(options["include-user-home"] ?? options.includeUserHome), includeMemories: options["include-memories"] === undefined && options.includeMemories === undefined ? provider === "qoder" : enabled(options["include-memories"] ?? options.includeMemories), - }, - }; + }), + ...(topology ? { topology, analysisScope } : {}), + }); } export function availableLane(data, status = "available") { diff --git a/scripts/harness-analysis/evidence-bundle/index.mjs b/scripts/harness-analysis/evidence-bundle/index.mjs index d175642..208f46f 100644 --- a/scripts/harness-analysis/evidence-bundle/index.mjs +++ b/scripts/harness-analysis/evidence-bundle/index.mjs @@ -1,4 +1,5 @@ import { analyzeHarnessEvidence } from "../report-run.mjs"; +import { resolveWorkspaceTopology } from "../../workspace-topology/index.mjs"; import { collectAgentCustomize } from "./agent-customize.mjs"; import { EVIDENCE_BUNDLE_KIND, @@ -39,6 +40,8 @@ async function collectLead(context, options, analyze) { ...(options["canvas-out"] ? { "canvas-out": options["canvas-out"] } : {}), ...(options["replace-canvas"] ? { "replace-canvas": options["replace-canvas"] } : {}), ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), + topology: context.topology, + analysisScope: context.analysisScope, }); if (!data?.evidence || !data?.summaryFacts) { throw Object.assign(new Error("lead analyzer returned an invalid contract"), { @@ -52,7 +55,20 @@ async function collectLead(context, options, analyze) { } export async function collectEvidenceBundle(options = {}, dependencies = {}) { - const context = freezeEvidenceBundleContext(options, dependencies.now?.() ?? new Date()); + const now = dependencies.now?.() ?? new Date(); + const baseContext = freezeEvidenceBundleContext(options, now); + const resolveTopology = dependencies.resolveWorkspaceTopology ?? resolveWorkspaceTopology; + const topologyResolution = await resolveTopology({ + workspace: baseContext.workspace, + maxFiles: options["topology-max-files"] ?? options.topologyMaxFiles, + maxMembers: options["topology-max-members"] ?? options.topologyMaxMembers, + maxInstructionScopes: options["topology-max-instruction-scopes"] ?? options.topologyMaxInstructionScopes, + }); + const context = freezeEvidenceBundleContext({ + ...options, + topology: topologyResolution.topology, + analysisScope: topologyResolution.analysisScope, + }, now); const sessionCollector = dependencies.collectSessionEvidence ?? collectSessionEvidence; const projectCollector = dependencies.collectProjectHarness ?? collectProjectHarness; const customizeCollector = dependencies.collectAgentCustomize ?? collectAgentCustomize; @@ -68,9 +84,10 @@ export async function collectEvidenceBundle(options = {}, dependencies = {}) { const unavailableLanes = EVIDENCE_LANE_NAMES.filter((name) => lanes[name]?.status === "unavailable"); const partialLanes = EVIDENCE_LANE_NAMES.filter((name) => lanes[name]?.status === "partial"); const leadFailed = !laneIsAvailable(lead); - const status = leadFailed || (context.depth === "normal" && incompleteLanes.length > 0) + const topologyIncomplete = context.topology.status !== "complete"; + const status = leadFailed || (context.depth === "normal" && (incompleteLanes.length > 0 || topologyIncomplete)) ? "failed" - : incompleteLanes.length > 0 + : incompleteLanes.length > 0 || topologyIncomplete ? "partial" : "complete"; return { @@ -87,6 +104,9 @@ export async function collectEvidenceBundle(options = {}, dependencies = {}) { unavailableLanes, partialLanes, leadRequired: true, + topologyRequired: true, + topologyStatus: context.topology.status, + topologyIncomplete, individualCommandsRemainDiagnostic: true, }, }; diff --git a/scripts/harness-analysis/evidence-bundle/project-harness.mjs b/scripts/harness-analysis/evidence-bundle/project-harness.mjs index 322f99f..6809f90 100644 --- a/scripts/harness-analysis/evidence-bundle/project-harness.mjs +++ b/scripts/harness-analysis/evidence-bundle/project-harness.mjs @@ -1,13 +1,24 @@ import { buildEvidencePack } from "../../core-change-watch/evidence-pack.mjs"; import { availableLane } from "./contract.mjs"; -export async function collectProjectHarness(context, _options = {}, dependencies = {}) { +export async function collectProjectHarness(context, options = {}, dependencies = {}) { const collect = dependencies.buildEvidencePack ?? buildEvidencePack; - const data = await collect({ cwd: context.workspace }); + const data = await collect({ + cwd: context.topology?.gitRoot ?? context.workspace, + analysisScope: context.analysisScope, + topology: context.topology, + baseRef: options["base-ref"] ?? options.baseRef ?? "HEAD", + ...(context.analysisScope?.kind === "path" + ? { packageRelPath: context.analysisScope.route } + : {}), + }); if (data?.kind !== "core-change-watch-evidence-pack") { throw Object.assign(new Error("project evidence returned an invalid contract"), { code: "INVALID_PROJECT_HARNESS_EVIDENCE", }); } + if (data.status !== "ok") { + return availableLane(data, "partial"); + } return availableLane(data); } diff --git a/scripts/harness-analysis/evidence-bundle/session-evidence.mjs b/scripts/harness-analysis/evidence-bundle/session-evidence.mjs index 23e5ade..9047d72 100644 --- a/scripts/harness-analysis/evidence-bundle/session-evidence.mjs +++ b/scripts/harness-analysis/evidence-bundle/session-evidence.mjs @@ -21,6 +21,8 @@ export async function collectSessionEvidence(context, options = {}, dependencies until: context.window.until, limit: context.evidenceLimit, "episode-limit": context.evidenceLimit, + topology: context.topology, + analysisScope: context.analysisScope, ...(options[`${context.provider}-home`] ? { [`${context.provider}-home`]: options[`${context.provider}-home`] } : {}), }); if (data?.kind !== "session-core-facts" || !Array.isArray(data.candidates)) { diff --git a/scripts/harness-analysis/fluency-dimensions.mjs b/scripts/harness-analysis/fluency-dimensions.mjs index 6d0b622..a415b48 100644 --- a/scripts/harness-analysis/fluency-dimensions.mjs +++ b/scripts/harness-analysis/fluency-dimensions.mjs @@ -16,7 +16,11 @@ export const BETTER_HARNESS_AGENT_FLUENCY_DIMENSION_IDS = [ // no-session or explicitly repository-only fallback. Agent Work Loop is the // default after a source probe confirms usable sessions. export const AGENT_WORK_LOOP_MODEL_ID = "agent-work-loop-v4"; -export const AGENT_WORK_LOOP_REPORT_CONTRACT_VERSION = 25; +// Finding targets were introduced as a required package-scoping contract in +// version 26. Older reports remain readable without a target because their +// report contract did not carry enough information to prove package ownership. +export const FINDING_TARGET_REPORT_CONTRACT_VERSION = 26; +export const AGENT_WORK_LOOP_REPORT_CONTRACT_VERSION = FINDING_TARGET_REPORT_CONTRACT_VERSION; export const LEARNING_CAPTURE_REVIEWED_SCORE_FLOOR = 35; // These IDs and reader-facing labels are the canonical Agent Work Loop diff --git a/scripts/harness-analysis/learning-capture-evidence.mjs b/scripts/harness-analysis/learning-capture-evidence.mjs index c5d9df1..d39c1a5 100644 --- a/scripts/harness-analysis/learning-capture-evidence.mjs +++ b/scripts/harness-analysis/learning-capture-evidence.mjs @@ -1,6 +1,12 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; +import { + isPathInAnalysisScope, + resolveAnalysisScopeForOptions, + scopePathspecArgs, +} from "../core-change-watch/common.mjs"; + const PROJECT_SKILL_RE = /(^|\/)(?:(?:\.qoder|\.agents|\.codex|\.cursor|\.claude|\.github|\.qoder-plugin|\.codex-plugin)\/skills\/|skills\/)[^/]+\/SKILL\.md$/iu; const SPEC_PATH_RE = /(^|\/)(?:(?:docs|\.qoder|\.agents|\.codex|\.cursor)\/(?:specs?|adrs?|rfcs?)\/|(?:specs?|adrs?|rfcs?)\/)[^/]+\.md$/iu; const DESIGN_RECORD_RE = /(^|\/)docs\/[^/]*(?:design|decision)[^/]*\.md$/iu; @@ -297,10 +303,19 @@ function pathCorrelated(sourcePaths, testPaths) { return sourceStems.some((source) => testStems.some((test) => source === test || source.includes(test) || test.includes(source))); } -export function collectBoundedGitHistory(workspace, { limit = 200, timeout = 5_000, runner = spawnSync } = {}) { +export function collectBoundedGitHistory(workspace, { + limit = 200, + timeout = 5_000, + runner = spawnSync, + analysisScope, +} = {}) { + const resolvedScope = analysisScope + ? resolveAnalysisScopeForOptions({ cwd: workspace, analysisScope }) + : null; const result = runner("git", [ - "-C", path.resolve(workspace), "log", "--no-merges", `-n${Math.max(1, Math.min(500, Number(limit) || 200))}`, - "--format=__HD_COMMIT__%H%x09%P%x09%s", "--name-status", "--no-renames", "--", + "-C", resolvedScope?.repoRoot ?? path.resolve(workspace), "log", "--no-merges", `-n${Math.max(1, Math.min(500, Number(limit) || 200))}`, + "--format=__HD_COMMIT__%H%x09%P%x09%s", "--name-status", "--no-renames", + ...(resolvedScope ? scopePathspecArgs(resolvedScope) : ["--"]), ], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024, timeout, windowsHide: true }); if (result?.status !== 0 || typeof result?.stdout !== "string") { return { status: "unavailable", commits: [], error: boundedLabel(result?.error?.code ?? "git-log-failed") }; @@ -317,7 +332,9 @@ export function collectBoundedGitHistory(workspace, { limit = 200, timeout = 5_0 if (!current || !rawLine.includes("\t")) continue; const [status = "", ...fileParts] = rawLine.split("\t"); const file = safePath(fileParts.at(-1)); - if (file) current.files.push({ status: status.slice(0, 1), path: file }); + if (file && (!resolvedScope || isPathInAnalysisScope(file, resolvedScope))) { + current.files.push({ status: status.slice(0, 1), path: file }); + } } return { status: "complete", commits }; } diff --git a/scripts/harness-analysis/practice-findings.mjs b/scripts/harness-analysis/practice-findings.mjs index c4d7400..3ab209a 100644 --- a/scripts/harness-analysis/practice-findings.mjs +++ b/scripts/harness-analysis/practice-findings.mjs @@ -1,4 +1,5 @@ import { ASSET_INTEGRITY_PROFILE } from "../coding-agent-practices/asset-integrity.mjs"; +import { findingTargetFromTopology } from "../workspace-topology/index.mjs"; const PROFILE_RULES = "agents-md-review"; const PROFILE_ASSETS = "agent-assets-review"; @@ -296,6 +297,7 @@ export function projectAgentLintPracticeEvidence({ integrityReview, locale = "en", provider = "qoder", + topology, } = {}) { const normalizedLocale = locale === "zh-CN" ? "zh-CN" : "en"; const grouped = new Map(); @@ -306,42 +308,65 @@ export function projectAgentLintPracticeEvidence({ if (NON_PROBLEM_FINDING_IDS.has(text(finding?.id))) continue; const surface = surfaceFor(finding, profile); if (!surface || !SURFACE_CONFIG[surface]) continue; + const ownerRoute = text(finding?.ownerRoute) || text(finding?.packageRoute) || undefined; + const packageRoute = text(finding?.packageRoute) || ownerRoute; const key = `${profile}:${text(finding?.id)}:${text(finding?.file)}:${text(finding?.assetName)}`; - const current = grouped.get(surface) ?? { findings: [], seen: new Set(), profiles: new Set() }; + const groupKey = `${surface}\u0000${ownerRoute ?? ""}`; + const current = grouped.get(groupKey) ?? { + surface, + ownerRoute, + packageRoute, + findings: [], + seen: new Set(), + profiles: new Set(), + }; if (!current.seen.has(key)) { current.seen.add(key); current.profiles.add(profile); current.findings.push({ ...finding, _profile: profile, _index: index }); } - grouped.set(surface, current); + grouped.set(groupKey, current); } } const findings = SURFACE_ORDER.flatMap((surface) => { - const group = grouped.get(surface); - if (!group || group.findings.length === 0) return []; - const config = SURFACE_CONFIG[surface]; - const profile = group.profiles.has(PROFILE_ASSETS) - ? PROFILE_ASSETS - : group.profiles.has(ASSET_INTEGRITY_PROFILE) - ? ASSET_INTEGRITY_PROFILE - : PROFILE_RULES; - return [{ - id: `practice-${safeId(surface) || "surface"}-quality`, - kind: "evidence-gap", - severity: severityFor(group.findings), - title: titleFor(surface, group.findings, normalizedLocale), - reason: groupedReason(surface, group.findings, normalizedLocale), - expectedOutcome: expectedOutcomeFor(surface, normalizedLocale), - expectedArtifact: config.expectedArtifact, - expectedOutput: [expectedOutputFor(surface, config.expectedArtifact, normalizedLocale)], - aiFixPrompt: aiFixPrompt(surface, group.findings, profile, provider, normalizedLocale), - dimensionRefs: [...config.dimensionRefs], - subdimensionRefs: [...config.subdimensionRefs], - staticEvidence: group.findings.map((finding) => evidenceRef(finding._profile, finding, finding._index)), - practiceSurface: surface, - practiceAssetNames: assetNames(group.findings), - }]; + const surfaceGroups = [...grouped.values()] + .filter((group) => group.surface === surface && group.findings.length > 0) + .sort((left, right) => (left.ownerRoute ?? "").localeCompare(right.ownerRoute ?? "")); + return surfaceGroups.map((group, index) => { + const config = SURFACE_CONFIG[surface]; + const profile = group.profiles.has(PROFILE_ASSETS) + ? PROFILE_ASSETS + : group.profiles.has(ASSET_INTEGRITY_PROFILE) + ? ASSET_INTEGRITY_PROFILE + : PROFILE_RULES; + const baseId = `practice-${safeId(surface) || "surface"}-quality`; + const ownerId = group.ownerRoute === "." ? "root" : safeId(group.ownerRoute); + const id = index === 0 ? baseId : `${baseId}-${ownerId || index + 1}`; + return { + id, + kind: "evidence-gap", + severity: severityFor(group.findings), + title: titleFor(surface, group.findings, normalizedLocale), + reason: groupedReason(surface, group.findings, normalizedLocale), + expectedOutcome: expectedOutcomeFor(surface, normalizedLocale), + expectedArtifact: config.expectedArtifact, + expectedOutput: [expectedOutputFor(surface, config.expectedArtifact, normalizedLocale)], + aiFixPrompt: aiFixPrompt(surface, group.findings, profile, provider, normalizedLocale), + dimensionRefs: [...config.dimensionRefs], + subdimensionRefs: [...config.subdimensionRefs], + staticEvidence: group.findings.map((finding) => evidenceRef(finding._profile, finding, finding._index)), + practiceSurface: surface, + practiceAssetNames: assetNames(group.findings), + ...(group.ownerRoute ? { + ownerRoute: group.ownerRoute, + packageRoute: group.packageRoute, + ...(topology ? { + target: findingTargetFromTopology(topology, { ownerRoute: group.ownerRoute }), + } : {}), + } : {}), + }; + }); }); return { diff --git a/scripts/harness-analysis/record-fix-output.mjs b/scripts/harness-analysis/record-fix-output.mjs index 611d8d9..3c89083 100644 --- a/scripts/harness-analysis/record-fix-output.mjs +++ b/scripts/harness-analysis/record-fix-output.mjs @@ -13,6 +13,10 @@ import { validateTaskLoopCanvasSplit, validateTaskLoopFindings, } from "./task-loop-report.mjs"; +import { + findingTargetErrors, + resolveWorkspaceTopology, +} from "../workspace-topology/index.mjs"; const LOCK_TIMEOUT_MS = 5_000; const STALE_LOCK_MS = 10 * 60_000; @@ -119,19 +123,31 @@ function isWithin(root, candidate) { return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); } -async function assertOutputTargets(actualOutput, workspacePath) { +async function assertOutputTargets(actualOutput, workspacePath, { + findingTarget, + topology, +} = {}) { const workspaceRealPath = await realpath(workspacePath); const homeRealPath = await realpath(os.homedir()); + const topologyRoot = topology?.gitRoot ?? topology?.requestedWorkspace ?? workspaceRealPath; + const projectRealPath = findingTarget ? await realpath(topologyRoot) : workspaceRealPath; + const ownerRoot = findingTarget?.ownerRoute === null || findingTarget?.ownerRoute === undefined + ? null + : path.resolve(topologyRoot, ...String(findingTarget.ownerRoute).split("/")); + const ownerRealPath = ownerRoot ? await realpath(ownerRoot) : null; for (const [index, output] of actualOutput.entries()) { if (!output?.path) continue; const logicalPath = String(output.path); - const root = output.scope === "Global" ? homeRealPath : workspaceRealPath; + const root = output.scope === "Global" ? homeRealPath : projectRealPath; const relativePath = output.scope === "Global" ? logicalPath.slice(2) : logicalPath; const targetPath = path.resolve(root, ...relativePath.split("/")); const targetStat = await stat(targetPath).catch(() => null); if (!targetStat?.isFile()) throw new Error(`actualOutput[${index}].path must resolve to an existing file`); const targetRealPath = await realpath(targetPath); if (!isWithin(root, targetRealPath)) throw new Error(`actualOutput[${index}].path resolves outside its ${output.scope} scope`); + if (output.scope === "Project" && ownerRealPath && !isWithin(ownerRealPath, targetRealPath)) { + throw new Error(`actualOutput[${index}].path resolves outside the finding target ownerRoute`); + } } } @@ -150,6 +166,8 @@ export async function recordFixOutput({ expectedRevision, result, consumeResult = false, + topology, + resolveTopology = resolveWorkspaceTopology, } = {}) { const workspacePath = path.resolve(String(workspace ?? "")); const findingsPath = path.resolve(String(findingsOption ?? "")); @@ -173,6 +191,27 @@ export async function recordFixOutput({ const matches = context.findings.findings.filter((finding) => finding?.id === findingId); if (matches.length !== 1) throw new Error(`finding id must match exactly one row: ${findingId}`); const finding = matches[0]; + let repairTopology = topology; + if (Object.hasOwn(finding, "target")) { + repairTopology ??= (await resolveTopology({ workspace: workspaceRealPath })).topology; + if (repairTopology.status !== "complete") { + throw Object.assign(new Error("finding-bound repair requires complete workspace topology"), { + code: "FINDING_TARGET_TOPOLOGY_INCOMPLETE", + }); + } + const targetErrors = findingTargetErrors(finding.target, { + topology: repairTopology, + required: true, + requireOwnerRoute: true, + prefix: `finding ${findingId}.target`, + }); + if (targetErrors.length > 0) { + throw Object.assign(new Error(targetErrors.join("; ")), { + code: "FINDING_TARGET_MISMATCH", + errors: targetErrors, + }); + } + } const currentRevision = Number.isInteger(finding.actualOutputRevision) ? finding.actualOutputRevision : 0; if (currentRevision !== revision) { throw Object.assign(new Error(`stale fix-output revision: expected ${revision}, found ${currentRevision}`), { @@ -187,7 +226,10 @@ export async function recordFixOutput({ delete finding.postFixScoreReview; context.findings.summary.assignmentSummaries = assignmentSummariesFromFindings(context.findings.findings); assertValidReport(context.findings, context.canvas, "Updated findings validation failed"); - await assertOutputTargets(finding.actualOutput, workspacePath); + await assertOutputTargets(finding.actualOutput, workspacePath, { + findingTarget: finding.target, + topology: repairTopology, + }); await atomicReplace(findingsPath, `${JSON.stringify(context.findings, null, 2)}\n`); payload = { kind: "harness-fix-output-record", diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index 4498617..f1851df 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { existsSync, readFileSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,9 +10,13 @@ import { canvasArtifactsFromReportData, readJsonFile, findingsJsonFromReportData import { repairFindingsJsonData } from "./repair-findings-json.mjs"; import { allocateRunDir } from "./run-dir.mjs"; import { evaluateFindingsJson, validateHarnessCanvasArtifacts } from "./validate-canvas.mjs"; +import { resolveWorkspaceTopology } from "../workspace-topology/index.mjs"; import { evaluateHtmlReport, renderHtml } from "./renderers/html.mjs"; import { renderMarkdown } from "./renderers/markdown.mjs"; -import { isAgentWorkLoopReport } from "./fluency-dimensions.mjs"; +import { + FINDING_TARGET_REPORT_CONTRACT_VERSION, + isAgentWorkLoopReport, +} from "./fluency-dimensions.mjs"; import { mergeTaskLoopCanvasData, projectTaskLoopFindings, @@ -318,6 +322,35 @@ export async function renderReport(options) { direct: options.direct === true, }) : rawInput; + const findings = Array.isArray(rawData?.findings) ? rawData.findings : []; + const hasStructuredFindingTarget = findings.some((finding) => + finding && typeof finding === "object" && Object.hasOwn(finding, "target")); + if (hasStructuredFindingTarget && !options.target) { + throw Object.assign(new Error("--target is required when findings carry structured target metadata"), { + code: "MISSING_RENDER_TARGET", + }); + } + const topology = options.topology + ?? (options.target + ? (await resolveWorkspaceTopology({ workspace: options.target })).topology + : undefined); + if (options.target && topology) { + const requestedTarget = await realpath(path.resolve(options.target)); + const topologyTarget = path.resolve(topology.requestedWorkspace); + if (requestedTarget !== topologyTarget) { + throw Object.assign(new Error("--target does not match the frozen workspace topology"), { + code: "RENDER_WORKSPACE_TOPOLOGY_MISMATCH", + }); + } + } + const requiresPackageFindingTarget = topology?.target?.kind === "workspace-member" + && Number.isInteger(rawData?.summary?.reportContractVersion) + && rawData.summary.reportContractVersion >= FINDING_TARGET_REPORT_CONTRACT_VERSION; + if ((hasStructuredFindingTarget || requiresPackageFindingTarget) && topology?.status !== "complete") { + throw Object.assign(new Error("structured package findings require a complete workspace topology"), { + code: "RENDER_WORKSPACE_TOPOLOGY_INCOMPLETE", + }); + } const repaired = repairFindingsJsonData(rawData, { targetPath: options.target ?? rawData.summary?.project?.path ?? rawData.summary?.projectName, }); @@ -326,6 +359,7 @@ export async function renderReport(options) { language: options.language, target: options.target, dataPath: inputPath, + topology, }); const outputLocation = resolveReportOutputLocation(options); const allocatedRunDir = outputLocation.runDir === null; diff --git a/scripts/harness-analysis/renderers/better-harness.mjs b/scripts/harness-analysis/renderers/better-harness.mjs index 9989824..488b997 100644 --- a/scripts/harness-analysis/renderers/better-harness.mjs +++ b/scripts/harness-analysis/renderers/better-harness.mjs @@ -10,6 +10,7 @@ import { validateTaskLoopFindings, validateTaskLoopUsagePair, } from "../task-loop-report.mjs"; +import { findingTargetErrors } from "../../workspace-topology/index.mjs"; export const BETTER_HARNESS_FINDINGS_FILE = "findings.json"; export const BETTER_HARNESS_CANVAS_DATA_FILE = "canvas.json"; @@ -25,7 +26,7 @@ const BETTER_HARNESS_ALLOWED_IMPORTS = new Set([ `./${BETTER_HARNESS_CANVAS_DATA_FILE}`, ]); const DIMENSION_FIELDS = new Set(["id", "label", "score", "summary", "findingRefs"]); -const FINDING_FIELDS = new Set(["id", "title", "severity", "reason", "aiFixPrompt", "dimensionRefs"]); +const FINDING_FIELDS = new Set(["id", "title", "severity", "reason", "aiFixPrompt", "dimensionRefs", "target"]); const DIMENSION_SUMMARY_EXAMPLE_RE = /^(?:example|示例)\s*[::]/i; function check(id, errors = [], warnings = [], summary = {}) { @@ -138,6 +139,9 @@ function betterHarnessFindingsErrors(findingsText, canvasText) { errors.push(`findings[${index}] dimensionRefs contains unknown dimension id: ${ref}`); } } + errors.push(...findingTargetErrors(finding?.target, { + prefix: `findings[${index}].target`, + })); } return errors; diff --git a/scripts/harness-analysis/repair-findings-json.mjs b/scripts/harness-analysis/repair-findings-json.mjs index c64687b..b538fd2 100644 --- a/scripts/harness-analysis/repair-findings-json.mjs +++ b/scripts/harness-analysis/repair-findings-json.mjs @@ -20,7 +20,7 @@ const VALID_AGENT_SURFACES = new Set([ "Session Insights", "Memories", ]); -const VALID_AGENT_PRACTICE_SCOPES = new Set(["Project", "Global", "Plugin"]); +const VALID_AGENT_PRACTICE_SCOPES = new Set(["Project", "Inherited", "Global", "Plugin"]); const RISK_LABEL_MAP = new Map([ ["high", "High"], @@ -117,6 +117,7 @@ const GENERATED_FINDING_FIELDS = new Set([ "reason", "aiFixPrompt", "dimensionRefs", + "target", ]); function clone(value) { return JSON.parse(JSON.stringify(value)); @@ -202,6 +203,7 @@ function validationCommandFor(finding) { function targetPathFor(finding, options) { const target = firstPathCandidate( + finding?.target?.ownerRoute, finding?.target, finding?.path, finding?.file, diff --git a/scripts/harness-analysis/report-data-schema.mjs b/scripts/harness-analysis/report-data-schema.mjs index bff8027..7b5b55e 100644 --- a/scripts/harness-analysis/report-data-schema.mjs +++ b/scripts/harness-analysis/report-data-schema.mjs @@ -2,13 +2,17 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { evaluateFindingsJson } from "./validate-canvas.mjs"; -import { isAgentWorkLoopReport } from "./fluency-dimensions.mjs"; +import { + FINDING_TARGET_REPORT_CONTRACT_VERSION, + isAgentWorkLoopReport, +} from "./fluency-dimensions.mjs"; import { isFullTaskLoopFindings, splitTaskLoopFindings, validateCompactTaskLoopFindings, validateTaskLoopFindings, } from "./task-loop-report.mjs"; +import { findingTargetErrors } from "../workspace-topology/index.mjs"; const ALLOWED_MODES = new Set(["qoder-canvas", "markdown", "html"]); const ALLOWED_LANGUAGES = new Set(["en", "zh-CN"]); @@ -19,6 +23,7 @@ const FINDING_OUTPUT_FIELDS = [ "reason", "aiFixPrompt", "dimensionRefs", + "target", ]; function isObject(value) { @@ -82,11 +87,18 @@ function findingsDataShapeErrors(data) { return errors; } +function requiresPackageFindingTarget(rawData, topology) { + return topology?.target?.kind === "workspace-member" + && Number.isInteger(rawData?.summary?.reportContractVersion) + && rawData.summary.reportContractVersion >= FINDING_TARGET_REPORT_CONTRACT_VERSION; +} + export function normalizeReportData(rawData, { mode = "qoder-canvas", language, target, dataPath, + topology, } = {}) { const errors = []; if (!ALLOWED_MODES.has(mode)) { @@ -109,6 +121,17 @@ export function normalizeReportData(rawData, { errors.push(...findingsCheck.errors); } } + if (Array.isArray(rawData?.findings) && topology) { + const requireFindingTarget = requiresPackageFindingTarget(rawData, topology); + for (const [index, finding] of rawData.findings.entries()) { + errors.push(...findingTargetErrors(finding?.target, { + topology, + required: requireFindingTarget, + requireOwnerRoute: finding?.target?.kind === "workspace-member", + prefix: `findings[${index}].target`, + })); + } + } if (errors.length > 0) { throw Object.assign(new Error(errors.join("; ")), { diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 81b5a5c..f9295b7 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -161,6 +161,8 @@ export async function analyzeHarnessEvidence(options = {}) { claudeHome: options["claude-home"], cursorHome: options["cursor-home"], qwenHome: options["qwen-home"], + topology: options.topology, + analysisScope: options.analysisScope, })).source; const sourceErrors = validateHarnessReportSource(source); if (sourceErrors.length > 0) { diff --git a/scripts/harness-analysis/report-source/source.mjs b/scripts/harness-analysis/report-source/source.mjs index 52f92dc..6a83b7f 100644 --- a/scripts/harness-analysis/report-source/source.mjs +++ b/scripts/harness-analysis/report-source/source.mjs @@ -9,6 +9,7 @@ import { import { validateLearningLoopReview } from "../learning-loop-candidates.mjs"; import { validateLearningCaptureEvidence } from "../learning-capture-evidence.mjs"; import { validateWorkflowDemandDiagnostics } from "../workflow-demand-diagnostics.mjs"; +import { findingTargetErrors } from "../../workspace-topology/index.mjs"; export const HARNESS_REPORT_SOURCE_SCHEMA_VERSION = 3; export const LEARNING_CAPTURE_INTERVENTION_FINDING_ID = "learning-capture-follow-up"; @@ -1076,6 +1077,9 @@ export function validateHarnessReportSource(source) { errors.push(...currentWorkflowDemandFindingBindingErrors(source)); errors.push(...learningCaptureSkillCreationBindingErrors(source)); errors.push(...validateLearningCaptureEvidence(source.repositoryEvidence?.learningCaptureEvidence)); + errors.push(...findingTargetErrors(source.repositoryEvidence?.findingTarget, { + prefix: "report source repositoryEvidence.findingTarget", + })); errors.push(...validateCheckupReportEvidence(source.repositoryEvidence?.customizationCheckup)); errors.push(...validateLearningCaptureReviewedChecks(source)); errors.push(...sourceFindingIntegrityErrors(source)); diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index fcfebc5..acc2828 100644 --- a/scripts/harness-analysis/task-loop-report.mjs +++ b/scripts/harness-analysis/task-loop-report.mjs @@ -31,13 +31,14 @@ import { } from "./report-source.mjs"; import { projectSemanticFacets, validateSemanticFacets } from "../session-analysis/semantic-facets.mjs"; import { restoreProjectedInterventionLedger, summarizeLearningCapture } from "./intervention-ledger.mjs"; +import { findingTargetErrors } from "../workspace-topology/index.mjs"; const DIMENSIONS = AGENT_WORK_LOOP_DIMENSIONS; const DIMENSION_BY_ID = new Map(DIMENSIONS.map((dimension) => [dimension.id, dimension])); const AI_AGENT_PRACTICE_SURFACES = Object.freeze(["Rules", "Hooks", "Skills", "Commands", "Custom Agents", "MCP", "Workflows", "Plugins", "Session Insights", "Memories"]); const AI_AGENT_PRACTICE_SURFACE_SET = new Set(AI_AGENT_PRACTICE_SURFACES); -const AI_AGENT_PRACTICE_SCOPES = Object.freeze(["Project", "Global", "Plugin"]); +const AI_AGENT_PRACTICE_SCOPES = Object.freeze(["Project", "Inherited", "Global", "Plugin"]); const AI_AGENT_PRACTICE_SCOPE_SET = new Set(AI_AGENT_PRACTICE_SCOPES); export const TASK_LOOP_SUGGESTION_KINDS = Object.freeze([ "try-existing", @@ -68,7 +69,7 @@ const TASK_LOOP_HOST_DIMENSION_FIELDS = Object.freeze([ const LEGACY_TASK_LOOP_HOST_DIMENSION_FIELDS = Object.freeze(["state"]); const TASK_LOOP_HOST_FINDING_FIELDS = Object.freeze([ "id", "title", "severity", "reason", "expectedOutput", "expectedArtifact", "aiFixPrompt", "dimensionRefs", - "actualOutputRevision", "actualOutput", "assignmentSummary", "postFixRepairReview", "postFixScoreReview", + "target", "actualOutputRevision", "actualOutput", "assignmentSummary", "postFixRepairReview", "postFixScoreReview", ]); const LEGACY_TASK_LOOP_HOST_FINDING_FIELDS = Object.freeze(["kind", "subdimensionRefs", "evidenceBridge"]); const TASK_LOOP_CANVAS_SUMMARY_FIELDS = Object.freeze([ @@ -1704,6 +1705,7 @@ function softwareFluencyReviewFindings(source) { ? rows(finding.dimensionRefs).map(String) : [defaultDimensionRef].filter(Boolean), subdimensionRefs: rows(finding?.subdimensionRefs).map(String), + ...(finding && Object.hasOwn(finding, "target") ? { target: finding.target } : {}), staticEvidence: unique([ ...rows(capability?.evidenceRefs), ...rows(finding?.evidenceRefs), @@ -1808,6 +1810,8 @@ function sourceFindings(source, dimensions) { const expectedOutcome = typeof item?.expectedOutcome === "string" && item.expectedOutcome.trim() ? item.expectedOutcome.trim() : projectUnlock(primary, locale); + const hasItemTarget = item && Object.hasOwn(item, "target"); + const fallbackTarget = source?.repositoryEvidence?.findingTarget; const finding = { id: String(item?.id ?? `task-loop-${index + 1}`), kind: ["evidence-gap", "missing-mechanism", "outcome-gap"].includes(item?.kind) ? item.kind : findingKind(primary), @@ -1820,6 +1824,9 @@ function sourceFindings(source, dimensions) { dimensionRefs, subdimensionRefs, evidenceBridge: bridge, + ...(hasItemTarget || fallbackTarget !== undefined + ? { target: hasItemTarget ? item.target : fallbackTarget } + : {}), }; finding.aiFixPrompt = actionableAiFixPrompt(source, finding, primary, locale, reader); finding.expectedOutput = findingExpectedOutput({ ...item, ...finding, aiFixPrompt: finding.aiFixPrompt }); @@ -2817,6 +2824,9 @@ export function validateCompactTaskLoopFindings(data) { && (typeof finding.expectedArtifact !== "string" || !finding.expectedArtifact.trim())) { errors.push(`${prefix}.expectedArtifact must be a non-empty string when supplied`); } + errors.push(...findingTargetErrors(finding?.target, { + prefix: `${prefix}.target`, + })); if (finding.expectedOutput !== undefined && (!Array.isArray(finding.expectedOutput) || finding.expectedOutput.length === 0 @@ -3893,10 +3903,10 @@ export function validateTaskLoopFindings(data) { const prefix = `findings[${index}]`; const findingFields = [ "id", "title", "severity", "reason", "aiFixPrompt", "dimensionRefs", "subdimensionRefs", "evidenceBridge", "expectedArtifact", - "expectedOutput", "kind", "actualOutputRevision", "actualOutput", "assignmentSummary", "postFixRepairReview", "postFixScoreReview", + "expectedOutput", "kind", "target", "actualOutputRevision", "actualOutput", "assignmentSummary", "postFixRepairReview", "postFixScoreReview", ]; const requiredFindingFields = findingFields.filter((field) => ![ - "actualOutputRevision", "actualOutput", "assignmentSummary", "postFixRepairReview", "postFixScoreReview", + "target", "actualOutputRevision", "actualOutput", "assignmentSummary", "postFixRepairReview", "postFixScoreReview", ].includes(field)); errors.push(...unsupportedFields(finding, findingFields, prefix)); for (const field of requiredFindingFields) { @@ -3923,6 +3933,9 @@ export function validateTaskLoopFindings(data) { errors.push(...assignmentSummaryErrors(finding, summary.locale, prefix)); errors.push(...postFixRepairReviewErrors(finding, summary, prefix)); errors.push(...postFixScoreReviewErrors(finding, summary, prefix)); + errors.push(...findingTargetErrors(finding?.target, { + prefix: `${prefix}.target`, + })); if (findingIds.has(finding?.id)) errors.push(`${prefix} duplicates finding id: ${finding?.id}`); findingIds.add(finding?.id); if (!["High", "Medium", "Low"].includes(finding?.severity)) errors.push(`${prefix} has invalid severity: ${finding?.severity}`); diff --git a/scripts/harness-analysis/task-loop-repository-evidence.mjs b/scripts/harness-analysis/task-loop-repository-evidence.mjs index d76c8ba..0f415a6 100644 --- a/scripts/harness-analysis/task-loop-repository-evidence.mjs +++ b/scripts/harness-analysis/task-loop-repository-evidence.mjs @@ -1,7 +1,11 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { listTrackedFiles } from "../core-change-watch/common.mjs"; +import { + fromAnalysisRelativePath, + listTrackedFiles, + resolveAnalysisScopeForOptions, +} from "../core-change-watch/common.mjs"; import { buildLearningCaptureEvidence, collectBoundedGitHistory, @@ -596,14 +600,42 @@ export function buildTaskLoopRepositoryEvidence({ }; } -export function scanTaskLoopRepositoryEvidence({ workspace, insights = {}, secretScan = {}, locale = "en" } = {}) { - const root = path.resolve(workspace); - const trackedFiles = listTrackedFiles(root); +export function scanTaskLoopRepositoryEvidence({ + workspace, + analysisScope, + topology, + insights = {}, + secretScan = {}, + locale = "en", +} = {}) { + if (topology?.gitRoot === null) { + return buildTaskLoopRepositoryEvidence({ + insights, + secretScan, + gitHistory: { status: "unavailable", commits: [], error: "not-a-git-repository" }, + locale, + }); + } + let resolvedScope; + try { + resolvedScope = resolveAnalysisScopeForOptions({ cwd: workspace, analysisScope }); + } catch (error) { + if (analysisScope || error?.code !== "GIT_COMMAND_FAILED") throw error; + return buildTaskLoopRepositoryEvidence({ + insights, + secretScan, + gitHistory: { status: "unavailable", commits: [], error: "not-a-git-repository" }, + locale, + }); + } + const root = resolvedScope.repoRoot; + const trackedFiles = listTrackedFiles(root, resolvedScope); + const packageManifestRoute = fromAnalysisRelativePath("package.json", resolvedScope); let packageManifest = {}; const fileContents = {}; - if (trackedFiles.map(posix).includes("package.json")) { + if (trackedFiles.map(posix).includes(packageManifestRoute)) { try { - packageManifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + packageManifest = JSON.parse(readFileSync(path.join(root, packageManifestRoute), "utf8")); } catch { packageManifest = {}; } @@ -616,6 +648,6 @@ export function scanTaskLoopRepositoryEvidence({ workspace, insights = {}, secre // The inventory remains useful when an optional guidance file cannot be read. } } - const gitHistory = collectBoundedGitHistory(root); + const gitHistory = collectBoundedGitHistory(root, { analysisScope: resolvedScope }); return buildTaskLoopRepositoryEvidence({ trackedFiles, packageManifest, fileContents, insights, secretScan, gitHistory, locale }); } diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 3a14975..f62040e 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -7,7 +7,11 @@ import { fileURLToPath } from "node:url"; import { createAnalyzer } from "../session-analysis.mjs"; import { runAgentLint } from "../agent-lint/index.mjs"; import { scanPaths } from "../agent-guardrails/secret-scan.mjs"; -import { listTrackedFiles } from "../core-change-watch/common.mjs"; +import { + listTrackedFiles, + resolveAnalysisScopeForOptions, + toAnalysisRelativePath, +} from "../core-change-watch/common.mjs"; import { collectProviderInventory, collectQoderInventory, @@ -39,6 +43,7 @@ import { loadPriorLearningCaptureState } from "./learning-capture-state.mjs"; import { scanTaskLoopRepositoryEvidence } from "./task-loop-repository-evidence.mjs"; import { buildLearningLoopReview } from "./learning-loop-candidates.mjs"; import { buildWorkflowDemandDiagnostics } from "./workflow-demand-diagnostics.mjs"; +import { findingTargetFromTopology } from "../workspace-topology/index.mjs"; export const TASK_LOOP_SOURCE_ADAPTER_VERSION = "task-loop-source-v2"; const DEFAULT_LIMIT = 40; @@ -101,6 +106,7 @@ export function projectPracticeCoverageRows(practiceInventory, includeGlobalCapa const projectRows = includeGlobalCapabilities ? [...coverageRows] : coverageRows.filter((row) => { const scopes = rows(row?.scopes); return scopes.includes("Project") + || scopes.includes("Inherited") || scopes.includes("Plugin") || (row?.surface === "Hooks" && scopes.includes("Global")); }); @@ -156,7 +162,7 @@ function coveragePaths(repositoryEvidence, surface) { .filter((row) => { if (String(row?.surface ?? "").toLowerCase() !== surface.toLowerCase()) return false; const scopes = rows(row?.scopes).map((scope) => String(scope)); - return scopes.length === 0 || scopes.includes("Project"); + return scopes.length === 0 || scopes.includes("Project") || scopes.includes("Inherited"); }) .flatMap((row) => rows(row?.paths).map((item) => String(item ?? "").trim()).filter(Boolean)) .filter((item, index, all) => all.indexOf(item) === index) @@ -210,11 +216,30 @@ function isWithinRoot(root, target) { export async function collectTrackedSensitiveConfigFiles( workspace, - trackedFiles = listTrackedFiles(path.resolve(workspace)), + trackedFiles, fsApi = { lstat, realpath }, + analysisScope, + topology, ) { - const root = await fsApi.realpath(path.resolve(workspace)); - const candidates = trackedFiles + let resolvedScope = null; + if (analysisScope || trackedFiles === undefined) { + try { + resolvedScope = resolveAnalysisScopeForOptions({ cwd: workspace, analysisScope }); + } catch (error) { + if ((analysisScope && topology?.gitRoot !== null) || error?.code !== "GIT_COMMAND_FAILED") throw error; + return { + files: [], + candidateCount: 0, + truncated: false, + skippedCount: 0, + errorCount: 1, + }; + } + } + const root = await fsApi.realpath(resolvedScope?.targetRoot ?? path.resolve(workspace)); + const inventory = trackedFiles ?? listTrackedFiles(resolvedScope.repoRoot, resolvedScope); + const candidates = inventory + .map((file) => resolvedScope?.kind === "path" ? toAnalysisRelativePath(file, resolvedScope) : file) .map((file) => String(file ?? "").replaceAll("\\", "/")) .filter((file) => SENSITIVE_CONFIG_FILE_RE.test(file) && !SECRET_SCAN_IGNORE_FILE_RE.test(file)) .sort(); @@ -822,6 +847,8 @@ export async function collectAgentLintPracticeEvidence(options = {}) { claudeHome: options.claudeHome ?? options["claude-home"], cursorHome: options.cursorHome ?? options["cursor-home"], qwenHome: options.qwenHome ?? options["qwen-home"], + topology: options.topology, + analysisScope: options.analysisScope, }; const [instructionReview, assetReview, practiceInventory] = await Promise.all([ runAgentLint({ ...common, profile: "agents-md-review" }), @@ -844,6 +871,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { integrityReview, locale: normalizeReaderLocale(options.language), provider, + topology: options.topology, }); if (!assetReviewSupported) { const assetReviewProjection = projected.reviews.find((review) => review.profile === "agent-assets-review"); @@ -1033,6 +1061,8 @@ export async function createTaskLoopSourceFromSessions(options = {}) { includeGlobalCapabilities: options.includeGlobalCapabilities ?? options["include-global-capabilities"] ?? false, + topology: options.topology, + analysisScope: options.analysisScope, }; const discovery = await analyzer.analyze({ ...analyzerOptions, command: "sources" }); const sessionInventory = Object.freeze(discovery.sessions.map((session) => Object.freeze(structuredClone(session)))); @@ -1080,7 +1110,13 @@ export async function createTaskLoopSourceFromSessions(options = {}) { })).insights, ) : insightResult.insights; - const sensitiveConfigFiles = await collectTrackedSensitiveConfigFiles(options.workspace); + const sensitiveConfigFiles = await collectTrackedSensitiveConfigFiles( + options.workspace, + undefined, + undefined, + options.analysisScope, + options.topology, + ); const secretScan = sensitiveConfigFiles.files.length > 0 ? await scanPaths(sensitiveConfigFiles.files, { cwd: options.workspace, @@ -1091,10 +1127,15 @@ export async function createTaskLoopSourceFromSessions(options = {}) { : { findings: [], summary: { totalFindings: 0 } }; const repositoryEvidence = scanTaskLoopRepositoryEvidence({ workspace: options.workspace, + analysisScope: options.analysisScope, + topology: options.topology, locale: language, insights: insightResult.insights, secretScan, }); + if (options.topology) { + repositoryEvidence.findingTarget = findingTargetFromTopology(options.topology); + } const scanReadErrorCount = rows(secretScan?.stats?.errors).length; const scanSkippedCount = Number(secretScan?.stats?.skippedFiles ?? 0); repositoryEvidence.secretScanCoverage = { diff --git a/scripts/harness-analysis/validate-canvas.mjs b/scripts/harness-analysis/validate-canvas.mjs index 97e82fa..f800b59 100644 --- a/scripts/harness-analysis/validate-canvas.mjs +++ b/scripts/harness-analysis/validate-canvas.mjs @@ -27,6 +27,7 @@ import { validateTaskLoopUsagePair, } from "./task-loop-report.mjs"; import { hasSyntheticEvidenceAlias } from "./ai-fix-prompt.mjs"; +import { findingTargetErrors } from "../workspace-topology/index.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const defaultRepoRoot = path.resolve(__dirname, "../.."); @@ -861,7 +862,7 @@ const FINDING_REQUIRED_FIELDS = [ "aiFixPrompt", "dimensionRefs", ]; -const FINDING_FIELD_SET = new Set(FINDING_REQUIRED_FIELDS); +const FINDING_FIELD_SET = new Set([...FINDING_REQUIRED_FIELDS, "target"]); const FINDINGS_JSON_RISK_LABELS = new Set(["High", "Medium", "Low"]); const AI_AGENT_PRACTICE_SURFACES = new Set([ @@ -876,7 +877,7 @@ const AI_AGENT_PRACTICE_SURFACES = new Set([ "Session Insights", "Memories", ]); -const AI_AGENT_PRACTICE_SCOPES = new Set(["Project", "Global", "Plugin"]); +const AI_AGENT_PRACTICE_SCOPES = new Set(["Project", "Inherited", "Global", "Plugin"]); const AI_FIX_REPAIR_COMMAND_RE = /^\/better-harness\s+(?:fix\s+this\s+issue|修复这个问题)\b/i; const AI_FIX_SCHEDULE_PROMPT_REQUIREMENTS = [ ["/schedule", /\/schedule\b/i], @@ -1301,6 +1302,13 @@ export function evaluateFindingsJson(findingsText, reportText, canvasDataText = if (finding?.severity && !FINDINGS_JSON_RISK_LABELS.has(finding.severity)) { errors.push(`findings[${index}] has invalid severity: ${finding.severity}; use High, Medium, or Low`); } + errors.push(...findingTargetErrors(finding.target, { + topology: options.topology, + required: options.requireFindingTarget === true + || options.topology?.target?.kind === "workspace-member", + requireOwnerRoute: finding?.target?.kind === "workspace-member", + prefix: `findings[${index}].target`, + })); errors.push(...aiFixPromptQualityErrors(finding, index)); const normalized = normalizeFindingTitle(title); if (normalized) { diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 71d90a6..718a6f6 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -147,6 +147,12 @@ const required = [ "package/scripts/review-trigger/cli.mjs", "package/scripts/coding-agent-practices/asset-baseline.mjs", "package/scripts/npm-package/create-bundle.mjs", + "package/scripts/workspace-topology/cli.mjs", + "package/scripts/workspace-topology/contract.mjs", + "package/scripts/workspace-topology/finding-target.mjs", + "package/scripts/workspace-topology/index.mjs", + "package/scripts/workspace-topology/inventory.mjs", + "package/scripts/workspace-topology/manifests.mjs", "package/scripts/harness-analysis/canvas-preview/cli.mjs", "package/scripts/harness-analysis/canvas-preview/fixture.mjs", "package/scripts/harness-analysis/canvas-preview/index.mjs", @@ -267,6 +273,12 @@ const requiredBundleEntries = [ "scripts/findings-recommend/index.mjs", "scripts/review-trigger/cli.mjs", "scripts/coding-agent-practices/asset-baseline.mjs", + "scripts/workspace-topology/cli.mjs", + "scripts/workspace-topology/contract.mjs", + "scripts/workspace-topology/finding-target.mjs", + "scripts/workspace-topology/index.mjs", + "scripts/workspace-topology/inventory.mjs", + "scripts/workspace-topology/manifests.mjs", "scripts/harness-analysis/canvas-preview/cli.mjs", "scripts/harness-analysis/canvas-preview/fixture.mjs", "scripts/harness-analysis/canvas-preview/index.mjs", diff --git a/scripts/session-analysis/platforms/claude.mjs b/scripts/session-analysis/platforms/claude.mjs index 7aceba5..ea0f4fe 100644 --- a/scripts/session-analysis/platforms/claude.mjs +++ b/scripts/session-analysis/platforms/claude.mjs @@ -8,12 +8,17 @@ import { parseArgs, parseBooleanFlag } from "../cli.mjs"; import { forEachJsonLine, pathExists, walkFiles } from "../fs.mjs"; import { expandHome, normalizeWorkspace } from "../paths.mjs"; import { + bindSessionWorkspaceCwds, emitProviderResult, + markSessionReadCoverage, runProviderAnalysis, runProviderCommand, + sessionWorkspaceCwd, + workspaceMatchScopeFromOptions, } from "../provider-runner.mjs"; import { parseResultFacts } from "../result-facts.mjs"; import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; function isWorkspaceMatch(candidate, workspace) { if (!candidate) return false; @@ -21,6 +26,11 @@ function isWorkspaceMatch(candidate, workspace) { return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + export function workspaceToClaudeSlugVariants(workspace) { const expanded = expandHome(workspace ?? process.cwd()); const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); @@ -254,13 +264,27 @@ function auditEvents(raw, sourceRef, options) { return [event]; } -async function probeTranscript(filePath, workspace) { - const summary = { sessionId: path.basename(filePath, ".jsonl"), firstSeen: null, lastSeen: null, workspaceMatch: false }; +async function probeTranscript(filePath, scope) { + const cwdCandidates = new Set(); + const summary = { + sessionId: path.basename(filePath, ".jsonl"), + firstSeen: null, + lastSeen: null, + workspaceMatch: false, + cwds: [], + }; await forEachJsonLine(filePath, (raw) => { summary.sessionId = inferSessionId(raw, summary.sessionId); - if (isWorkspaceMatch(raw?.cwd, workspace)) summary.workspaceMatch = true; + if (typeof raw?.cwd === "string" && raw.cwd.length > 0) cwdCandidates.add(raw.cwd); + if (!scope._workspaceMatchScope && isWorkspaceMatch(raw?.cwd, scope.workspace)) { + summary.workspaceMatch = true; + } mergeTimeRange(summary, inferTimestamp(raw)); }); + summary.cwds = [...cwdCandidates]; + if (scope._workspaceMatchScope) { + summary.workspaceMatch = summary.cwds.some((cwd) => isScopedWorkspaceMatch(cwd, scope)); + } return summary; } @@ -273,7 +297,15 @@ function addRef(sessions, sessionId, workspace, ref) { lastSeen: null, sourceKinds: new Set(), sourceRefs: [], + workspaceCwdCandidates: new Map(), }; + if (typeof ref.cwd === "string" && ref.cwd.length > 0) { + const priority = Number(ref.cwdPriority ?? 0); + session.workspaceCwdCandidates.set( + ref.cwd, + Math.max(priority, session.workspaceCwdCandidates.get(ref.cwd) ?? Number.NEGATIVE_INFINITY), + ); + } session.sourceKinds.add(ref.kind); session.sourceRefs.push(ref); mergeTimeRange(session, ref.firstSeen ?? ref.timestamp); @@ -281,8 +313,30 @@ function addRef(sessions, sessionId, workspace, ref) { sessions.set(sessionId, session); } +function addWorkspaceCwdCandidates(session, candidates, priority) { + if (!session) return; + for (const candidate of candidates) { + if (typeof candidate !== "string" || candidate.length === 0) continue; + session.workspaceCwdCandidates.set( + candidate, + Math.max(priority, session.workspaceCwdCandidates.get(candidate) ?? Number.NEGATIVE_INFINITY), + ); + } +} + function finalizeSession(session) { - return { ...session, sourceKinds: [...session.sourceKinds].sort() }; + const { workspaceCwdCandidates, ...publicSession } = session; + const finalized = { ...publicSession, sourceKinds: [...session.sourceKinds].sort() }; + const priorities = [...workspaceCwdCandidates.values()]; + const strongest = priorities.length > 0 ? Math.max(...priorities) : null; + return bindSessionWorkspaceCwds( + finalized, + strongest === null + ? [] + : [...workspaceCwdCandidates] + .filter(([_cwd, priority]) => priority === strongest) + .map(([cwd]) => cwd), + ); } function dedupeEvents(events) { @@ -307,17 +361,26 @@ export class ClaudeSessionAnalyzer extends SessionAnalyzer { const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); + const transcriptWorkspaces = [...new Set([ + workspace, + workspaceMatchScope?.requestedWorkspace, + workspaceMatchScope?.target.kind === "workspace-member" ? workspaceMatchScope.gitRoot : null, + ].filter(Boolean))]; return { platform: "claude", workspace, home: path.resolve(expandHome(options.home ?? options.claudeHome ?? options["claude-home"] ?? "~/.claude")), - _workspaceSlugVariants: workspaceToClaudeSlugVariants(workspace), + _workspaceSlugVariants: [...new Set( + transcriptWorkspaces.flatMap((candidate) => workspaceToClaudeSlugVariants(candidate)), + )], since: since.label, sinceTime: since.time, until: until.label, untilTime: until.time, sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), + _workspaceMatchScope: workspaceMatchScope, }; } @@ -371,7 +434,7 @@ export class ClaudeSessionAnalyzer extends SessionAnalyzer { if (!await pathExists(rootPath)) continue; const files = await walkFiles(rootPath, { maxDepth: 2, limit: 20_000, match: (file) => file.endsWith(".jsonl") }); for (const filePath of files) { - const probe = await probeTranscript(filePath, scope.workspace); + const probe = await probeTranscript(filePath, scope); if (!probe.workspaceMatch || !withinTimeRange(probe.lastSeen ?? probe.firstSeen, scope)) continue; addRef(sessions, probe.sessionId, scope.workspace, { kind: transcriptRoot.kind, @@ -380,6 +443,7 @@ export class ClaudeSessionAnalyzer extends SessionAnalyzer { firstSeen: probe.firstSeen, lastSeen: probe.lastSeen, }); + addWorkspaceCwdCandidates(sessions.get(probe.sessionId), probe.cwds, 3); } } const knownIds = new Set(sessions.keys()); @@ -420,20 +484,47 @@ export class ClaudeSessionAnalyzer extends SessionAnalyzer { async readSession(session, scope, options = {}) { const events = []; - for (const ref of session.sourceRefs ?? []) { + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const refs = preflight + ? (session.sourceRefs ?? []).filter((ref) => !ref.kind.includes("audit")) + : session.sourceRefs ?? []; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : null; + const rootCandidate = scope._workspaceMatchScope + && classifyWorkspaceCwd(identityCwd, scope._workspaceMatchScope) === WORKSPACE_CWD_MATCH.ROOT_CANDIDATE; + for (const ref of refs) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } if (!ref.path.endsWith(".jsonl")) continue; - await forEachJsonLine(ref.path, (raw, line) => { + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { if (inferSessionId(raw, session.sessionId) !== session.sessionId) return; - if (!ref.kind.includes("audit") && raw?.cwd && !isWorkspaceMatch(raw.cwd, scope.workspace)) return; + if (!ref.kind.includes("audit") + && !rootCandidate + && raw?.cwd + && !isScopedWorkspaceMatch(raw.cwd, scope)) return; for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } - }); + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return dedupeEvents(events).sort((left, right) => + const sorted = dedupeEvents(events) + .map((event) => event.cwd || !identityCwd ? event : { ...event, cwd: identityCwd }) + .sort((left, right) => (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0) || Number(left.evidenceRef?.seq ?? 0) - Number(right.evidenceRef?.seq ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); } async analysisWarnings(_scope, roots, sessions) { diff --git a/scripts/session-analysis/platforms/codex.mjs b/scripts/session-analysis/platforms/codex.mjs index 83fbed6..cf490ba 100644 --- a/scripts/session-analysis/platforms/codex.mjs +++ b/scripts/session-analysis/platforms/codex.mjs @@ -30,6 +30,17 @@ import { factsHydrationLimit, prepareFactsSessionInventory, } from "../session-core-facts.mjs"; +import { + bindSessionWorkspaceCwds, + hydrateWorkspaceSelection, + markSessionReadCoverage, + qualifyWorkspaceSessionInventory, + sessionWorkspaceCwd, + withWorkspaceMatchDiagnostics, + workspaceQualifiedSelectionEntries, + workspaceMatchScopeFromOptions, +} from "../provider-runner.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; const DEFAULT_LIMIT = 50; @@ -331,6 +342,11 @@ function isWorkspaceMatch(candidate, workspace) { return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + function createSessionRecord(sessionId, workspace) { return { sessionId, @@ -340,6 +356,7 @@ function createSessionRecord(sessionId, workspace) { firstSeen: null, lastSeen: null, indexedEventCounts: new Map(), + workspaceCwdCandidates: new Map(), }; } @@ -352,6 +369,13 @@ function addSessionRef(sessions, sessionId, workspace, ref) { } session.sourceKinds.add(ref.kind); + if (typeof ref.cwd === "string" && ref.cwd.length > 0) { + const priority = Number(ref.cwdPriority ?? 0); + session.workspaceCwdCandidates.set( + ref.cwd, + Math.max(priority, session.workspaceCwdCandidates.get(ref.cwd) ?? Number.NEGATIVE_INFINITY), + ); + } session.indexedEventCounts.set(ref.eventType, (session.indexedEventCounts.get(ref.eventType) ?? 0) + 1); mergeTimeRange(session, ref.timestamp); @@ -382,7 +406,7 @@ function addSessionRef(sessions, sessionId, workspace, ref) { function finalizeSession(session) { const sourceKinds = [...session.sourceKinds].sort(); - return { + const finalized = { sessionId: session.sessionId, workspace: session.workspace, sourceKinds, @@ -411,6 +435,16 @@ function finalizeSession(session) { lastSeen: ref.lastSeen, })), }; + const priorities = [...session.workspaceCwdCandidates.values()]; + const strongest = priorities.length > 0 ? Math.max(...priorities) : null; + return bindSessionWorkspaceCwds( + finalized, + strongest === null + ? [] + : [...session.workspaceCwdCandidates] + .filter(([_cwd, priority]) => priority === strongest) + .map(([cwd]) => cwd), + ); } function summarizeEvents(events) { @@ -591,6 +625,7 @@ async function firstJsonlRecord(filePath) { export class CodexSessionAnalyzer extends SessionAnalyzer { async resolveScope(options = {}) { const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); return { @@ -606,6 +641,7 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { includeGlobalCapabilities: parseBooleanFlag( options["include-global-capabilities"] ?? options.includeGlobalCapabilities ?? false, ), + _workspaceMatchScope: workspaceMatchScope, }; } @@ -688,7 +724,8 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { continue; } await forEachJsonLine(root.path, (raw, line) => { - if (!isWorkspaceMatch(inferCwd(raw), scope.workspace)) { + const cwd = inferCwd(raw); + if (!isScopedWorkspaceMatch(cwd, scope)) { return; } const sessionId = inferSessionId(raw); @@ -703,6 +740,8 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { line, eventType: inferType(raw, "audit"), timestamp, + cwd, + cwdPriority: 1, }); }); } @@ -715,7 +754,7 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { } await forEachJsonLine(root.path, (raw, line) => { const cwd = inferCwd(raw); - const planningScope = isWorkspaceMatch(cwd, scope.workspace) ? "workspace" : "user-global"; + const planningScope = isScopedWorkspaceMatch(cwd, scope) ? "workspace" : "user-global"; if (planningScope === "user-global" && !scope.includeGlobalCapabilities) { return; } @@ -732,6 +771,8 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { eventType: "session-index", timestamp, planningScope, + cwd, + cwdPriority: 2, }); }); } @@ -751,7 +792,7 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { const first = await firstJsonlRecord(filePath); const timestamp = inferTimestamp(first); const cwd = inferCwd(first); - const planningScope = cwd && !isWorkspaceMatch(cwd, scope.workspace) ? "user-global" : "workspace"; + const planningScope = cwd && !isScopedWorkspaceMatch(cwd, scope) ? "user-global" : "workspace"; if (planningScope === "user-global" && !scope.includeGlobalCapabilities) { continue; } @@ -766,6 +807,8 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { eventType: "session-jsonl", timestamp, planningScope, + cwd, + cwdPriority: 3, }); } } @@ -926,25 +969,50 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { const includeContent = parseBooleanFlag(options["include-content"] ?? options.includeContent ?? false); const includeCommandText = parseBooleanFlag(options["include-command-text"] ?? options.includeCommandText ?? false); const includeUserText = parseBooleanFlag(options["include-user-text"] ?? options.includeUserText ?? false); - for (const ref of session.sourceRefs ?? []) { + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const refs = preflight + ? (session.sourceRefs ?? []).filter((ref) => ["codex-session-jsonl", "codex-archived-session"].includes(ref.kind)) + : session.sourceRefs ?? []; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : null; + const rootCandidate = scope._workspaceMatchScope + && classifyWorkspaceCwd(identityCwd, scope._workspaceMatchScope) === WORKSPACE_CWD_MATCH.ROOT_CANDIDATE; + for (const ref of refs) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } if (!ref.path.endsWith(".jsonl")) { continue; } - await forEachJsonLine(ref.path, (raw, line) => { + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { const sessionId = inferSessionId(raw, session.sessionId); if (sessionId !== session.sessionId) { return; } - if (!scope.includeGlobalCapabilities && inferCwd(raw) && !isWorkspaceMatch(inferCwd(raw), scope.workspace)) { + if (!scope.includeGlobalCapabilities + && !rootCandidate + && inferCwd(raw) + && !isScopedWorkspaceMatch(inferCwd(raw), scope)) { return; } const event = this.normalizeEvent(raw, { ...ref, line }, { includeContent, includeCommandText, includeUserText }); if (withinTimeRange(event.timestamp, scope)) { events.push(event); } - }); + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return events.sort((a, b) => (timestampMillis(a.timestamp) ?? 0) - (timestampMillis(b.timestamp) ?? 0)); + const sorted = events.sort((a, b) => (timestampMillis(a.timestamp) ?? 0) - (timestampMillis(b.timestamp) ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); } async analyze(options = {}) { @@ -956,34 +1024,37 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { const discoveredSessions = Array.isArray(options.sessionInventory) ? filterSessionsByScope(options.sessionInventory, scope) : filterSessionsByScope(await this.discoverSessions(scope, roots), scope); + const workspaceRun = await qualifyWorkspaceSessionInventory({ + analyzer: this, + sessions: discoveredSessions, + scope, + options, + }); + const qualifiedSessions = workspaceRun.sessions; const factsInventory = factsMode - ? prepareFactsSessionInventory(discoveredSessions, factsContext) - : { sessions: discoveredSessions, omitted: {} }; + ? prepareFactsSessionInventory(qualifiedSessions, factsContext) + : { sessions: qualifiedSessions, omitted: {} }; const sessions = factsInventory.sessions; const warnings = sourceWarnings(roots); + const resultBase = withWorkspaceMatchDiagnostics({ + scope: publicScope(scope), + sources: roots.map(toPublicSource), + sessions, + facets: null, + warnings, + }, workspaceRun); if (options.command === "sources") { - return { - scope: publicScope(scope), - sources: roots.map(toPublicSource), - sessions, - facets: null, - warnings, - }; + return resultBase; } if (options.command === "sessions") { const limit = options.limit === undefined ? null : Number(options.limit); return { - scope: publicScope(scope), - sources: roots.map(toPublicSource), + ...resultBase, sessions: Number.isFinite(limit) ? sessions.slice(0, limit) : sessions, - facets: null, - warnings, }; } - const detailedSessions = []; - const events = []; const insightMode = options.command === "insights"; const fileReadMode = options.command === "file-reads"; const eventOptions = factsMode @@ -991,7 +1062,12 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { : options.command === "facets" || insightMode || fileReadMode ? { ...options, includeCommandText: true, includeUserText: true, includeContent: true } : options; - const selectionEntries = !factsMode && (options.selectionEntries ?? ( + const suppliedSelectionEntries = workspaceQualifiedSelectionEntries( + options.selectionEntries, + sessions, + workspaceRun, + ); + const selectionEntries = !factsMode && (suppliedSelectionEntries ?? ( options.selectionPlan ? await collectSessionSelectionEntries({ analyzer: this, @@ -1008,53 +1084,54 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { strategy: factsMode ? options.selection ?? "stratified" : options.selection, defaultLimit: factsMode ? 5 : DEFAULT_LIMIT, }); - for (const session of selection.sessions) { - const sessionEvents = await this.readSession(session, scope, eventOptions); - for (const event of sessionEvents) { - events.push(event); - } - detailedSessions.push(this.mergeSession(sessionEvents, session)); - } + const hydration = await hydrateWorkspaceSelection({ + analyzer: this, + selection, + scope, + eventOptions, + workspaceRun, + options, + }); + const effectiveSelection = hydration.selection; + const detailedSessions = hydration.detailedSessions; + const events = hydration.events; if (factsMode) { - return buildSessionCoreFacts({ + return withWorkspaceMatchDiagnostics(buildSessionCoreFacts({ scope, events, - selection, + selection: effectiveSelection, warnings, omitted: factsInventory.omitted, episodeLimit: options["episode-limit"] ?? options.episodeLimit ?? options.limit, debug: parseBooleanFlag(options.debug ?? false), - }); + }), workspaceRun, hydration.hydrationQualifications); } if (fileReadMode) { - return { - scope: publicScope(scope), - sources: roots.map(toPublicSource), + return withWorkspaceMatchDiagnostics({ + ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), fileReads: buildFileReadDiagnostics({ scope: publicScope(scope), indexedSessions: sessions, sessions: detailedSessions, warnings, events, - selectionStrategy: selection.strategy, - selectionStrata: selection.strata, + selectionStrategy: effectiveSelection.strategy, + selectionStrata: effectiveSelection.strata, adapterVersion: "codex-v2", }), - warnings, - }; + }, workspaceRun, hydration.hydrationQualifications); } const facets = buildFacets(sessions, detailedSessions, events); if (insightMode) { - return { - scope: publicScope(scope), - sources: roots.map(toPublicSource), + return withWorkspaceMatchDiagnostics({ + ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), facets, insights: buildInsightPack({ scope: publicScope(scope), @@ -1063,22 +1140,19 @@ export class CodexSessionAnalyzer extends SessionAnalyzer { facets, warnings, events, - selectionStrategy: selection.strategy, - selectionStrata: selection.strata, + selectionStrategy: effectiveSelection.strategy, + selectionStrata: effectiveSelection.strata, adapterVersion: "codex-v2", }), - warnings, - }; + }, workspaceRun, hydration.hydrationQualifications); } - return { - scope: publicScope(scope), - sources: roots.map(toPublicSource), + return withWorkspaceMatchDiagnostics({ + ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), facets, - warnings, - }; + }, workspaceRun, hydration.hydrationQualifications); } } diff --git a/scripts/session-analysis/platforms/cursor.mjs b/scripts/session-analysis/platforms/cursor.mjs index 00e962c..14359ff 100644 --- a/scripts/session-analysis/platforms/cursor.mjs +++ b/scripts/session-analysis/platforms/cursor.mjs @@ -8,7 +8,15 @@ import { SessionAnalyzer } from "../../session-analysis.mjs"; import { parseArgs, parseBooleanFlag } from "../cli.mjs"; import { forEachJsonLine, pathExists, walkFiles } from "../fs.mjs"; import { expandHome, normalizeWorkspace } from "../paths.mjs"; -import { emitProviderResult, runProviderAnalysis, runProviderCommand } from "../provider-runner.mjs"; +import { + bindSessionWorkspaceCwds, + emitProviderResult, + markSessionReadCoverage, + runProviderAnalysis, + runProviderCommand, + sessionWorkspaceCwd, + workspaceMatchScopeFromOptions, +} from "../provider-runner.mjs"; import { parseResultFacts } from "../result-facts.mjs"; import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; @@ -258,7 +266,15 @@ function addRef(sessions, sessionId, workspace, ref) { sourceKinds: new Set(), sourceRefs: [], eventTimestampCoverage: "unobserved", + workspaceCwdCandidates: new Map(), }; + if (typeof ref.cwd === "string" && ref.cwd.length > 0) { + const priority = Number(ref.cwdPriority ?? 0); + session.workspaceCwdCandidates.set( + ref.cwd, + Math.max(priority, session.workspaceCwdCandidates.get(ref.cwd) ?? Number.NEGATIVE_INFINITY), + ); + } if (!session.sourceRefs.some((existing) => existing.kind === ref.kind && existing.path === ref.path)) { session.sourceRefs.push(ref); } @@ -270,7 +286,18 @@ function addRef(sessions, sessionId, workspace, ref) { } function finalizeSession(session) { - return { ...session, sourceKinds: [...session.sourceKinds].sort() }; + const { workspaceCwdCandidates, ...publicSession } = session; + const finalized = { ...publicSession, sourceKinds: [...session.sourceKinds].sort() }; + const priorities = [...workspaceCwdCandidates.values()]; + const strongest = priorities.length > 0 ? Math.max(...priorities) : null; + return bindSessionWorkspaceCwds( + finalized, + strongest === null + ? [] + : [...workspaceCwdCandidates] + .filter(([_cwd, priority]) => priority === strongest) + .map(([cwd]) => cwd), + ); } async function readJson(filePath) { @@ -399,11 +426,19 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); + const transcriptWorkspaces = [...new Set([ + workspace, + workspaceMatchScope?.requestedWorkspace, + workspaceMatchScope?.target.kind === "workspace-member" ? workspaceMatchScope.gitRoot : null, + ].filter(Boolean))]; return { platform: "cursor", workspace, home: path.resolve(expandHome(options.home ?? options.cursorHome ?? options["cursor-home"] ?? "~/.cursor")), - _workspaceSlugVariants: workspaceToCursorSlugVariants(workspace), + _workspaceSlugVariants: [...new Set( + transcriptWorkspaces.flatMap((candidate) => workspaceToCursorSlugVariants(candidate)), + )], since: since.label, sinceTime: since.time, until: until.label, @@ -411,6 +446,7 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), _command: options.command ?? null, + _workspaceMatchScope: workspaceMatchScope, }; } @@ -500,7 +536,7 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { if (!knownIds.has(sessionId)) continue; let meta; try { meta = await readJson(filePath); } catch { continue; } - if (meta.cwd && !isWorkspaceMatch(meta.cwd, scope.workspace)) continue; + if (meta.cwd && !scope._workspaceMatchScope && !isWorkspaceMatch(meta.cwd, scope.workspace)) continue; const firstSeen = normalizeTimestamp(meta.createdAtMs); const lastSeen = normalizeTimestamp(meta.updatedAtMs); addRef(sessions, sessionId, scope.workspace, { @@ -509,6 +545,7 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { path: filePath, firstSeen, lastSeen, + ...(meta.cwd ? { cwd: meta.cwd, cwdPriority: 4 } : {}), }); } } @@ -566,15 +603,32 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { async readSession(session, scope, options = {}) { const events = []; - for (const ref of session.sourceRefs ?? []) { + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const refs = preflight + ? (session.sourceRefs ?? []).filter((ref) => ref.kind === "cursor-agent-transcript") + : session.sourceRefs ?? []; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : null; + for (const ref of refs) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } if (ref.kind === "cursor-chat-meta") { let raw; - try { raw = await readJson(ref.path); } catch { continue; } + try { raw = await readJson(ref.path); } catch { + truncated = true; + continue; + } events.push(...this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId }, options) .filter((event) => withinTimeRange(event.timestamp, scope))); continue; } - await forEachJsonLine(ref.path, (raw, line) => { + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { if (ref.kind.includes("audit")) { const identities = [raw?.session_id, raw?.conversation_id, raw?.transcript_path ? path.basename(String(raw.transcript_path), ".jsonl") : null].filter(Boolean); @@ -583,12 +637,20 @@ export class CursorSessionAnalyzer extends SessionAnalyzer { for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (event.sessionId === session.sessionId && withinTimeRange(event.timestamp, scope)) events.push(event); } - }); + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return events.sort((left, right) => + const sorted = events + .map((event) => event.cwd || !identityCwd ? event : { ...event, cwd: identityCwd }) + .sort((left, right) => (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0) || Number(left.evidenceRef?.seq ?? 0) - Number(right.evidenceRef?.seq ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); } async analysisWarnings(scope, roots, sessions) { diff --git a/scripts/session-analysis/platforms/qoder.mjs b/scripts/session-analysis/platforms/qoder.mjs index 4ceb930..2f8e052 100644 --- a/scripts/session-analysis/platforms/qoder.mjs +++ b/scripts/session-analysis/platforms/qoder.mjs @@ -31,6 +31,17 @@ import { factsHydrationLimit, prepareFactsSessionInventory, } from "../session-core-facts.mjs"; +import { + bindSessionWorkspaceCwds, + hydrateWorkspaceSelection, + markSessionReadCoverage, + qualifyWorkspaceSessionInventory, + sessionWorkspaceCwd, + withWorkspaceMatchDiagnostics, + workspaceQualifiedSelectionEntries, + workspaceMatchScopeFromOptions, +} from "../provider-runner.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; const DEFAULT_PLATFORM = "qoder"; const DEFAULT_LIMIT = 50; @@ -72,6 +83,12 @@ function sourceRootPaths(root) { return root.paths ?? [root.path]; } +function transcriptWorkspaceForRoot(scope, sourceRoot) { + const slug = path.basename(sourceRoot); + return scope._workspaceTranscriptIdentities + ?.find((identity) => identity.slugs.includes(slug))?.workspace ?? scope.workspace; +} + function parseSegmentTimestamp(filePath) { const base = path.basename(filePath, path.extname(filePath)); const match = base.match( @@ -93,6 +110,11 @@ function isWorkspaceMatch(candidate, workspace) { return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + function inferSessionId(raw, sourceRef) { return ( raw?.sessionId ?? @@ -611,16 +633,18 @@ function toPublicSource(root) { async function readSessionIdFromJsonl(filePath, fallbackRef) { let found = null; let firstTimestamp = null; + let cwd = null; await forEachJsonLine( filePath, (raw) => { found = inferSessionId(raw, fallbackRef); firstTimestamp = inferTimestamp(raw, fallbackRef); + cwd = inferCwd(raw) ?? cwd; return found ? false : undefined; }, { maxLines: 25 }, ); - return { sessionId: found, timestamp: firstTimestamp }; + return { sessionId: found, timestamp: firstTimestamp, cwd }; } async function readHomeSessionProbe(filePath, fallbackRef, scope) { @@ -628,7 +652,7 @@ async function readHomeSessionProbe(filePath, fallbackRef, scope) { const observe = (raw) => { probe.sessionId = probe.sessionId ?? inferSessionId(raw, fallbackRef); probe.timestamp = probe.timestamp ?? inferTimestamp(raw, fallbackRef); - if (isWorkspaceMatch(inferCwd(raw), scope.workspace)) { + if (isScopedWorkspaceMatch(inferCwd(raw), scope)) { probe.workspaceMatched = true; } return probe.sessionId && probe.timestamp && probe.workspaceMatched ? false : undefined; @@ -655,6 +679,7 @@ function createSessionRecord(sessionId, workspace) { firstSeen: null, lastSeen: null, indexedEventCounts: new Map(), + workspaceCwdCandidates: new Map(), }; } @@ -683,6 +708,13 @@ function addSessionRef(sessions, sessionId, workspace, ref) { } session.sourceKinds.add(ref.kind); + if (typeof ref.cwd === "string" && ref.cwd.length > 0) { + const priority = Number(ref.cwdPriority ?? 0); + session.workspaceCwdCandidates.set( + ref.cwd, + Math.max(priority, session.workspaceCwdCandidates.get(ref.cwd) ?? Number.NEGATIVE_INFINITY), + ); + } addIndexedCount(session, ref.eventType); mergeTimeRange(session, ref.timestamp); @@ -749,7 +781,7 @@ function finalizeSession(session) { lastSeen: ref.lastSeen, })); - return { + const finalized = { sessionId: session.sessionId, workspace: session.workspace, sourceKinds, @@ -759,6 +791,16 @@ function finalizeSession(session) { indexedEventCounts: mapToSortedObject(session.indexedEventCounts), sourceRefs, }; + const priorities = [...session.workspaceCwdCandidates.values()]; + const strongest = priorities.length > 0 ? Math.max(...priorities) : null; + return bindSessionWorkspaceCwds( + finalized, + strongest === null + ? [] + : [...session.workspaceCwdCandidates] + .filter(([_cwd, priority]) => priority === strongest) + .map(([cwd]) => cwd), + ); } function filterSessionsByScope(sessions, scope) { @@ -878,10 +920,22 @@ function disabledRootWarnings(roots) { export class QoderSessionAnalyzer extends SessionAnalyzer { async resolveScope(options = {}) { const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); const home = path.resolve(expandHome(options.home ?? options["qoder-home"] ?? "~/.qoder")); - const workspaceSlugVariants = workspaceToQoderSlugVariants(workspace); + const transcriptWorkspaces = [...new Set([ + workspace, + workspaceMatchScope?.requestedWorkspace, + workspaceMatchScope?.target.kind === "workspace-member" ? workspaceMatchScope.gitRoot : null, + ].filter(Boolean))]; + const workspaceTranscriptIdentities = transcriptWorkspaces.map((identityWorkspace) => ({ + workspace: identityWorkspace, + slugs: workspaceToQoderSlugVariants(identityWorkspace), + })); + const workspaceSlugVariants = [...new Set( + workspaceTranscriptIdentities.flatMap((identity) => identity.slugs), + )]; return { platform: "qoder", @@ -898,6 +952,8 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { includeGlobalCapabilities: parseBooleanFlag( options["include-global-capabilities"] ?? options.includeGlobalCapabilities ?? false, ), + _workspaceMatchScope: workspaceMatchScope, + _workspaceTranscriptIdentities: workspaceTranscriptIdentities, }; } @@ -1012,7 +1068,8 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { await forEachJsonLine(root.path, (raw, line) => { const sessionId = inferSessionId(raw); - if (!sessionId || !isWorkspaceMatch(inferCwd(raw), scope.workspace)) { + const cwd = inferCwd(raw); + if (!sessionId || !isScopedWorkspaceMatch(cwd, scope)) { return; } const timestamp = inferTimestamp(raw); @@ -1025,6 +1082,8 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { line, eventType: inferEventType(raw, "audit"), timestamp, + cwd, + cwdPriority: 1, }); }); } @@ -1036,6 +1095,7 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { } for (const sourceRoot of sourceRootPaths(root)) { + const identityCwd = transcriptWorkspaceForRoot(scope, sourceRoot); const files = await walkFiles(sourceRoot, { maxDepth: 3, limit: 20_000, @@ -1056,6 +1116,8 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { path: filePath, eventType: "segment", timestamp, + cwd: identityCwd, + cwdPriority: 3, }); } } @@ -1065,10 +1127,12 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { const root = roots.find((item) => item.id === "qoder-projects"); if (root?.exists && root.enabled) { for (const sourceRoot of sourceRootPaths(root)) { + const identityCwd = transcriptWorkspaceForRoot(scope, sourceRoot); await this.discoverProjectRootSessions(scope, sessions, sourceRoot, { kind: "project-jsonl", transcriptKind: "execution-transcript", planningScope: "workspace", + identityCwd, }); } } @@ -1094,11 +1158,17 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { kind: "global-project-jsonl", transcriptKind: "global-project-jsonl", planningScope: "user-global", + identityCwd: null, }); } } - async discoverProjectRootSessions(scope, sessions, projectRoot, { kind, transcriptKind, planningScope }) { + async discoverProjectRootSessions(scope, sessions, projectRoot, { + kind, + transcriptKind, + planningScope, + identityCwd, + }) { let entries; try { entries = await readdir(projectRoot, { withFileTypes: true }); @@ -1121,6 +1191,7 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { eventType: "conversation-jsonl", timestamp, planningScope, + ...(identityCwd ? { cwd: identityCwd, cwdPriority: 3 } : {}), }); } @@ -1141,6 +1212,7 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { eventType: "session.state", timestamp, planningScope, + ...(identityCwd ? { cwd: identityCwd, cwdPriority: 3 } : {}), }); } } @@ -1168,6 +1240,7 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { eventType: "execution-transcript", timestamp, planningScope, + ...(identityCwd ? { cwd: identityCwd, cwdPriority: 3 } : {}), }); } } @@ -1447,32 +1520,61 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { const includeContent = parseBooleanFlag(options["include-content"] ?? options.includeContent ?? false); const includeCommandText = parseBooleanFlag(options["include-command-text"] ?? options.includeCommandText ?? false); const includeUserText = parseBooleanFlag(options["include-user-text"] ?? options.includeUserText ?? false); - const refs = session.sourceRefs ?? []; - const workspaceLinked = refs.some( + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const sessionRefs = session.sourceRefs ?? []; + const workspaceLinked = sessionRefs.some( (ref) => ref.kind !== "home-session" && ref.planningScope !== "user-global", ); + const refs = preflight + ? sessionRefs.filter((ref) => ref.kind !== "audit-jsonl" && ref.kind !== "project-state") + : sessionRefs; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : scope.workspace; + const rootCandidate = scope._workspaceMatchScope + && classifyWorkspaceCwd(identityCwd, scope._workspaceMatchScope) === WORKSPACE_CWD_MATCH.ROOT_CANDIDATE; + const readOptions = { includeContent, includeCommandText, includeUserText, rootCandidate }; for (const ref of refs) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } + let readCoverage = null; if (ref.kind === "audit-jsonl") { - await this.readAuditEvents(session.sessionId, scope, ref, events, { includeContent, includeCommandText, includeUserText }); + readCoverage = await this.readAuditEvents( + session.sessionId, + scope, + ref, + events, + { ...readOptions, maxLines: remainingLines }, + ); } else if (ref.kind === "project-state") { - await this.readStateEvent(session.sessionId, ref, events, { includeContent, includeCommandText, includeUserText }); + if (!await this.readStateEvent(session.sessionId, ref, events, readOptions)) truncated = true; } else if (ref.path.endsWith(JSONL_EXT)) { - await this.readJsonlEvents( + readCoverage = await this.readJsonlEvents( session.sessionId, scope, ref, events, - { includeContent, includeCommandText, includeUserText }, + { ...readOptions, maxLines: remainingLines }, { workspaceLinked }, ); } + if (readCoverage?.invalidLines > 0) truncated = true; + if (remainingLines !== null && readCoverage) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return events + const sorted = events .map((event) => event.cwd || event.planningScope === "user-global" ? event - : { ...event, cwd: scope.workspace }) + : { ...event, cwd: identityCwd ?? scope.workspace }) .filter((event) => withinTimeRange(event.timestamp, scope)) .sort((a, b) => { const left = timestampMillis(a.timestamp) ?? 0; @@ -1482,10 +1584,11 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { } return (a.evidenceRef.line ?? a.evidenceRef.seq ?? 0) - (b.evidenceRef.line ?? b.evidenceRef.seq ?? 0); }); + return markSessionReadCoverage(sorted, { truncated }); } async readJsonlEvents(sessionId, scope, ref, events, options, { workspaceLinked = false } = {}) { - await forEachJsonLine(ref.path, (raw, line) => { + return forEachJsonLine(ref.path, (raw, line) => { const sourceRef = { ...ref, line, sessionId }; const rawSessionId = inferSessionId(raw, sourceRef); if (rawSessionId && rawSessionId !== sessionId && ref.kind !== "logs-session") { @@ -1498,29 +1601,30 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { } } else { const recordCwd = inferCwd(raw); - if (recordCwd ? !isWorkspaceMatch(recordCwd, scope.workspace) : !workspaceLinked) { + if (recordCwd ? !isScopedWorkspaceMatch(recordCwd, scope) : !workspaceLinked) { return; } } } events.push(this.normalizeEvent(raw, sourceRef, options)); - }); + }, options.maxLines === null ? {} : { maxLines: options.maxLines }); } async readAuditEvents(sessionId, scope, ref, events, options) { - await forEachJsonLine(ref.path, (raw, line) => { - if (inferSessionId(raw) !== sessionId || !isWorkspaceMatch(inferCwd(raw), scope.workspace)) { + return forEachJsonLine(ref.path, (raw, line) => { + if (inferSessionId(raw) !== sessionId + || (!options.rootCandidate && !isScopedWorkspaceMatch(inferCwd(raw), scope))) { return; } const sourceRef = { ...ref, line, sessionId }; events.push(this.normalizeEvent(raw, sourceRef, options)); - }); + }, options.maxLines === null ? {} : { maxLines: options.maxLines }); } async readStateEvent(sessionId, ref, events, options) { const raw = await readJson(ref.path).catch(() => null); if (!raw) { - return; + return false; } const sourceRef = { ...ref, @@ -1529,6 +1633,7 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { timestamp: normalizeTimestamp(raw.updatedAt ?? raw.createdAt), }; events.push(this.normalizeEvent({ ...raw, type: "session.state" }, sourceRef, options)); + return true; } async analyze(options = {}) { @@ -1536,23 +1641,38 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { const factsContext = factsMode ? createFactsRunContext(options, "qoder") : null; if (factsContext) options = factsContext.options; const result = await super.analyze(options); + const scope = await this.resolveScope(options); + const workspaceRun = await qualifyWorkspaceSessionInventory({ + analyzer: this, + sessions: result.sessions, + scope, + options, + }); + const resultBase = withWorkspaceMatchDiagnostics({ + ...result, + sessions: workspaceRun.sessions, + }, workspaceRun); if (options.command === "sources") { - return result; + return resultBase; } if (options.command === "sessions") { const limit = options.limit === undefined ? null : Number(options.limit); return { - ...result, - sessions: Number.isFinite(limit) ? result.sessions.slice(0, limit) : result.sessions, + ...resultBase, + sessions: Number.isFinite(limit) ? workspaceRun.sessions.slice(0, limit) : workspaceRun.sessions, }; } - const scope = await this.resolveScope(options); const factsInventory = factsMode - ? prepareFactsSessionInventory(result.sessions, factsContext) - : { sessions: result.sessions, omitted: {} }; + ? prepareFactsSessionInventory(workspaceRun.sessions, factsContext) + : { sessions: workspaceRun.sessions, omitted: {} }; const selectableSessions = factsInventory.sessions; - const selectionEntries = !factsMode && (options.selectionEntries ?? ( + const suppliedSelectionEntries = workspaceQualifiedSelectionEntries( + options.selectionEntries, + selectableSessions, + workspaceRun, + ); + const selectionEntries = !factsMode && (suppliedSelectionEntries ?? ( options.selectionPlan ? await collectSessionSelectionEntries({ analyzer: this, @@ -1569,10 +1689,6 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { strategy: factsMode ? options.selection ?? "stratified" : options.selection, defaultLimit: factsMode ? 5 : DEFAULT_LIMIT, }); - const sessions = selection.sessions; - const detailedSessions = []; - const allEvents = []; - const insightMode = options.command === "insights"; const fileReadMode = options.command === "file-reads"; const eventOptions = @@ -1580,68 +1696,74 @@ export class QoderSessionAnalyzer extends SessionAnalyzer { ? { ...options, includeCommandText: true, includeUserText: true } : options; - for (const session of sessions) { - const events = await this.readSession(session, scope, eventOptions); - allEvents.push(...events); - detailedSessions.push(this.mergeSession(events, session)); - } + const hydration = await hydrateWorkspaceSelection({ + analyzer: this, + selection, + scope, + eventOptions, + workspaceRun, + options, + }); + const effectiveSelection = hydration.selection; + const detailedSessions = hydration.detailedSessions; + const allEvents = hydration.events; if (factsMode) { - return buildSessionCoreFacts({ + return withWorkspaceMatchDiagnostics(buildSessionCoreFacts({ scope, events: allEvents, - selection, - warnings: result.warnings, + selection: effectiveSelection, + warnings: resultBase.warnings, omitted: factsInventory.omitted, episodeLimit: options["episode-limit"] ?? options.episodeLimit ?? options.limit, debug: parseBooleanFlag(options.debug ?? false), - }); + }), workspaceRun, hydration.hydrationQualifications); } if (fileReadMode) { - const { facets: _unusedFacets, ...baseResult } = result; - return { + const { facets: _unusedFacets, ...baseResult } = resultBase; + return withWorkspaceMatchDiagnostics({ ...baseResult, sessions: detailedSessions, fileReads: buildFileReadDiagnostics({ - scope: result.scope, - indexedSessions: result.sessions, + scope: resultBase.scope, + indexedSessions: workspaceRun.sessions, sessions: detailedSessions, - warnings: result.warnings, + warnings: resultBase.warnings, events: allEvents, }), - }; + }, workspaceRun, hydration.hydrationQualifications); } - const facets = buildFacets(result.sessions, detailedSessions, allEvents); + const facets = buildFacets(workspaceRun.sessions, detailedSessions, allEvents); if (insightMode) { const pricingTable = options["pricing-table"] ? await readJson(path.resolve(options["pricing-table"])) : undefined; - return { - ...result, + return withWorkspaceMatchDiagnostics({ + ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), facets, insights: buildInsightPack({ - scope: result.scope, - sources: result.sources, + scope: resultBase.scope, + sources: resultBase.sources, sessions: detailedSessions, facets, - warnings: result.warnings, + warnings: resultBase.warnings, events: allEvents, - selectionStrategy: selection.strategy, - selectionStrata: selection.strata, + selectionStrategy: effectiveSelection.strategy, + selectionStrata: effectiveSelection.strata, adapterVersion: "qoder-v2", usageOptions: { pricingTable }, }), - }; + }, workspaceRun, hydration.hydrationQualifications); } - return { - ...result, + return withWorkspaceMatchDiagnostics({ + ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), facets, - }; + }, workspaceRun, hydration.hydrationQualifications); } } diff --git a/scripts/session-analysis/provider-runner.mjs b/scripts/session-analysis/provider-runner.mjs index bdc34d8..f01bdb9 100644 --- a/scripts/session-analysis/provider-runner.mjs +++ b/scripts/session-analysis/provider-runner.mjs @@ -16,8 +16,285 @@ import { prepareFactsSessionInventory, } from "./session-core-facts.mjs"; import { mergeTimeRange, timestampMillis } from "./time.mjs"; +import { + WORKSPACE_CWD_MATCH, + WORKSPACE_SESSION_MATCH, + classifyWorkspaceCwd, + qualifyWorkspaceSession, + summarizeWorkspaceQualifications, + validateWorkspaceMatchTopology, +} from "./workspace-match.mjs"; const DEFAULT_LIMIT = 50; +const DEFAULT_WORKSPACE_PREFLIGHT_MAX_LINES = 2_000; +const DEFAULT_WORKSPACE_PATH_FACT_LIMIT = 2_000; +const SESSION_WORKSPACE_CWDS = Symbol("session-workspace-cwds"); +const SESSION_READ_COVERAGE = Symbol("session-read-coverage"); + +function boundedPositiveInteger(value, fallback, maximum = 20_000) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(maximum, Math.max(1, Math.trunc(parsed))); +} + +function workspacePreflightMaxLines(options = {}) { + return boundedPositiveInteger( + options.workspacePreflightMaxLines ?? options["workspace-preflight-max-lines"], + DEFAULT_WORKSPACE_PREFLIGHT_MAX_LINES, + ); +} + +function workspacePathFactLimit(options = {}) { + return boundedPositiveInteger( + options.workspacePathFactLimit ?? options["workspace-path-fact-limit"], + DEFAULT_WORKSPACE_PATH_FACT_LIMIT, + ); +} + +function fullHydrationEventOptions(options = {}) { + const hydrationOptions = { ...options }; + delete hydrationOptions.workspacePreflightMaxLines; + delete hydrationOptions["workspace-preflight-max-lines"]; + return hydrationOptions; +} + +export function workspaceMatchScopeFromOptions(options = {}) { + return options.topology ? validateWorkspaceMatchTopology(options.topology) : null; +} + +export function bindSessionWorkspaceCwds(session, candidates = []) { + if (!session || typeof session !== "object") return session; + const values = [...new Set( + (Array.isArray(candidates) ? candidates : [candidates]) + .filter((candidate) => typeof candidate === "string" && candidate.length > 0), + )]; + Object.defineProperty(session, SESSION_WORKSPACE_CWDS, { + configurable: true, + enumerable: false, + value: Object.freeze(values), + }); + return session; +} + +export function sessionWorkspaceCwds(session) { + const bound = session?.[SESSION_WORKSPACE_CWDS]; + if (Array.isArray(bound) && bound.length > 0) return bound; + const explicit = session?.workspaceCwd ?? session?.cwd; + return typeof explicit === "string" && explicit.length > 0 ? [explicit] : []; +} + +export function sessionWorkspaceCwd(session, workspaceScope) { + if (!workspaceScope) return sessionWorkspaceCwds(session)[0] ?? null; + if (session?.workspaceMatch === WORKSPACE_SESSION_MATCH.DIRECT_CWD) { + return workspaceScope.requestedWorkspace; + } + if (session?.workspaceMatch === WORKSPACE_SESSION_MATCH.ROOT_CWD) { + return workspaceScope.gitRoot; + } + const candidates = sessionWorkspaceCwds(session); + if (candidates.length === 0) return null; + const matches = new Set(candidates.map((candidate) => classifyWorkspaceCwd(candidate, workspaceScope))); + if (matches.size !== 1) return null; + const [match] = matches; + if (match === WORKSPACE_CWD_MATCH.DIRECT) return workspaceScope.requestedWorkspace; + if (match === WORKSPACE_CWD_MATCH.ROOT_CANDIDATE) return workspaceScope.gitRoot; + return candidates.length === 1 ? candidates[0] : null; +} + +export function markSessionReadCoverage(events, { truncated = false } = {}) { + if (!Array.isArray(events)) return events; + Object.defineProperty(events, SESSION_READ_COVERAGE, { + configurable: true, + enumerable: false, + value: Object.freeze({ truncated: truncated === true }), + }); + return events; +} + +function sessionReadCoverage(events) { + return events?.[SESSION_READ_COVERAGE] ?? null; +} + +function trustedPathFacts(events = [], limit = DEFAULT_WORKSPACE_PATH_FACT_LIMIT) { + const pathFacts = []; + let truncated = false; + for (const event of events) { + const paths = [ + event?.filePath, + ...(Array.isArray(event?.targetPaths) ? event.targetPaths : []), + ...(Array.isArray(event?.affectedPaths) ? event.affectedPaths : []), + ]; + for (const candidate of paths) { + if (typeof candidate !== "string" || candidate.length === 0) continue; + if (pathFacts.length >= limit) { + truncated = true; + break; + } + pathFacts.push(event?.cwd ? { path: candidate, cwd: event.cwd } : { path: candidate }); + } + if (truncated) break; + } + return { pathFacts, truncated }; +} + +async function readWorkspacePreflight(analyzer, session, scope, options) { + const maxLines = workspacePreflightMaxLines(options); + const readOptions = { + ...options, + includeCommandText: false, + includeUserText: false, + includeContent: false, + "include-command-text": false, + "include-user-text": false, + "include-content": false, + workspacePreflightMaxLines: maxLines, + }; + const read = typeof analyzer.readWorkspacePreflight === "function" + ? await analyzer.readWorkspacePreflight(session, scope, readOptions) + : await analyzer.readSession(session, scope, readOptions); + if (Array.isArray(read)) { + const coverage = sessionReadCoverage(read); + return { events: read, truncated: coverage ? coverage.truncated : true }; + } + return { + events: Array.isArray(read?.events) ? read.events : [], + truncated: read?.truncated === true + || (read?.truncated !== false && !sessionReadCoverage(read?.events)) + || sessionReadCoverage(read?.events)?.truncated === true, + }; +} + +function admittedSession(session, qualification) { + const admitted = { + ...session, + workspaceMatch: qualification.workspaceMatch, + }; + return bindSessionWorkspaceCwds(admitted, sessionWorkspaceCwds(session)); +} + +export async function qualifyWorkspaceSessionInventory({ + analyzer, + sessions = [], + scope, + options = {}, +} = {}) { + const workspaceScope = scope?._workspaceMatchScope ?? null; + if (!workspaceScope) { + return { + enabled: false, + sessions, + qualifications: [], + workspaceScope: null, + }; + } + + const admitted = []; + const qualifications = []; + const pathFactLimit = workspacePathFactLimit(options); + for (const session of sessions) { + const cwd = sessionWorkspaceCwd(session, workspaceScope); + const cwdMatch = classifyWorkspaceCwd(cwd, workspaceScope); + let qualification; + if (cwdMatch === WORKSPACE_CWD_MATCH.ROOT_CANDIDATE) { + const preflight = await readWorkspacePreflight(analyzer, session, scope, options); + const facts = trustedPathFacts(preflight.events, pathFactLimit); + qualification = qualifyWorkspaceSession({ + cwd, + pathFacts: facts.pathFacts, + truncated: preflight.truncated || facts.truncated, + }, workspaceScope); + } else { + qualification = qualifyWorkspaceSession({ cwd }, workspaceScope); + } + qualifications.push(qualification); + if (qualification.qualified) admitted.push(admittedSession(session, qualification)); + } + + return { + enabled: true, + sessions: admitted, + qualifications, + workspaceScope, + }; +} + +export function recheckHydratedWorkspaceSession(session, events, workspaceRun, options = {}) { + if (!workspaceRun?.enabled) return null; + if (session?.workspaceMatch === WORKSPACE_SESSION_MATCH.DIRECT_CWD) { + return qualifyWorkspaceSession({ cwd: workspaceRun.workspaceScope.requestedWorkspace }, workspaceRun.workspaceScope); + } + const facts = trustedPathFacts(events, workspacePathFactLimit(options)); + const cwd = sessionWorkspaceCwd(session, workspaceRun.workspaceScope); + const readCoverage = sessionReadCoverage(events); + return qualifyWorkspaceSession({ + cwd, + pathFacts: facts.pathFacts, + truncated: facts.truncated || !readCoverage || readCoverage.truncated, + }, workspaceRun.workspaceScope); +} + +export function workspaceQualifiedSelectionEntries(entries, sessions, workspaceRun) { + if (!workspaceRun?.enabled || !Array.isArray(entries)) return entries; + const eligibleIds = new Set(sessions.map((session) => session?.sessionId).filter(Boolean)); + const reconciled = entries.filter((entry) => eligibleIds.has(entry?.session?.sessionId)); + if (reconciled.length !== entries.length || reconciled.length !== sessions.length) { + throw Object.assign(new Error("session selection entries do not match the qualified workspace population"), { + code: "SESSION_SELECTION_WORKSPACE_POPULATION_DRIFT", + }); + } + return reconciled; +} + +export async function hydrateWorkspaceSelection({ + analyzer, + selection, + scope, + eventOptions = {}, + workspaceRun, + options = {}, +} = {}) { + const selectedSessions = selection?.sessions ?? []; + const admittedSessions = []; + const detailedSessions = []; + const events = []; + const hydrationQualifications = []; + for (const session of selectedSessions) { + const sessionEvents = await analyzer.readSession(session, scope, eventOptions); + const qualification = recheckHydratedWorkspaceSession(session, sessionEvents, workspaceRun, options); + if (qualification && session.workspaceMatch === WORKSPACE_SESSION_MATCH.ROOT_CWD) { + hydrationQualifications.push(qualification); + } + if (qualification && !qualification.qualified) continue; + admittedSessions.push(session); + for (const event of sessionEvents) { + events.push(event); + } + detailedSessions.push(analyzer.mergeSession(sessionEvents, session)); + } + const effectiveSelection = workspaceRun?.enabled && admittedSessions.length !== selectedSessions.length + ? { + ...selection, + sessions: admittedSessions, + analyzedCount: admittedSessions.length, + } + : selection; + return { selection: effectiveSelection, detailedSessions, events, hydrationQualifications }; +} + +export function workspaceMatchDiagnostics(workspaceRun, hydrationQualifications = []) { + if (!workspaceRun?.enabled) return null; + return Object.freeze({ + kind: "better-harness.session-workspace-match", + schemaVersion: 1, + preflight: summarizeWorkspaceQualifications(workspaceRun.qualifications), + hydration: summarizeWorkspaceQualifications(hydrationQualifications), + }); +} + +export function withWorkspaceMatchDiagnostics(result, workspaceRun, hydrationQualifications = []) { + const diagnostics = workspaceMatchDiagnostics(workspaceRun, hydrationQualifications); + return diagnostics ? { ...result, sessionWorkspaceMatch: diagnostics } : result; +} export function publicScope(scope = {}) { return Object.fromEntries( @@ -152,21 +429,28 @@ export async function runProviderAnalysis(analyzer, options = {}, config = {}) { const discovered = Array.isArray(options.sessionInventory) ? filterSessionsByScope(options.sessionInventory, scope) : filterSessionsByScope(await analyzer.discoverSessions(scope, roots), scope); + const workspaceRun = await qualifyWorkspaceSessionInventory({ + analyzer, + sessions: discovered, + scope, + options, + }); + const qualifiedSessions = workspaceRun.sessions; const factsInventory = factsMode - ? prepareFactsSessionInventory(discovered, factsContext) - : { sessions: discovered, omitted: {} }; + ? prepareFactsSessionInventory(qualifiedSessions, factsContext) + : { sessions: qualifiedSessions, omitted: {} }; const sessions = factsInventory.sessions; const warnings = [ ...sourceWarnings(roots), ...(typeof analyzer.analysisWarnings === "function" ? await analyzer.analysisWarnings(scope, roots, sessions) : []), ]; - const resultBase = { + const resultBase = withWorkspaceMatchDiagnostics({ scope: publicScope(scope), sources: roots.map(publicSource), sessions, facets: null, warnings, - }; + }, workspaceRun); if (options.command === "sources") return resultBase; if (options.command === "sessions") { const limit = options.limit === undefined ? null : Number(options.limit); @@ -180,7 +464,12 @@ export async function runProviderAnalysis(analyzer, options = {}, config = {}) { : options.command === "facets" || insightMode || fileReadMode ? { ...options, includeCommandText: true, includeUserText: true, includeContent: true } : options; - const selectionEntries = !factsMode && (options.selectionEntries ?? (options.selectionPlan + const suppliedSelectionEntries = workspaceQualifiedSelectionEntries( + options.selectionEntries, + sessions, + workspaceRun, + ); + const selectionEntries = !factsMode && (suppliedSelectionEntries ?? (options.selectionPlan ? await collectSessionSelectionEntries({ analyzer, sessions, @@ -195,58 +484,62 @@ export async function runProviderAnalysis(analyzer, options = {}, config = {}) { strategy: factsMode ? options.selection ?? "stratified" : options.selection, defaultLimit: factsMode ? 5 : DEFAULT_LIMIT, }); - const detailedSessions = []; - const events = []; - for (const session of selection.sessions) { - const sessionEvents = await analyzer.readSession(session, scope, eventOptions); - events.push(...sessionEvents); - detailedSessions.push(analyzer.mergeSession(sessionEvents, session)); - } + const hydration = await hydrateWorkspaceSelection({ + analyzer, + selection, + scope, + eventOptions: fullHydrationEventOptions(eventOptions), + workspaceRun, + options, + }); + const effectiveSelection = hydration.selection; + const detailedSessions = hydration.detailedSessions; + const events = hydration.events; if (factsMode) { const sourceCoverage = typeof analyzer.factsSourceCoverage === "function" ? await analyzer.factsSourceCoverage(scope, { roots, discoveredSessions: discovered, eligibleSessions: sessions, - selectedSessions: selection.sessions, + selectedSessions: effectiveSelection.sessions, events, warnings, }) : null; - return buildSessionCoreFacts({ + return withWorkspaceMatchDiagnostics(buildSessionCoreFacts({ scope, events, - selection, + selection: effectiveSelection, warnings, omitted: factsInventory.omitted, episodeLimit: options["episode-limit"] ?? options.episodeLimit ?? options.limit, debug: parseBooleanFlag(options.debug ?? false), sourceCoverage, - }); + }), workspaceRun, hydration.hydrationQualifications); } if (fileReadMode) { - return { + return withWorkspaceMatchDiagnostics({ ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), fileReads: buildFileReadDiagnostics({ scope: publicScope(scope), indexedSessions: sessions, sessions: detailedSessions, warnings, events, - selectionStrategy: selection.strategy, - selectionStrata: selection.strata, + selectionStrategy: effectiveSelection.strategy, + selectionStrata: effectiveSelection.strata, adapterVersion, }), - }; + }, workspaceRun, hydration.hydrationQualifications); } const facets = buildProviderFacets({ indexedSessions: sessions, detailedSessions, events, platform }); if (insightMode) { - return { + return withWorkspaceMatchDiagnostics({ ...resultBase, sessions: detailedSessions, - selection: selectionSummary(selection), + selection: selectionSummary(effectiveSelection), facets, insights: buildInsightPack({ scope: publicScope(scope), @@ -255,14 +548,19 @@ export async function runProviderAnalysis(analyzer, options = {}, config = {}) { facets, warnings, events, - selectionStrategy: selection.strategy, - selectionStrata: selection.strata, + selectionStrategy: effectiveSelection.strategy, + selectionStrata: effectiveSelection.strata, adapterVersion, usageOptions: { pricingTable: await pricingTable(options) }, }), - }; + }, workspaceRun, hydration.hydrationQualifications); } - return { ...resultBase, sessions: detailedSessions, selection: selectionSummary(selection), facets }; + return withWorkspaceMatchDiagnostics({ + ...resultBase, + sessions: detailedSessions, + selection: selectionSummary(effectiveSelection), + facets, + }, workspaceRun, hydration.hydrationQualifications); } function filterEvents(result, type) { @@ -288,15 +586,36 @@ export async function runProviderCommand(analyzer, command, options = {}) { if (command === "show" || command === "events") { const index = await analyzer.analyze({ ...commandOptions, command: "sessions", limit: 1 }); const scope = await analyzer.resolveScope(commandOptions); + const workspaceRun = { + enabled: Boolean(scope._workspaceMatchScope), + workspaceScope: scope._workspaceMatchScope ?? null, + }; const sessions = []; + const hydrationQualifications = []; + const eventOptions = fullHydrationEventOptions(commandOptions); for (const session of index.sessions) { - const events = await analyzer.readSession(session, scope, commandOptions); + const events = await analyzer.readSession(session, scope, eventOptions); + const qualification = recheckHydratedWorkspaceSession(session, events, workspaceRun, commandOptions); + if (qualification && session.workspaceMatch === WORKSPACE_SESSION_MATCH.ROOT_CWD) { + hydrationQualifications.push(qualification); + } + if (qualification && !qualification.qualified) continue; sessions.push({ ...analyzer.mergeSession(events, session), events: command === "show" && !parseBooleanFlag(options["include-events"] ?? false) ? undefined : events, }); } - return command === "events" ? filterEvents({ ...index, sessions }, options.type) : { ...index, sessions }; + const result = index.sessionWorkspaceMatch + ? { + ...index, + sessions, + sessionWorkspaceMatch: { + ...index.sessionWorkspaceMatch, + hydration: summarizeWorkspaceQualifications(hydrationQualifications), + }, + } + : { ...index, sessions }; + return command === "events" ? filterEvents(result, options.type) : result; } throw new Error(`Unknown command: ${command}`); } diff --git a/scripts/session-analysis/workspace-match.mjs b/scripts/session-analysis/workspace-match.mjs new file mode 100644 index 0000000..914b823 --- /dev/null +++ b/scripts/session-analysis/workspace-match.mjs @@ -0,0 +1,399 @@ +import path from "node:path"; + +export const WORKSPACE_TOPOLOGY_KIND = "better-harness.workspace-topology"; +export const WORKSPACE_TOPOLOGY_SCHEMA_VERSION = 1; +export const SESSION_WORKSPACE_SCOPE_KIND = "better-harness.session-workspace-scope"; + +export const WORKSPACE_CWD_MATCH = Object.freeze({ + DIRECT: "direct-cwd", + ROOT_CANDIDATE: "root-cwd-candidate", + UNMATCHED: "unmatched", +}); + +export const WORKSPACE_SESSION_MATCH = Object.freeze({ + DIRECT_CWD: "direct-cwd", + ROOT_CWD: "root-cwd", +}); + +export const WORKSPACE_PATH_CLASS = Object.freeze({ + TARGET: "target", + GIT_OTHER: "git-other", + OUTSIDE_GIT: "outside-git", + UNRESOLVED: "unresolved", +}); + +export const WORKSPACE_QUALIFICATION_STATUS = Object.freeze({ + DIRECT: "direct", + TARGET_ONLY: "target-only", + MIXED: "mixed", + NO_TARGET: "no-target", + AMBIGUOUS: "ambiguous", + TRUNCATED: "truncated", + UNMATCHED_CWD: "unmatched-cwd", +}); + +const TARGET_KINDS = new Set(["repo-root", "workspace-member", "repo-subtree", "standalone"]); +const MEMBER_MATCHES = new Set(["exact", "descendant", "none"]); +const WINDOWS_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)/u; + +function invalidTopology(message) { + throw Object.assign(new TypeError(message), { code: "INVALID_WORKSPACE_TOPOLOGY" }); +} + +function invalidScope() { + throw Object.assign(new TypeError("a validated session workspace scope is required"), { + code: "INVALID_SESSION_WORKSPACE_SCOPE", + }); +} + +function pathFlavor(value) { + if (typeof value !== "string" || value.length === 0 || value.includes("\0")) return null; + if (WINDOWS_ABSOLUTE.test(value)) return "win32"; + if (path.posix.isAbsolute(value)) return "posix"; + return null; +} + +function pathApi(flavor) { + return flavor === "win32" ? path.win32 : path.posix; +} + +function normalizePath(value, flavor) { + const api = pathApi(flavor); + const normalized = api.normalize(value); + const root = api.parse(normalized).root; + if (normalized === root) return normalized; + return normalized.replace(flavor === "win32" ? /[\\/]+$/u : /\/+$/u, ""); +} + +function comparable(value, flavor) { + const normalized = normalizePath(value, flavor); + return flavor === "win32" ? normalized.toLowerCase() : normalized; +} + +function samePath(left, right, flavor) { + return comparable(left, flavor) === comparable(right, flavor); +} + +function isContainedPath(root, candidate, flavor) { + const api = pathApi(flavor); + const relative = api.relative(root, candidate); + return relative === "" + || (relative !== ".." && !relative.startsWith(`..${api.sep}`) && !api.isAbsolute(relative)); +} + +function normalizeAbsolute(value, expectedFlavor, field) { + const flavor = pathFlavor(value); + if (!flavor) invalidTopology(`${field} must be an absolute path`); + if (expectedFlavor && flavor !== expectedFlavor) { + invalidTopology(`${field} must use the same path convention as requestedWorkspace`); + } + return { value: normalizePath(value, flavor), flavor }; +} + +function normalizeRoute(value, field) { + if (value === ".") return value; + if (typeof value !== "string" || value.length === 0 || value.includes("\0") || value.includes("\\")) { + invalidTopology(`${field} must be a canonical Git-root-relative POSIX route`); + } + if (path.posix.isAbsolute(value)) { + invalidTopology(`${field} must be relative to the Git root`); + } + const segments = value.split("/"); + if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + invalidTopology(`${field} must not contain empty, current, or parent segments`); + } + if (path.posix.normalize(value) !== value) { + invalidTopology(`${field} must be normalized`); + } + return value; +} + +function routeContains(parent, child) { + return parent === child || child.startsWith(`${parent}/`); +} + +function expectedWorkspace(gitRoot, route, flavor) { + if (route === ".") return gitRoot; + return pathApi(flavor).join(gitRoot, ...route.split("/")); +} + +/** + * Validate the topology fields needed by session attribution and return a + * normalized internal scope. The topology capability remains the owner of the + * complete public topology contract. + */ +export function validateWorkspaceMatchTopology(topology) { + if (!topology || typeof topology !== "object" || Array.isArray(topology)) { + invalidTopology("workspace topology must be an object"); + } + if (topology.kind !== WORKSPACE_TOPOLOGY_KIND) { + invalidTopology(`workspace topology kind must be ${WORKSPACE_TOPOLOGY_KIND}`); + } + if (topology.schemaVersion !== WORKSPACE_TOPOLOGY_SCHEMA_VERSION) { + invalidTopology(`workspace topology schemaVersion must be ${WORKSPACE_TOPOLOGY_SCHEMA_VERSION}`); + } + + const requested = normalizeAbsolute(topology.requestedWorkspace, null, "requestedWorkspace"); + const target = topology.target; + if (!target || typeof target !== "object" || Array.isArray(target) || !TARGET_KINDS.has(target.kind)) { + invalidTopology("target.kind must be repo-root, workspace-member, repo-subtree, or standalone"); + } + const route = normalizeRoute(target.route, "target.route"); + const memberRoute = target.memberRoute === null + ? null + : normalizeRoute(target.memberRoute, "target.memberRoute"); + if (!MEMBER_MATCHES.has(target.memberMatch)) { + invalidTopology("target.memberMatch must be exact, descendant, or none"); + } + + let gitRoot = null; + if (target.kind === "standalone") { + if (topology.gitRoot !== null) invalidTopology("standalone topology must have a null gitRoot"); + if (route !== "." || memberRoute !== null || target.memberMatch !== "none") { + invalidTopology("standalone target must use route '.', null memberRoute, and memberMatch none"); + } + } else { + const normalizedRoot = normalizeAbsolute(topology.gitRoot, requested.flavor, "gitRoot"); + gitRoot = normalizedRoot.value; + if (!isContainedPath(gitRoot, requested.value, requested.flavor)) { + invalidTopology("requestedWorkspace must be contained by gitRoot"); + } + if (!samePath(expectedWorkspace(gitRoot, route, requested.flavor), requested.value, requested.flavor)) { + invalidTopology("target.route must resolve to requestedWorkspace"); + } + } + + if (target.kind === "repo-root") { + if (route !== "." || memberRoute !== null || target.memberMatch !== "none") { + invalidTopology("repo-root target must use route '.', null memberRoute, and memberMatch none"); + } + } else if (target.kind === "workspace-member") { + if (route === "." || memberRoute === null || !new Set(["exact", "descendant"]).has(target.memberMatch)) { + invalidTopology("workspace-member target requires a member route and exact or descendant memberMatch"); + } + if (!routeContains(memberRoute, route)) { + invalidTopology("target.memberRoute must contain target.route"); + } + if (target.memberMatch === "exact" && memberRoute !== route) { + invalidTopology("exact memberMatch requires target.memberRoute to equal target.route"); + } + if (target.memberMatch === "descendant" && memberRoute === route) { + invalidTopology("descendant memberMatch requires target.route below target.memberRoute"); + } + } else if (target.kind === "repo-subtree") { + if (route === "." || memberRoute !== null || target.memberMatch !== "none") { + invalidTopology("repo-subtree target requires a non-root route and no member match"); + } + } + + return Object.freeze({ + kind: SESSION_WORKSPACE_SCOPE_KIND, + requestedWorkspace: requested.value, + gitRoot, + pathFlavor: requested.flavor, + target: Object.freeze({ + kind: target.kind, + route, + memberRoute, + memberMatch: target.memberMatch, + }), + }); +} + +function requireScope(scope) { + if (!scope || scope.kind !== SESSION_WORKSPACE_SCOPE_KIND) invalidScope(); + return scope; +} + +function normalizeCandidateAbsolute(value, scope) { + if (typeof value !== "string" || value.length === 0 || value.includes("\0")) return null; + if (pathFlavor(value) !== scope.pathFlavor) return null; + return normalizePath(value, scope.pathFlavor); +} + +export function classifyWorkspaceCwd(candidate, inputScope) { + const scope = requireScope(inputScope); + const normalized = normalizeCandidateAbsolute(candidate, scope); + if (!normalized) return WORKSPACE_CWD_MATCH.UNMATCHED; + if (isContainedPath(scope.requestedWorkspace, normalized, scope.pathFlavor)) { + return WORKSPACE_CWD_MATCH.DIRECT; + } + if (scope.target.kind === "workspace-member" + && scope.gitRoot + && samePath(scope.gitRoot, normalized, scope.pathFlavor)) { + return WORKSPACE_CWD_MATCH.ROOT_CANDIDATE; + } + return WORKSPACE_CWD_MATCH.UNMATCHED; +} + +function resolvePathFact(fact, scope, sessionCwd) { + const objectFact = fact && typeof fact === "object" && !Array.isArray(fact) ? fact : null; + const rawPath = typeof fact === "string" ? fact : objectFact?.path ?? objectFact?.filePath; + if (typeof rawPath !== "string" + || rawPath.length === 0 + || rawPath.includes("\0") + || rawPath === "~" + || rawPath.startsWith("~/") + || rawPath.startsWith("~\\")) { + return null; + } + + const absoluteFlavor = pathFlavor(rawPath); + if (absoluteFlavor) { + return absoluteFlavor === scope.pathFlavor ? normalizePath(rawPath, scope.pathFlavor) : null; + } + + const rawBase = objectFact && Object.hasOwn(objectFact, "cwd") ? objectFact.cwd : sessionCwd; + const base = normalizeCandidateAbsolute(rawBase, scope); + if (!base) return null; + return normalizePath(pathApi(scope.pathFlavor).resolve(base, rawPath), scope.pathFlavor); +} + +/** + * Classify one trusted event path fact. This function never reads prose or the + * filesystem and returns only a privacy-safe category. + */ +export function classifyWorkspacePathFact(fact, inputScope, options = {}) { + const scope = requireScope(inputScope); + const absolute = resolvePathFact(fact, scope, options.cwd); + if (!absolute) return WORKSPACE_PATH_CLASS.UNRESOLVED; + if (isContainedPath(scope.requestedWorkspace, absolute, scope.pathFlavor)) { + return WORKSPACE_PATH_CLASS.TARGET; + } + if (scope.gitRoot && isContainedPath(scope.gitRoot, absolute, scope.pathFlavor)) { + return WORKSPACE_PATH_CLASS.GIT_OTHER; + } + return WORKSPACE_PATH_CLASS.OUTSIDE_GIT; +} + +function emptyPathDiagnostics() { + return { observed: 0, target: 0, foreign: 0, unresolved: 0 }; +} + +function result({ qualified, status, workspaceMatch, cwdMatch, pathFacts, truncated, basis }) { + return Object.freeze({ + qualified, + status, + workspaceMatch, + diagnostics: Object.freeze({ + basis, + cwdMatch, + pathFacts: Object.freeze({ ...pathFacts }), + truncated, + }), + }); +} + +/** + * Qualify a discovered session for one workspace. Direct-CWD sessions retain + * current behavior. Root-CWD candidates require positive, target-only trusted + * path facts and fail closed on ambiguity or truncation. + */ +export function qualifyWorkspaceSession({ cwd, pathFacts = [], truncated = false } = {}, inputScope) { + const scope = requireScope(inputScope); + if (!Array.isArray(pathFacts)) { + throw Object.assign(new TypeError("pathFacts must be an array"), { code: "INVALID_SESSION_PATH_FACTS" }); + } + if (typeof truncated !== "boolean") { + throw Object.assign(new TypeError("truncated must be a boolean"), { code: "INVALID_SESSION_PATH_FACTS" }); + } + + const cwdMatch = classifyWorkspaceCwd(cwd, scope); + if (cwdMatch === WORKSPACE_CWD_MATCH.DIRECT) { + return result({ + qualified: true, + status: WORKSPACE_QUALIFICATION_STATUS.DIRECT, + workspaceMatch: WORKSPACE_SESSION_MATCH.DIRECT_CWD, + cwdMatch, + pathFacts: emptyPathDiagnostics(), + truncated: false, + basis: "cwd", + }); + } + if (cwdMatch === WORKSPACE_CWD_MATCH.UNMATCHED) { + return result({ + qualified: false, + status: WORKSPACE_QUALIFICATION_STATUS.UNMATCHED_CWD, + workspaceMatch: null, + cwdMatch, + pathFacts: emptyPathDiagnostics(), + truncated: false, + basis: "cwd", + }); + } + + const counts = emptyPathDiagnostics(); + for (const fact of pathFacts) { + counts.observed += 1; + const classification = classifyWorkspacePathFact(fact, scope, { cwd }); + if (classification === WORKSPACE_PATH_CLASS.TARGET) counts.target += 1; + else if (classification === WORKSPACE_PATH_CLASS.UNRESOLVED) counts.unresolved += 1; + else counts.foreign += 1; + } + + const common = { + workspaceMatch: null, + cwdMatch, + pathFacts: counts, + truncated, + basis: "cwd-and-trusted-path-facts", + }; + if (truncated) { + return result({ ...common, qualified: false, status: WORKSPACE_QUALIFICATION_STATUS.TRUNCATED }); + } + if (counts.unresolved > 0) { + return result({ ...common, qualified: false, status: WORKSPACE_QUALIFICATION_STATUS.AMBIGUOUS }); + } + if (counts.target > 0 && counts.foreign > 0) { + return result({ ...common, qualified: false, status: WORKSPACE_QUALIFICATION_STATUS.MIXED }); + } + if (counts.target === 0) { + return result({ ...common, qualified: false, status: WORKSPACE_QUALIFICATION_STATUS.NO_TARGET }); + } + return result({ + ...common, + qualified: true, + status: WORKSPACE_QUALIFICATION_STATUS.TARGET_ONLY, + workspaceMatch: WORKSPACE_SESSION_MATCH.ROOT_CWD, + }); +} + +/** Aggregate qualifications without retaining paths, CWDs, or session ids. */ +export function summarizeWorkspaceQualifications(qualifications = []) { + if (!Array.isArray(qualifications)) { + throw Object.assign(new TypeError("qualifications must be an array"), { + code: "INVALID_SESSION_WORKSPACE_QUALIFICATIONS", + }); + } + const summary = { + basis: "workspace-match-aggregate", + sessions: qualifications.length, + qualified: { directCwd: 0, rootCwd: 0 }, + omitted: { + unmatchedCwd: 0, + noTargetActivity: 0, + mixedActivity: 0, + ambiguousActivity: 0, + truncatedPreflight: 0, + }, + }; + for (const qualification of qualifications) { + if (qualification?.workspaceMatch === WORKSPACE_SESSION_MATCH.DIRECT_CWD && qualification?.qualified) { + summary.qualified.directCwd += 1; + } else if (qualification?.workspaceMatch === WORKSPACE_SESSION_MATCH.ROOT_CWD + && qualification?.qualified) { + summary.qualified.rootCwd += 1; + } else if (qualification?.status === WORKSPACE_QUALIFICATION_STATUS.UNMATCHED_CWD) { + summary.omitted.unmatchedCwd += 1; + } else if (qualification?.status === WORKSPACE_QUALIFICATION_STATUS.NO_TARGET) { + summary.omitted.noTargetActivity += 1; + } else if (qualification?.status === WORKSPACE_QUALIFICATION_STATUS.MIXED) { + summary.omitted.mixedActivity += 1; + } else if (qualification?.status === WORKSPACE_QUALIFICATION_STATUS.AMBIGUOUS) { + summary.omitted.ambiguousActivity += 1; + } else if (qualification?.status === WORKSPACE_QUALIFICATION_STATUS.TRUNCATED) { + summary.omitted.truncatedPreflight += 1; + } + } + return summary; +} diff --git a/scripts/workspace-topology/cli.mjs b/scripts/workspace-topology/cli.mjs new file mode 100644 index 0000000..19d27da --- /dev/null +++ b/scripts/workspace-topology/cli.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { resolveWorkspaceTopology } from "./index.mjs"; + +const HELP = `Usage: + workspace-topology --workspace [--json] + +Options: + --workspace Workspace root or package target. + --max-files Maximum visible inventory entries. + --max-members Maximum retained workspace members. + --max-instruction-scopes Maximum retained instruction scopes. + --format Output format (default: text). + --json Alias for --format json. + --help Show this help without probing the workspace. +`; + +export function parseArgs(argv = []) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) { + throw Object.assign(new Error(`unexpected argument: ${arg}`), { code: "UNEXPECTED_ARGUMENT" }); + } + const body = arg.slice(2); + const equal = body.indexOf("="); + if (equal !== -1) { + options[body.slice(0, equal)] = body.slice(equal + 1); + continue; + } + if (body === "json" || body === "help") { + options[body] = true; + continue; + } + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + throw Object.assign(new Error(`missing value for --${body}`), { code: "MISSING_OPTION_VALUE" }); + } + options[body] = next; + index += 1; + } + return options; +} + +function humanSummary(result) { + const { topology, analysisScope } = result; + return [ + `Workspace topology: ${topology.status}`, + `Target: ${topology.target.kind} (${topology.target.route})`, + `Git root: ${topology.gitRoot ?? "none"}`, + `Members: ${topology.members.items.length}/${topology.members.total}`, + `Instruction scopes: ${topology.instructionScopes.items.length}/${topology.instructionScopes.total}`, + `Analysis scope: ${analysisScope.kind} (${analysisScope.route})`, + `Inventory: ${topology.discovery.inventoryMode}, scanned ${topology.discovery.scanned}, omitted ${topology.discovery.omitted}`, + ].join("\n"); +} + +export async function main(argv = process.argv.slice(2), dependencies = {}) { + let options; + try { + options = parseArgs(argv); + if (options.help) { + if (dependencies.stdout?.write) dependencies.stdout.write(HELP); + else process.stdout.write(HELP); + return 0; + } + const format = options.json ? "json" : String(options.format ?? "text"); + if (!new Set(["text", "json"]).has(format)) { + throw Object.assign(new Error(`unsupported format: ${format}`), { code: "UNSUPPORTED_FORMAT" }); + } + const result = await (dependencies.resolveWorkspaceTopology ?? resolveWorkspaceTopology)(options); + const output = format === "json" + ? `${JSON.stringify({ topology: result.topology, analysisScope: result.analysisScope })}\n` + : `${humanSummary(result)}\n`; + if (dependencies.stdout?.write) dependencies.stdout.write(output); + else process.stdout.write(output); + return 0; + } catch (error) { + const message = `${error.code ? `${error.code}: ` : ""}${error.message}\n`; + if (dependencies.stderr?.write) dependencies.stderr.write(message); + else process.stderr.write(message); + return 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +let isEntrypoint = false; +try { + isEntrypoint = process.argv[1] && realpathSync(process.argv[1]) === realpathSync(currentFile); +} catch { + isEntrypoint = process.argv[1] === currentFile; +} +if (isEntrypoint) { + process.exitCode = await main(); +} diff --git a/scripts/workspace-topology/contract.mjs b/scripts/workspace-topology/contract.mjs new file mode 100644 index 0000000..8206d8a --- /dev/null +++ b/scripts/workspace-topology/contract.mjs @@ -0,0 +1,276 @@ +import path from "node:path"; + +export const WORKSPACE_TOPOLOGY_KIND = "better-harness.workspace-topology"; +export const WORKSPACE_TOPOLOGY_SCHEMA_VERSION = 1; + +const TARGET_KINDS = new Set([ + "repo-root", + "workspace-member", + "repo-subtree", + "standalone", +]); +const MEMBER_MATCHES = new Set(["exact", "descendant", "none"]); +const STATUS_VALUES = new Set(["complete", "partial"]); +const MEMBER_KINDS = new Set(["manifest", "convention"]); +const INSTRUCTION_PROVIDERS = new Set(["codex", "qoder", "claude", "cursor"]); +const INSTRUCTION_ACTIVATIONS = new Set(["effective", "candidate"]); + +function contractError(message, code = "INVALID_WORKSPACE_TOPOLOGY") { + return Object.assign(new Error(message), { code }); +} + +export function normalizeRoute(value, name = "route") { + let route = String(value ?? "").replaceAll("\\", "/").trim(); + route = route.replace(/^\.\/+/u, "").replace(/\/+$/u, ""); + if (!route || route === ".") return "."; + if (route.startsWith("/") || /^[A-Za-z]:\//u.test(route) || route.includes("\0")) { + throw contractError(`${name} must be a relative route`); + } + const parts = route.split("/").filter((part) => part && part !== "."); + if (parts.some((part) => part === "..")) { + throw contractError(`${name} must not escape its root`); + } + return parts.join("/") || "."; +} + +export function routeContains(parentRoute, childRoute) { + const parent = normalizeRoute(parentRoute, "parent route"); + const child = normalizeRoute(childRoute, "child route"); + return parent === "." || child === parent || child.startsWith(`${parent}/`); +} + +export function relativeRoute(root, target, name = "target") { + const relative = path.relative(path.resolve(root), path.resolve(target)); + if (relative === "") return "."; + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw contractError(`${name} must be contained by the topology root`, "WORKSPACE_OUTSIDE_TOPOLOGY_ROOT"); + } + return normalizeRoute(relative, `${name} route`); +} + +function countEnvelopeErrors(value, name) { + const errors = []; + if (!value || typeof value !== "object" || !Array.isArray(value.items)) { + return [`${name} must contain an items array`]; + } + for (const field of ["total", "omitted"]) { + if (!Number.isInteger(value[field]) || value[field] < 0) { + errors.push(`${name}.${field} must be a non-negative integer`); + } + } + if (typeof value.truncated !== "boolean") { + errors.push(`${name}.truncated must be boolean`); + } + if (Number.isInteger(value.total) && value.total < value.items.length) { + errors.push(`${name}.total must cover retained items`); + } + if (Number.isInteger(value.omitted) + && Number.isInteger(value.total) + && value.items.length + value.omitted !== value.total) { + errors.push(`${name}.omitted must equal total minus retained items`); + } + return errors; +} + +export function workspaceTopologyErrors(topology) { + const errors = []; + if (!topology || typeof topology !== "object" || Array.isArray(topology)) { + return ["topology must be an object"]; + } + if (topology.kind !== WORKSPACE_TOPOLOGY_KIND) { + errors.push(`kind must be ${WORKSPACE_TOPOLOGY_KIND}`); + } + if (topology.schemaVersion !== WORKSPACE_TOPOLOGY_SCHEMA_VERSION) { + errors.push(`schemaVersion must be ${WORKSPACE_TOPOLOGY_SCHEMA_VERSION}`); + } + if (!STATUS_VALUES.has(topology.status)) { + errors.push("status must be complete or partial"); + } + if (!path.isAbsolute(String(topology.requestedWorkspace ?? ""))) { + errors.push("requestedWorkspace must be absolute"); + } + if (topology.gitRoot !== null && !path.isAbsolute(String(topology.gitRoot ?? ""))) { + errors.push("gitRoot must be null or absolute"); + } + + const target = topology.target; + if (!target || typeof target !== "object") { + errors.push("target must be an object"); + } else { + if (!TARGET_KINDS.has(target.kind)) errors.push("target.kind is unsupported"); + if (!MEMBER_MATCHES.has(target.memberMatch)) errors.push("target.memberMatch is unsupported"); + try { + if (normalizeRoute(target.route, "target.route") !== target.route) { + errors.push("target.route must be canonical"); + } + } catch (error) { + errors.push(error.message); + } + if (target.memberRoute !== null) { + try { + if (normalizeRoute(target.memberRoute, "target.memberRoute") !== target.memberRoute) { + errors.push("target.memberRoute must be canonical"); + } + } catch (error) { + errors.push(error.message); + } + } + if (target.kind === "repo-root" && target.route !== ".") { + errors.push("repo-root target.route must be ."); + } + if (target.kind !== "standalone" && topology.gitRoot === null) { + errors.push(`${target.kind} topology requires a gitRoot`); + } + if (target.kind === "standalone" && topology.gitRoot !== null) { + errors.push("standalone topology must not have a gitRoot"); + } + if (target.kind === "workspace-member" && target.memberRoute === null) { + errors.push("workspace-member target requires memberRoute"); + } + if (target.kind === "workspace-member" && target.memberRoute !== null) { + try { + if (!routeContains(target.memberRoute, target.route)) { + errors.push("workspace-member target.memberRoute must contain target.route"); + } + } catch { + // Canonical route errors above already describe this failure. + } + if (target.memberMatch === "exact" && target.memberRoute !== target.route) { + errors.push("exact memberMatch requires target.memberRoute to equal target.route"); + } + if (target.memberMatch === "descendant" && target.memberRoute === target.route) { + errors.push("descendant memberMatch requires target.route below target.memberRoute"); + } + } + if (target.kind !== "workspace-member" + && (target.memberRoute !== null || target.memberMatch !== "none")) { + errors.push(`${target.kind} target must not declare member ownership`); + } + } + + errors.push(...countEnvelopeErrors(topology.members, "members")); + errors.push(...countEnvelopeErrors(topology.instructionScopes, "instructionScopes")); + + for (const [index, member] of (topology.members?.items ?? []).entries()) { + try { + if (normalizeRoute(member.route, `members.items[${index}].route`) !== member.route) { + errors.push(`members.items[${index}].route must be canonical`); + } + } catch (error) { + errors.push(error.message); + } + if (!MEMBER_KINDS.has(member.kind)) { + errors.push(`members.items[${index}].kind is unsupported`); + } + if (!Array.isArray(member.discoveredBy) || member.discoveredBy.length === 0) { + errors.push(`members.items[${index}].discoveredBy must be non-empty`); + } + if (member.manifestRoute !== null && member.manifestRoute !== undefined) { + try { + if (normalizeRoute(member.manifestRoute, `members.items[${index}].manifestRoute`) !== member.manifestRoute) { + errors.push(`members.items[${index}].manifestRoute must be canonical`); + } + } catch (error) { + errors.push(error.message); + } + } + } + + for (const [index, scope] of (topology.instructionScopes?.items ?? []).entries()) { + try { + if (normalizeRoute(scope.route, `instructionScopes.items[${index}].route`) !== scope.route) { + errors.push(`instructionScopes.items[${index}].route must be canonical`); + } + } catch (error) { + errors.push(error.message); + } + if (!INSTRUCTION_PROVIDERS.has(scope.provider)) { + errors.push(`instructionScopes.items[${index}].provider is unsupported`); + } + if (!INSTRUCTION_ACTIVATIONS.has(scope.activation)) { + errors.push(`instructionScopes.items[${index}].activation is unsupported`); + } + } + + if (!topology.discovery || typeof topology.discovery !== "object") { + errors.push("discovery must be an object"); + } else { + for (const field of ["tracked", "untracked", "scanned", "omitted"]) { + if (!Number.isInteger(topology.discovery[field]) || topology.discovery[field] < 0) { + errors.push(`discovery.${field} must be a non-negative integer`); + } + } + if (typeof topology.discovery.truncated !== "boolean") { + errors.push("discovery.truncated must be boolean"); + } + if (!Array.isArray(topology.discovery.warnings)) { + errors.push("discovery.warnings must be an array"); + } + if (Number.isInteger(topology.discovery.tracked) + && Number.isInteger(topology.discovery.untracked) + && Number.isInteger(topology.discovery.scanned) + && Number.isInteger(topology.discovery.omitted) + && topology.discovery.tracked + topology.discovery.untracked + !== topology.discovery.scanned + topology.discovery.omitted) { + errors.push("discovery scanned plus omitted must equal tracked plus untracked"); + } + } + + if (topology.status === "complete" + && (topology.members?.truncated + || topology.instructionScopes?.truncated + || topology.discovery?.truncated)) { + errors.push("complete topology must not contain truncated collections"); + } + + if (target?.memberRoute + && !(topology.members?.items ?? []).some((member) => member.route === target.memberRoute)) { + errors.push("target memberRoute must be retained in members.items"); + } + return errors; +} + +export function validateWorkspaceTopology(topology) { + const errors = workspaceTopologyErrors(topology); + if (errors.length > 0) { + throw Object.assign(contractError(errors.join("; ")), { errors }); + } + return topology; +} + +function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +export function freezeWorkspaceTopology(topology) { + validateWorkspaceTopology(topology); + return deepFreeze(structuredClone(topology)); +} + +export function analysisScopeFromTopology(topology) { + validateWorkspaceTopology(topology); + const route = topology.target.route; + return Object.freeze({ + kind: topology.target.kind === "repo-root" || topology.target.kind === "standalone" + ? "repo" + : "path", + route, + pathspecs: topology.gitRoot && route !== "." + ? Object.freeze([`:(top,literal)${route}`]) + : Object.freeze([]), + }); +} + +export function ownerRouteForTopologyPath(topology, filePath) { + validateWorkspaceTopology(topology); + const route = path.isAbsolute(String(filePath)) + ? relativeRoute(topology.gitRoot ?? topology.requestedWorkspace, filePath, "file") + : normalizeRoute(filePath, "file route"); + const matches = topology.members.items + .map((member) => member.route) + .filter((memberRoute) => routeContains(memberRoute, route)) + .sort((left, right) => right.length - left.length || left.localeCompare(right)); + return matches[0] ?? "."; +} diff --git a/scripts/workspace-topology/finding-target.mjs b/scripts/workspace-topology/finding-target.mjs new file mode 100644 index 0000000..cca8f27 --- /dev/null +++ b/scripts/workspace-topology/finding-target.mjs @@ -0,0 +1,132 @@ +import { + normalizeRoute, + routeContains, + validateWorkspaceTopology, +} from "./contract.mjs"; + +export const FINDING_TARGET_KINDS = Object.freeze([ + "repo-root", + "workspace-member", + "repo-subtree", + "standalone", +]); + +const FINDING_TARGET_KIND_SET = new Set(FINDING_TARGET_KINDS); +const FINDING_TARGET_FIELDS = new Set(["kind", "packageRoute", "ownerRoute"]); + +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function canonicalNullableRouteErrors(value, field, prefix) { + if (value === null) return []; + if (typeof value !== "string" || value.trim() === "") { + return [`${prefix}.${field} must be a canonical relative route or null`]; + } + try { + return normalizeRoute(value, `${prefix}.${field}`) === value + ? [] + : [`${prefix}.${field} must be canonical`]; + } catch (error) { + return [error.message]; + } +} + +export function findingTargetErrors(target, { + topology, + required = false, + requireOwnerRoute = false, + prefix = "finding.target", +} = {}) { + if (target === undefined) return required ? [`${prefix} is required for this workspace target`] : []; + if (!isObject(target)) return [`${prefix} must be an object`]; + + const errors = []; + for (const field of Object.keys(target)) { + if (!FINDING_TARGET_FIELDS.has(field)) { + errors.push(`${prefix} has unsupported field: ${field}`); + } + } + for (const field of FINDING_TARGET_FIELDS) { + if (!Object.hasOwn(target, field)) errors.push(`${prefix} missing ${field}`); + } + if (!FINDING_TARGET_KIND_SET.has(target.kind)) { + errors.push(`${prefix}.kind is unsupported`); + } + errors.push(...canonicalNullableRouteErrors(target.packageRoute, "packageRoute", prefix)); + errors.push(...canonicalNullableRouteErrors(target.ownerRoute, "ownerRoute", prefix)); + + if (target.kind === "workspace-member" && target.packageRoute === null) { + errors.push(`${prefix}.packageRoute is required for a workspace-member`); + } + if (requireOwnerRoute && target.ownerRoute === null) { + errors.push(`${prefix}.ownerRoute is required for this operation`); + } + if (target.kind && target.kind !== "workspace-member" && target.packageRoute !== null) { + errors.push(`${prefix}.packageRoute must be null for ${target.kind}`); + } + + if (topology === undefined) return errors; + try { + validateWorkspaceTopology(topology); + } catch (error) { + errors.push(`${prefix} cannot be checked against invalid topology: ${error.message}`); + return errors; + } + if (topology.status !== "complete") { + errors.push(`${prefix} requires complete workspace topology`); + } + if (target.kind !== topology.target.kind) { + errors.push(`${prefix}.kind does not match the frozen workspace topology`); + } + const expectedPackageRoute = topology.target.kind === "workspace-member" + ? topology.target.memberRoute + : null; + if (target.packageRoute !== expectedPackageRoute) { + errors.push(`${prefix}.packageRoute does not match the frozen workspace topology`); + } + if (target.ownerRoute !== null) { + try { + const ownerRoute = normalizeRoute(target.ownerRoute, `${prefix}.ownerRoute`); + const targetRoute = topology.target.route; + if (!routeContains(ownerRoute, targetRoute) && !routeContains(targetRoute, ownerRoute)) { + errors.push(`${prefix}.ownerRoute is outside the frozen target route`); + } + } catch { + // The canonical route error above already describes this failure. + } + } + return errors; +} + +export function validateFindingTarget(target, options = {}) { + const errors = findingTargetErrors(target, options); + if (errors.length > 0) { + throw Object.assign(new Error(errors.join("; ")), { + code: "INVALID_FINDING_TARGET", + errors, + }); + } + return target; +} + +export function findingTargetFromTopology(topology, { ownerRoute } = {}) { + validateWorkspaceTopology(topology); + const target = { + kind: topology.target.kind, + packageRoute: topology.target.kind === "workspace-member" + ? topology.target.memberRoute + : null, + ownerRoute: ownerRoute === null + ? null + : normalizeRoute( + ownerRoute ?? topology.target.memberRoute ?? topology.target.route, + "finding owner route", + ), + }; + validateFindingTarget(target, { + topology, + requireOwnerRoute: topology.target.kind === "workspace-member", + }); + return Object.freeze(target); +} diff --git a/scripts/workspace-topology/index.mjs b/scripts/workspace-topology/index.mjs new file mode 100644 index 0000000..0119e9a --- /dev/null +++ b/scripts/workspace-topology/index.mjs @@ -0,0 +1,181 @@ +import { realpath } from "node:fs/promises"; +import path from "node:path"; + +import { + WORKSPACE_TOPOLOGY_KIND, + WORKSPACE_TOPOLOGY_SCHEMA_VERSION, + analysisScopeFromTopology, + freezeWorkspaceTopology, + normalizeRoute, + ownerRouteForTopologyPath, + relativeRoute, + routeContains, + validateWorkspaceTopology, +} from "./contract.mjs"; +import { + assertWorkspaceDirectory, + collectWorkspaceInventory, + resolveGitRoot, +} from "./inventory.mjs"; +import { discoverWorkspaceStructure } from "./manifests.mjs"; + +const DEFAULT_MAX_FILES = 50_000; +const DEFAULT_MAX_MEMBERS = 500; +const DEFAULT_MAX_INSTRUCTION_SCOPES = 1_000; + +function positiveLimit(value, fallback, name) { + const parsed = Number(value ?? fallback); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1_000_000) { + throw Object.assign(new Error(`${name} must be an integer from 1 to 1000000`), { + code: "INVALID_WORKSPACE_TOPOLOGY_LIMIT", + }); + } + return parsed; +} + +function bounded(items, limit, preserve = null) { + let retained = items.slice(0, limit); + if (preserve && !retained.some((item) => item.route === preserve)) { + const required = items.find((item) => item.route === preserve); + if (required) { + retained = limit === 1 ? [required] : [...retained.slice(0, limit - 1), required]; + retained.sort((left, right) => left.route.localeCompare(right.route)); + } + } + return { + items: retained, + total: items.length, + omitted: items.length - retained.length, + truncated: items.length > retained.length, + }; +} + +function owningMember(members, targetRoute) { + return members + .filter((member) => routeContains(member.route, targetRoute)) + .sort((left, right) => right.route.length - left.route.length || left.route.localeCompare(right.route))[0] ?? null; +} + +function targetContract({ gitRoot, targetRoute, member }) { + if (!gitRoot) { + return { + kind: "standalone", + route: ".", + memberRoute: null, + memberMatch: "none", + }; + } + if (targetRoute === ".") { + return { + kind: "repo-root", + route: ".", + memberRoute: null, + memberMatch: "none", + }; + } + if (member) { + return { + kind: "workspace-member", + route: targetRoute, + memberRoute: member.route, + memberMatch: member.route === targetRoute ? "exact" : "descendant", + }; + } + return { + kind: "repo-subtree", + route: targetRoute, + memberRoute: null, + memberMatch: "none", + }; +} + +export async function resolveWorkspaceTopology(options = {}, dependencies = {}) { + const input = typeof options === "string" ? { workspace: options } : options; + if (!input.workspace) { + throw Object.assign(new Error("--workspace is required"), { code: "MISSING_WORKSPACE" }); + } + const requestedPath = path.resolve(String(input.workspace)); + await (dependencies.assertWorkspaceDirectory ?? assertWorkspaceDirectory)(requestedPath); + const requestedWorkspace = await realpath(requestedPath); + const gitProbe = (dependencies.resolveGitRoot ?? resolveGitRoot)(requestedWorkspace); + const gitRoot = gitProbe.gitRoot; + const topologyRoot = gitRoot ?? requestedWorkspace; + const targetRoute = gitRoot + ? relativeRoute(gitRoot, requestedWorkspace, "workspace") + : "."; + const maxFiles = positiveLimit(input.maxFiles ?? input["max-files"], DEFAULT_MAX_FILES, "maxFiles"); + const maxMembers = positiveLimit(input.maxMembers ?? input["max-members"], DEFAULT_MAX_MEMBERS, "maxMembers"); + const maxInstructionScopes = positiveLimit( + input.maxInstructionScopes ?? input["max-instruction-scopes"], + DEFAULT_MAX_INSTRUCTION_SCOPES, + "maxInstructionScopes", + ); + const inventory = await (dependencies.collectWorkspaceInventory ?? collectWorkspaceInventory)({ + root: topologyRoot, + gitRoot, + gitAvailable: gitProbe.gitAvailable, + maxFiles, + initialWarnings: gitProbe.warning ? [gitProbe.warning] : [], + }); + const structure = await (dependencies.discoverWorkspaceStructure ?? discoverWorkspaceStructure)({ + root: topologyRoot, + items: inventory.discoveryItems ?? inventory.items, + targetRoute, + }); + const member = owningMember(structure.members, targetRoute); + const members = bounded(structure.members, maxMembers, member?.route ?? null); + const instructionScopes = bounded(structure.instructionScopes, maxInstructionScopes); + const discoveryWarnings = [ + ...inventory.coverage.warnings, + ...structure.warnings, + ...(members.truncated ? [{ code: "member-list-truncated" }] : []), + ...(instructionScopes.truncated ? [{ code: "instruction-scope-list-truncated" }] : []), + ]; + const partial = inventory.coverage.truncated + || members.truncated + || instructionScopes.truncated + || discoveryWarnings.some((item) => !new Set([ + "git-root-realpath-unavailable", + ]).has(item.code)); + + const topology = freezeWorkspaceTopology({ + kind: WORKSPACE_TOPOLOGY_KIND, + schemaVersion: WORKSPACE_TOPOLOGY_SCHEMA_VERSION, + status: partial ? "partial" : "complete", + requestedWorkspace, + gitRoot, + target: targetContract({ gitRoot, targetRoute, member }), + members, + instructionScopes, + discovery: { + ...inventory.coverage, + warnings: discoveryWarnings, + }, + }); + const analysisScope = analysisScopeFromTopology(topology); + return Object.freeze({ + topology, + analysisScope, + inventory: Object.freeze({ + items: Object.freeze(inventory.items.map((item) => Object.freeze({ ...item }))), + }), + }); +} + +export function ownerRouteForPath(topology, filePath) { + return ownerRouteForTopologyPath(topology, filePath); +} + +export { + WORKSPACE_TOPOLOGY_KIND, + WORKSPACE_TOPOLOGY_SCHEMA_VERSION, + normalizeRoute, + routeContains, + validateWorkspaceTopology, +}; +export { + FINDING_TARGET_KINDS, + findingTargetErrors, + findingTargetFromTopology, + validateFindingTarget, +} from "./finding-target.mjs"; diff --git a/scripts/workspace-topology/inventory.mjs b/scripts/workspace-topology/inventory.mjs new file mode 100644 index 0000000..da895ac --- /dev/null +++ b/scripts/workspace-topology/inventory.mjs @@ -0,0 +1,308 @@ +import { spawn, spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { normalizeRoute } from "./contract.mjs"; + +const GIT_MAX_BUFFER_BYTES = 128 * 1024 * 1024; +const STRUCTURAL_FILE_LIMIT = 100_000; +const STATIC_EXCLUDED_DIRS = new Set([ + ".build", + ".cache", + ".git", + ".next", + ".qoder", + ".turbo", + "coverage", + "dist", + "node_modules", + "out", + "target", + "vendor", +]); + +function git(cwd, args) { + return spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: GIT_MAX_BUFFER_BYTES, + }); +} + +function warning(code) { + return Object.freeze({ code }); +} + +function isStructuralRoute(route) { + const base = path.posix.basename(route); + return new Set([ + "AGENTS.md", + "CLAUDE.md", + "CLAUDE.local.md", + "Cargo.toml", + "go.mod", + "go.work", + "package.json", + "pnpm-workspace.yaml", + ]).has(base) + || route === ".github/copilot-instructions.md" + || route.includes("/.claude/rules/") + || route.startsWith(".claude/rules/") + || route.includes("/.cursor/rules/") + || route.startsWith(".cursor/rules/") + || route.includes("/.qoder/rules/") + || route.startsWith(".qoder/rules/"); +} + +const STRUCTURAL_PATHSPECS = Object.freeze([ + "AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "Cargo.toml", "go.mod", "go.work", + "package.json", "pnpm-workspace.yaml", ".github/copilot-instructions.md", + ":(top,glob)**/AGENTS.md", ":(top,glob)**/CLAUDE.md", ":(top,glob)**/CLAUDE.local.md", + ":(top,glob)**/Cargo.toml", ":(top,glob)**/go.mod", ":(top,glob)**/go.work", + ":(top,glob)**/package.json", ":(top,glob)**/pnpm-workspace.yaml", + ":(top,glob)**/.claude/rules/**", ":(top,glob)**/.cursor/rules/**", + ":(top,glob)**/.qoder/rules/**", +]); + +function boundedGitNullList(cwd, args, limit) { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const items = []; + let pending = ""; + let stderr = ""; + let stopped = false; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + if (stderr.length < 8_192) stderr += chunk; + }); + child.stdout.on("data", (chunk) => { + if (stopped) return; + pending += chunk; + let separator; + while ((separator = pending.indexOf("\0")) !== -1) { + const raw = pending.slice(0, separator); + pending = pending.slice(separator + 1); + if (raw) items.push(normalizeRoute(raw)); + if (items.length > limit) { + stopped = true; + child.kill(); + break; + } + } + }); + child.on("error", reject); + child.on("close", (code) => { + if (!stopped && code !== 0) { + reject(Object.assign(new Error(`git ${args.join(" ")} failed: ${stderr.trim()}`), { + code: "GIT_INVENTORY_FAILED", + })); + return; + } + const truncated = items.length > limit; + resolve({ + items: items.slice(0, limit), + observed: items.length, + truncated, + }); + }); + }); +} + +export function resolveGitRoot(workspace) { + const result = git(workspace, ["rev-parse", "--show-toplevel"]); + if (result.error?.code === "ENOENT") { + return { + gitRoot: null, + gitAvailable: false, + warning: warning("git-unavailable"), + }; + } + if (result.status !== 0) { + return { gitRoot: null, gitAvailable: true, warning: null }; + } + const candidate = String(result.stdout ?? "").trim(); + try { + return { gitRoot: realpathSync(candidate), gitAvailable: true, warning: null }; + } catch { + return { + gitRoot: path.resolve(candidate), + gitAvailable: true, + warning: warning("git-root-realpath-unavailable"), + }; + } +} + +function excludedFallbackDirectory(name) { + return STATIC_EXCLUDED_DIRS.has(name) || /^out(?:-|$)/u.test(name); +} + +async function filesystemItems(root, maxFiles) { + const items = []; + const discoveryItems = []; + let observed = 0; + let visitedEntries = 0; + let traversalTruncated = false; + let structureTruncated = false; + const maxVisitedEntries = maxFiles + STRUCTURAL_FILE_LIMIT; + + async function walk(absolute, relative = "") { + if (traversalTruncated) return; + let entries; + try { + entries = await readdir(absolute, { withFileTypes: true }); + } catch { + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + visitedEntries += 1; + if (visitedEntries > maxVisitedEntries) { + traversalTruncated = true; + return; + } + const route = normalizeRoute(relative ? `${relative}/${entry.name}` : entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (!excludedFallbackDirectory(entry.name)) { + await walk(path.join(absolute, entry.name), route); + } + } else if (entry.isFile()) { + if (observed <= maxFiles) items.push({ route, provenance: "filesystem" }); + observed += 1; + if (isStructuralRoute(route)) { + if (discoveryItems.length < STRUCTURAL_FILE_LIMIT) { + discoveryItems.push({ route, provenance: "filesystem" }); + } else { + structureTruncated = true; + } + } + } + } + } + + await walk(root); + return { + items, + discoveryItems, + observed, + traversalTruncated, + structureTruncated, + }; +} + +function boundedInventory(items, { + maxFiles, + discoveryItems = [], + tracked, + untracked, + inventoryMode, + ignoreMode, + warnings = [], +}) { + const stable = [...items].sort((left, right) => left.route.localeCompare(right.route)); + const retained = stable.slice(0, maxFiles); + const uniqueDiscovery = [...new Map(discoveryItems.map((item) => [item.route, item])).values()] + .sort((left, right) => left.route.localeCompare(right.route)); + const omitted = Math.max(0, tracked + untracked - retained.length); + return { + items: retained.map((item) => Object.freeze({ ...item })), + discoveryItems: uniqueDiscovery.map((item) => Object.freeze({ ...item })), + coverage: { + inventoryMode, + ignoreMode, + tracked, + untracked, + scanned: retained.length, + omitted, + truncated: omitted > 0, + warnings: [ + ...warnings, + ...(omitted > 0 ? [warning("inventory-truncated")] : []), + ], + }, + }; +} + +export async function collectWorkspaceInventory({ + root, + gitRoot, + gitAvailable = true, + maxFiles, + initialWarnings = [], +}) { + if (gitRoot) { + try { + const [trackedResult, untrackedResult, trackedStructure, untrackedStructure] = await Promise.all([ + boundedGitNullList(gitRoot, ["ls-files", "-z", "--cached"], maxFiles + 1), + boundedGitNullList(gitRoot, ["ls-files", "-z", "--others", "--exclude-standard"], maxFiles + 1), + boundedGitNullList(gitRoot, ["ls-files", "-z", "--cached", "--", ...STRUCTURAL_PATHSPECS], STRUCTURAL_FILE_LIMIT), + boundedGitNullList(gitRoot, ["ls-files", "-z", "--others", "--exclude-standard", "--", ...STRUCTURAL_PATHSPECS], STRUCTURAL_FILE_LIMIT), + ]); + const byRoute = new Map(); + for (const route of untrackedResult.items) byRoute.set(route, { route, provenance: "untracked" }); + for (const route of trackedResult.items) byRoute.set(route, { route, provenance: "tracked" }); + const structureByRoute = new Map(); + for (const route of untrackedStructure.items) structureByRoute.set(route, { route, provenance: "untracked" }); + for (const route of trackedStructure.items) structureByRoute.set(route, { route, provenance: "tracked" }); + const countIsLowerBound = trackedResult.truncated || untrackedResult.truncated; + const structureTruncated = trackedStructure.truncated || untrackedStructure.truncated; + return boundedInventory([...byRoute.values()], { + maxFiles, + discoveryItems: [...structureByRoute.values()], + tracked: trackedResult.observed, + untracked: untrackedResult.observed, + inventoryMode: "git", + ignoreMode: "git-index", + warnings: [ + ...initialWarnings, + ...(countIsLowerBound ? [warning("inventory-count-lower-bound")] : []), + ...(structureTruncated ? [warning("structural-inventory-truncated")] : []), + ], + }); + } catch { + initialWarnings = [...initialWarnings, warning("git-inventory-unavailable")]; + } + } else if (!gitAvailable) { + initialWarnings = [...initialWarnings, warning("git-inventory-unavailable")]; + } + + const fallback = await filesystemItems(root, maxFiles); + const lowerBound = fallback.observed > maxFiles + 1 || fallback.traversalTruncated; + return boundedInventory(fallback.items, { + maxFiles, + discoveryItems: fallback.discoveryItems, + tracked: 0, + untracked: Math.min(fallback.observed, maxFiles + 1), + inventoryMode: "filesystem-fallback", + ignoreMode: "static", + warnings: [ + ...initialWarnings, + ...(lowerBound ? [warning("inventory-count-lower-bound")] : []), + ...(fallback.traversalTruncated ? [warning("filesystem-discovery-truncated")] : []), + ...(fallback.structureTruncated ? [warning("structural-inventory-truncated")] : []), + ], + }); +} + +export async function assertWorkspaceDirectory(workspace) { + let info; + try { + info = await stat(workspace); + } catch { + throw Object.assign(new Error(`workspace does not exist: ${workspace}`), { + code: "WORKSPACE_NOT_FOUND", + }); + } + if (!info.isDirectory()) { + throw Object.assign(new Error(`workspace must be a directory: ${workspace}`), { + code: "WORKSPACE_NOT_DIRECTORY", + }); + } +} diff --git a/scripts/workspace-topology/manifests.mjs b/scripts/workspace-topology/manifests.mjs new file mode 100644 index 0000000..8b107e8 --- /dev/null +++ b/scripts/workspace-topology/manifests.mjs @@ -0,0 +1,356 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { normalizeRoute, routeContains } from "./contract.mjs"; + +const PACKAGE_MARKERS = new Set(["package.json", "go.mod", "Cargo.toml"]); +const CONTAINER_ROOTS = new Set([ + "apps", + "extensions", + "native", + "packages", + "plugins", + "services", +]); + +function warning(code, route = undefined) { + return Object.freeze({ code, ...(route ? { route } : {}) }); +} + +function dirnameRoute(route) { + const value = path.posix.dirname(route); + return value === "." ? "." : normalizeRoute(value); +} + +function joinRoute(base, value) { + const normalized = String(value ?? "").replaceAll("\\", "/").replace(/^\.\/+/u, ""); + return normalizeRoute(base === "." ? normalized : `${base}/${normalized}`); +} + +function globToRegExp(pattern) { + const normalized = normalizeRoute(pattern, "workspace pattern"); + let source = ""; + for (let index = 0; index < normalized.length; index += 1) { + const char = normalized[index]; + const next = normalized[index + 1]; + if (char === "*" && next === "*") { + source += ".*"; + index += 1; + } else if (char === "*") { + source += "[^/]*"; + } else if (char === "?") { + source += "[^/]"; + } else { + source += char.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&"); + } + } + return new RegExp(`^${source}$`, "u"); +} + +function packageWorkspacePatterns(value) { + if (Array.isArray(value)) return value; + if (value && typeof value === "object" && Array.isArray(value.packages)) return value.packages; + return []; +} + +function parsePnpmPackages(text) { + const patterns = []; + let inPackages = false; + for (const rawLine of String(text).split(/\r?\n/u)) { + const line = rawLine.replace(/\s+#.*$/u, ""); + if (/^packages\s*:\s*$/u.test(line.trim())) { + inPackages = true; + continue; + } + if (inPackages && /^\S/u.test(line) && !/^-/u.test(line.trim())) break; + const match = inPackages ? line.match(/^\s*-\s*["']?([^"']+?)["']?\s*$/u) : null; + if (match) patterns.push(match[1].trim()); + } + return patterns; +} + +function parseGoWorkUses(text) { + const withoutComments = String(text).replace(/\/\/.*$/gmu, ""); + const patterns = []; + const block = withoutComments.match(/\buse\s*\(([\s\S]*?)\)/u); + if (block) { + for (const line of block[1].split(/\r?\n/u)) { + const value = line.trim().split(/\s+/u)[0]; + if (value) patterns.push(value); + } + } + for (const match of withoutComments.matchAll(/^\s*use\s+([^\s(][^\s]*)/gmu)) { + patterns.push(match[1]); + } + return patterns; +} + +function parseCargoWorkspace(text) { + const section = []; + let inWorkspace = false; + for (const line of String(text).split(/\r?\n/u)) { + if (/^\s*\[workspace\]\s*$/u.test(line)) { + inWorkspace = true; + continue; + } + if (inWorkspace && /^\s*\[[^\]]+\]\s*$/u.test(line)) break; + if (inWorkspace) section.push(line); + } + const workspace = section.join("\n"); + const quotedArray = (name) => { + const body = workspace.match(new RegExp(`\\b${name}\\s*=\\s*\\[([\\s\\S]*?)\\]`, "u"))?.[1] ?? ""; + return [...body.matchAll(/["']([^"']+)["']/gu)].map((match) => match[1]); + }; + return { include: quotedArray("members"), exclude: quotedArray("exclude") }; +} + +function patternMatches(pattern, route) { + try { + return globToRegExp(pattern).test(route); + } catch { + return false; + } +} + +function containedPatterns(patterns, manifestDir, manifestRoute, warnings) { + const contained = []; + for (const pattern of patterns) { + try { + contained.push(joinRoute(manifestDir, pattern)); + } catch { + warnings.push(warning("workspace-pattern-outside-root", manifestRoute)); + } + } + return contained; +} + +function markerDirectories(items, marker) { + return items + .filter((item) => path.posix.basename(item.route) === marker) + .map((item) => dirnameRoute(item.route)); +} + +function addMember(map, route, kind, discoveredBy, manifestRoute = null) { + if (route === ".") return; + const current = map.get(route); + const nextKind = current?.kind === "manifest" || kind === "manifest" ? "manifest" : "convention"; + map.set(route, { + route, + kind: nextKind, + discoveredBy: [...new Set([...(current?.discoveredBy ?? []), discoveredBy])].sort(), + manifestRoute: current?.manifestRoute ?? manifestRoute, + }); +} + +function isWithinRoot(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" + || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +async function safeStructureItems(root, items, warnings) { + const canonicalRoot = await realpath(root); + const safe = []; + for (const item of items) { + const absolute = path.resolve(root, ...item.route.split("/")); + try { + const metadata = await lstat(absolute); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + warnings.push(warning("structure-entry-unsafe", item.route)); + continue; + } + const canonical = await realpath(absolute); + if (!isWithinRoot(canonicalRoot, canonical)) { + warnings.push(warning("structure-entry-unsafe", item.route)); + continue; + } + safe.push(item); + } catch { + warnings.push(warning("structure-entry-unavailable", item.route)); + } + } + return safe; +} + +async function readInventoryFile(root, item, warnings) { + try { + return await readFile(path.join(root, ...item.route.split("/")), "utf8"); + } catch { + warnings.push(warning("manifest-read-unavailable", item.route)); + return null; + } +} + +function candidateConventionRoute(instructionRoute, manifestParents) { + const owner = dirnameRoute(instructionRoute); + if (manifestParents.has(owner)) return owner; + const parts = owner === "." ? [] : owner.split("/"); + if (CONTAINER_ROOTS.has(parts[0]) && parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + if (parts[0] === "src" && parts[1] === "product") return "src/product"; + if (parts[0] === "cli") return "cli"; + return null; +} + +function scopeActivation(provider, ownerRoute, targetRoute) { + const appliesToTarget = routeContains(ownerRoute, targetRoute); + if (provider === "codex") return appliesToTarget ? "effective" : "candidate"; + if (provider === "claude") return appliesToTarget ? "effective" : "candidate"; + if (provider === "qoder") return ownerRoute === "." ? "effective" : "candidate"; + return "candidate"; +} + +export async function discoverWorkspaceStructure({ + root, + items, + targetRoute, +}) { + const members = new Map(); + const warnings = []; + const structureItems = await safeStructureItems(root, items, warnings); + const manifestParents = new Set(); + const blockedConventionPatterns = []; + const manifestItems = structureItems.filter((item) => new Set([ + "package.json", + "pnpm-workspace.yaml", + "go.work", + "Cargo.toml", + ]).has(path.posix.basename(item.route))); + + for (const item of manifestItems) { + const base = path.posix.basename(item.route); + const manifestDir = dirnameRoute(item.route); + const text = await readInventoryFile(root, item, warnings); + if (text === null) continue; + + let patterns = []; + let exclusions = []; + let marker = null; + let source = null; + try { + if (base === "package.json") { + const parsed = JSON.parse(text); + patterns = packageWorkspacePatterns(parsed.workspaces); + marker = "package.json"; + source = `${item.route}#workspaces`; + manifestParents.add(manifestDir); + } else if (base === "pnpm-workspace.yaml") { + patterns = parsePnpmPackages(text); + marker = "package.json"; + source = item.route; + } else if (base === "go.work") { + patterns = parseGoWorkUses(text); + marker = "go.mod"; + source = item.route; + } else if (base === "Cargo.toml") { + const parsed = parseCargoWorkspace(text); + patterns = parsed.include; + exclusions = parsed.exclude; + marker = "Cargo.toml"; + source = `${item.route}#[workspace]`; + manifestParents.add(manifestDir); + } + } catch { + warnings.push(warning("manifest-parse-unavailable", item.route)); + continue; + } + + if (!source || patterns.length === 0) continue; + const directories = markerDirectories(structureItems, marker); + const includes = containedPatterns( + patterns.filter((pattern) => !String(pattern).trim().startsWith("!")), + manifestDir, + item.route, + warnings, + ); + const excludes = containedPatterns([ + ...exclusions, + ...patterns + .filter((pattern) => String(pattern).trim().startsWith("!")) + .map((pattern) => String(pattern).trim().slice(1)), + ], manifestDir, item.route, warnings); + blockedConventionPatterns.push(...excludes); + for (const directory of directories) { + if (!includes.some((pattern) => patternMatches(pattern, directory))) continue; + if (excludes.some((pattern) => patternMatches(pattern, directory))) continue; + addMember(members, directory, "manifest", source, item.route); + } + } + + const packageMarkerParents = new Set( + structureItems + .filter((item) => PACKAGE_MARKERS.has(path.posix.basename(item.route))) + .map((item) => dirnameRoute(item.route)) + .filter((route) => route !== "."), + ); + for (const route of packageMarkerParents) { + if (blockedConventionPatterns.some((pattern) => patternMatches(pattern, route))) continue; + const covered = [...members.values()].some((member) => routeContains(member.route, route)); + if (!covered) addMember(members, route, "convention", "nested-package-manifest"); + } + + const instructionScopes = []; + const instructionItems = structureItems.filter((item) => { + const base = path.posix.basename(item.route); + const extension = path.posix.extname(item.route).toLowerCase(); + return base === "AGENTS.md" + || base === "CLAUDE.md" + || base === "CLAUDE.local.md" + || item.route === ".github/copilot-instructions.md" + || ((item.route.includes("/.claude/rules/") || item.route.startsWith(".claude/rules/")) + && new Set([".md", ".mdc"]).has(extension)) + || ((item.route.includes("/.cursor/rules/") || item.route.startsWith(".cursor/rules/")) + && new Set([".md", ".mdc"]).has(extension)) + || ((item.route.includes("/.qoder/rules/") || item.route.startsWith(".qoder/rules/")) + && new Set([".md", ".mdc"]).has(extension)); + }); + + for (const item of instructionItems) { + const base = path.posix.basename(item.route); + let ownerRoute = dirnameRoute(item.route); + const rows = []; + if (base === "AGENTS.md") { + rows.push(["codex", ownerRoute], ["qoder", ownerRoute], ["cursor", ownerRoute]); + } else if (base === "CLAUDE.md" || base === "CLAUDE.local.md") { + if (item.route === ".claude/CLAUDE.md") ownerRoute = "."; + rows.push(["claude", ownerRoute]); + } else if (item.route === ".github/copilot-instructions.md") { + ownerRoute = "."; + rows.push(["cursor", ownerRoute]); + } else if (item.route.includes("/.claude/rules/") || item.route.startsWith(".claude/rules/")) { + ownerRoute = normalizeRoute(item.route.split("/.claude/rules/")[0] || "."); + rows.push(["claude", ownerRoute]); + } else if (item.route.includes("/.cursor/rules/") || item.route.startsWith(".cursor/rules/")) { + ownerRoute = normalizeRoute(item.route.split("/.cursor/rules/")[0] || "."); + rows.push(["cursor", ownerRoute]); + } else if (item.route.includes("/.qoder/rules/") || item.route.startsWith(".qoder/rules/")) { + ownerRoute = normalizeRoute(item.route.split("/.qoder/rules/")[0] || "."); + rows.push(["qoder", ownerRoute]); + } + for (const [provider, scopeRoute] of rows) { + instructionScopes.push({ + route: item.route, + provider, + activation: item.route === ".github/copilot-instructions.md" + ? "candidate" + : scopeActivation(provider, scopeRoute, targetRoute), + }); + } + + if (base === "AGENTS.md" || base === "CLAUDE.md") { + const conventionRoute = candidateConventionRoute(item.route, packageMarkerParents); + if (conventionRoute) { + const covered = [...members.values()].some((member) => routeContains(member.route, conventionRoute)); + if (!covered) addMember(members, conventionRoute, "convention", "nested-instruction-scope"); + } + } + } + + return { + members: [...members.values()].sort((left, right) => left.route.localeCompare(right.route)), + instructionScopes: instructionScopes.sort((left, right) => + left.route.localeCompare(right.route) || left.provider.localeCompare(right.provider)), + warnings, + }; +} diff --git a/skills/better-harness/SKILL.md b/skills/better-harness/SKILL.md index 6f74854..41c9e77 100644 --- a/skills/better-harness/SKILL.md +++ b/skills/better-harness/SKILL.md @@ -44,9 +44,11 @@ Memory metadata requires `--include-memories`; user/global or installed-Plugin metadata requires `--include-user-home`. Apply both when both scopes are authorized. Neither flag authorizes Memory bodies. -The command freezes target, provider, window, depth, evidence limit, and asset -authority once. It returns `sessionEvidence`, `projectHarness`, and -`agentCustomize` lanes plus the lead analyzer envelope. The Agent Customize +It freezes resolved topology, provider, window, depth, limit, and authority. +Before delegation, read +`bundle.context.topology.target`; report `kind`, `route`, and `packageRoute` +(`memberRoute` or `null`). Providers must agree. It returns `sessionEvidence`, +`projectHarness`, `agentCustomize`, and the lead envelope. The Agent Customize lane contains the bounded `lint`, `inventory`, and `integrity` envelopes from one shared asset snapshot. Keep every lane and stage status and each provider distinct. Use the individual `session-analysis facts`, `core-change-watch diff --git a/skills/better-harness/references/finding-bound-fix.md b/skills/better-harness/references/finding-bound-fix.md index c4ce3e9..ddf5527 100644 --- a/skills/better-harness/references/finding-bound-fix.md +++ b/skills/better-harness/references/finding-bound-fix.md @@ -28,11 +28,43 @@ version equality never selects the route. Missing, ambiguous, stale, inaccessible, or incomplete callbacks stop before editing. Never search for a recent run or substitute another report. +## Bind the Current Workspace Topology + +Read the exact finding, then resolve the callback workspace without widening it: + +```text + harness workspace-topology --workspace --json +``` + +Require `topology.status: "complete"`. When the finding has a structured +`target`, require its complete `kind`, `packageRoute`, and `ownerRoute`; a +present-but-incomplete target is invalid and never falls back to legacy routing. +Accept only `repo-root|workspace-member|repo-subtree|standalone`; a workspace +member requires a non-null package route. +Require canonical Git-root-relative POSIX routes, reject absolute or escaping +routes, and verify any non-null `packageRoute` is a retained topology member. +For a path target, the package route must match the current member and the owner +must be that route, an ancestor owner, or inside the current target; a sibling +owner is outside authority. For a root target, the owner may be `.` or a route +inside the current topology. Standalone findings require a standalone topology. + +Any kind, package, owner, containment, or topology-status mismatch fails closed +before inspection or editing. Do not infer a replacement route from finding +prose, evidence paths, expected output, or branch names. Resolve `` +from `topology.gitRoot` (or `requestedWorkspace` for standalone) and the +validated `ownerRoute`, then use that smallest owner for inspection, mutation, +and verification. + +For a legacy report with no `target` field at all, keep it readable: bind +`` to the callback's exact `workspacePath`, do not invent +`packageRoute` or `ownerRoute`, and do not widen or narrow the repair scope. + ## Load the Smallest Owner -Read the exact finding, then load its smallest packaged owner before inspecting -the target. For Rules, Skills, Hooks, MCP, Memory, customization, or design -findings, start from [Agent Customize](../../../references/agent-customize/routing.md). +After topology binding passes, load the smallest packaged owner before +inspecting ``. For Rules, Skills, Hooks, MCP, Memory, customization, +or design findings, start from +[Agent Customize](../../../references/agent-customize/routing.md). For `frontend-design-contract-missing`, also load the [DESIGN.md Contract](../../../references/project-harness/design-md-contract.md) @@ -42,7 +74,8 @@ augmentation and never invent brand decisions. ## Apply and Verify -Apply only the authorized fix and run the smallest target-owned validation. +Apply only the authorized fix inside `` and run the smallest +owner-owned validation. Keep the bound `findings.json` unchanged until the record command succeeds; it is the locked pre-fix score baseline for the optional review below. Derive 1-12 `actualOutput` rows from the real diff or configuration result, not @@ -50,6 +83,11 @@ Derive 1-12 `actualOutput` rows from the real diff or configuration result, not kind, a reader-facing name, `Project|Global`, an openable slash-normalized path when one survives, and a concise artifact result. Any `SKILL.md` path must use `artifact: Skill`. +For a structured finding, write every `Project` path relative to the frozen +topology Git root (or standalone requested workspace), even when the callback +workspace is a member package. This keeps an ancestor-owned result such as +`AGENTS.md` openable without `..`; the recorder validates it against +`ownerRoute`. Legacy findings keep callback-workspace-relative paths. Author one standalone `assignmentSummary` in the report's exact locale. Its title and body explain the finding-level verified outcome and validation diff --git a/test/agent-asset-baseline.test.mjs b/test/agent-asset-baseline.test.mjs index 7b785d4..f5bec37 100644 --- a/test/agent-asset-baseline.test.mjs +++ b/test/agent-asset-baseline.test.mjs @@ -102,11 +102,12 @@ test("asset baseline shares one inventory snapshot and emits compact AI envelope assert.equal(lintInventory, rawInventory); assert.equal(publicInventory, rawInventory); assert.equal(result.kind, ASSET_BASELINE_KIND); - assert.equal(result.status, "complete"); + assert.equal(result.status, "partial"); assert.equal(result.diagnostics.sharedInventorySnapshot, true); assert.equal(result.diagnostics.compact, true); assert.equal(result.envelopes.lint.data.findings.items.length, MAX_BASELINE_FINDINGS); assert.equal(result.envelopes.lint.data.findings.omitted, 4); + assert.equal(result.envelopes.lint.data.findings.truncated, true); assert.equal(result.envelopes.integrity.data.findings.omitted, 4); assert.deepEqual(result.envelopes.inventory.data.ownerRoutes.items[0], { kind: "skills", @@ -119,6 +120,12 @@ test("asset baseline shares one inventory snapshot and emits compact AI envelope assert.equal(result.envelopes.inventory.data.ownerRoutes.items.some((item) => item.kind === "plugins"), true); assert.equal(result.envelopes.inventory.data.ownerRoutes.items.some((item) => item.kind === "agents"), true); assert.equal(result.envelopes.inventory.data.ownerRoutes.omitted, 16); + assert.equal(result.envelopes.inventory.data.ownerRoutes.truncated, true); + assert.deepEqual(result.diagnostics.truncatedStages, [ + "lint-findings", + "inventory-owner-routes", + "integrity-findings", + ]); assert.equal(Object.hasOwn(result.envelopes.inventory.data.summary, "practiceCoverageRows"), false); const serialized = JSON.stringify(result); assert.ok(Buffer.byteLength(serialized) < 12_000, "fixture baseline must stay compact for AI reading"); @@ -288,3 +295,99 @@ test("Qwen asset baseline completes from a native project fixture", async () => await rm(root, { recursive: true, force: true }); } }); + +test("package asset baseline preserves root and intermediate assets as inherited owners", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-asset-monorepo-")); + const workspace = path.join(root, "packages", "app"); + const qoderHome = path.join(root, "qoder-home"); + try { + await mkdir(qoderHome, { recursive: true }); + await mkdir(path.join(root, ".agents", "skills", "root-review"), { recursive: true }); + await mkdir(path.join(workspace, ".qoder", "skills", "local-review"), { recursive: true }); + await writeFile(path.join(root, "AGENTS.md"), "# Root instructions\n"); + await writeFile(path.join(root, "packages", "AGENTS.md"), "# Packages instructions\n"); + await writeFile(path.join(workspace, "AGENTS.md"), "# App instructions\n"); + await writeFile( + path.join(root, ".agents", "skills", "root-review", "SKILL.md"), + "---\nname: root-review\ndescription: Review the repository contract.\n---\n", + ); + await writeFile( + path.join(workspace, ".qoder", "skills", "local-review", "SKILL.md"), + "---\nname: local-review\ndescription: Review the package contract.\n---\n", + ); + const topology = { + kind: "better-harness.workspace-topology", + schemaVersion: 1, + status: "complete", + requestedWorkspace: workspace, + gitRoot: root, + target: { + kind: "workspace-member", + route: "packages/app", + memberRoute: "packages/app", + memberMatch: "exact", + }, + members: { + items: [{ + route: "packages/app", + kind: "manifest", + discoveredBy: ["package.json#workspaces"], + manifestRoute: "package.json", + }], + total: 1, + omitted: 0, + truncated: false, + }, + instructionScopes: { + items: [ + { route: "AGENTS.md", provider: "qoder", activation: "effective" }, + { route: "packages/AGENTS.md", provider: "qoder", activation: "candidate" }, + { route: "packages/app/AGENTS.md", provider: "qoder", activation: "candidate" }, + ], + total: 3, + omitted: 0, + truncated: false, + }, + discovery: { + inventoryMode: "git", + ignoreMode: "git-index", + tracked: 5, + untracked: 0, + scanned: 5, + omitted: 0, + truncated: false, + warnings: [], + }, + }; + + const result = await collectAssetBaseline({ + provider: "qoder", + workspace, + qoderHome, + topology, + includeUserHome: false, + }); + + assert.equal(result.status, "complete"); + assert.equal(result.diagnostics.inheritedWorkspaceCount, 2); + const owners = result.envelopes.inventory.data.ownerRoutes.items; + assert.ok(owners.some((item) => + item.kind === "skills" + && item.scope === "project" + && item.name === "local-review" + && item.route === ".qoder/skills/local-review/SKILL.md")); + assert.ok(owners.some((item) => + item.kind === "skills" + && item.scope === "inherited" + && item.name === "root-review" + && item.route === ".agents/skills/root-review/SKILL.md" + && item.effectiveTarget === "packages/app")); + const lintEntrypoints = result.envelopes.lint.data.assetInventory; + assert.ok(lintEntrypoints); + assert.ok(result.envelopes.lint.data.summary.entrypoints >= 3); + assert.ok(result.envelopes.inventory.data.coverageRows.some((row) => + row.surface === "Skills" && row.scopes.includes("Inherited"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/agent-lint-monorepo.test.mjs b/test/agent-lint-monorepo.test.mjs new file mode 100644 index 0000000..9e846cd --- /dev/null +++ b/test/agent-lint-monorepo.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + collectAgentInstructionGraph, + discoverAgentEntrypoints, + runAgentLint, +} from "../scripts/agent-lint/index.mjs"; +import { resolveWorkspaceTopology } from "../scripts/workspace-topology/index.mjs"; + +function git(cwd, args) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed\n${result.stderr}`); + } +} + +async function writeFixtureFile(root, route, content) { + const absolute = path.join(root, ...route.split("/")); + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, content); +} + +async function makeMonorepo() { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-lint-topology-")); + const files = { + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "AGENTS.md": "# Root agents\n", + "CLAUDE.md": "# Root Claude\n", + "CLAUDE.local.md": "# Root local Claude\n", + ".claude/rules/root.mdc": "# Root Claude rule\n", + ".cursor/rules/root.md": "# Root Cursor rule\n", + ".github/copilot-instructions.md": "# Root Copilot instructions\n", + ".qoder/rules/root.md": "# Root Qoder rule\n", + ".qoder/rules/root.mdc": "# Root Qoder mdc rule\n", + ".ci/AGENTS.md": "# CI agents\n", + "packages/a/package.json": JSON.stringify({ name: "a" }), + "packages/a/AGENTS.md": "# Package A agents\n\n[Missing](missing.md)\n", + "packages/a/CLAUDE.md": "# Package A Claude\n", + "packages/a/.claude/rules/local.md": "# Package A Claude rule\n", + "packages/a/.cursor/rules/local.mdc": "# Package A Cursor rule\n", + "packages/a/.qoder/rules/local.md": "# Package A Qoder rule\n", + "packages/a/.qoder/rules/local.mdc": "# Package A Qoder mdc rule\n", + "packages/a/src/AGENTS.md": "# Package A source agents\n", + "packages/b/package.json": JSON.stringify({ name: "b" }), + "packages/b/AGENTS.md": "# Package B agents\n", + }; + + git(root, ["init", "-q"]); + git(root, ["config", "user.email", "test@example.com"]); + git(root, ["config", "user.name", "Test User"]); + for (const [route, content] of Object.entries(files)) { + await writeFixtureFile(root, route, content); + } + git(root, ["add", "."]); + git(root, ["commit", "-q", "-m", "fixture"]); + return root; +} + +function byRoute(entrypoints, route) { + return entrypoints.find((entrypoint) => entrypoint.relativePath === route); +} + +test("topology-backed root discovery inventories provider scopes without a nested filesystem walk", async () => { + const repo = await makeMonorepo(); + try { + const { topology } = await resolveWorkspaceTopology({ workspace: repo }); + await writeFixtureFile(repo, "packages/late/AGENTS.md", "# Added after topology resolution\n"); + + const entrypoints = await discoverAgentEntrypoints({ + workspace: path.join(repo, "packages/a"), + provider: "qoder", + topology, + }); + const routes = entrypoints.map((entrypoint) => entrypoint.relativePath); + + assert.ok(routes.includes("AGENTS.md")); + assert.ok(routes.includes(".ci/AGENTS.md")); + assert.ok(routes.includes("packages/a/AGENTS.md")); + assert.ok(routes.includes("packages/b/AGENTS.md")); + assert.ok(routes.includes("packages/a/.qoder/rules/local.md")); + assert.ok(routes.includes("packages/a/.qoder/rules/local.mdc")); + assert.ok(routes.includes(".qoder/rules/root.mdc")); + assert.ok(!routes.includes("CLAUDE.md")); + assert.ok(!routes.includes(".claude/rules/root.mdc")); + assert.ok(!routes.includes(".cursor/rules/root.md")); + assert.ok(!routes.includes(".github/copilot-instructions.md")); + assert.ok(!routes.includes("packages/late/AGENTS.md")); + assert.equal(byRoute(entrypoints, "AGENTS.md").activation, "effective"); + assert.equal(byRoute(entrypoints, "packages/a/AGENTS.md").activation, "candidate"); + assert.equal(byRoute(entrypoints, "packages/a/AGENTS.md").packageRoute, "packages/a"); + assert.equal(byRoute(entrypoints, ".ci/AGENTS.md").packageRoute, "."); + + const claudeRoutes = (await discoverAgentEntrypoints({ + workspace: path.join(repo, "packages/a"), + provider: "claude", + topology, + })).map((entrypoint) => entrypoint.relativePath); + assert.ok(claudeRoutes.includes("CLAUDE.md")); + assert.ok(claudeRoutes.includes("CLAUDE.local.md")); + assert.ok(claudeRoutes.includes(".claude/rules/root.mdc")); + assert.ok(claudeRoutes.includes("packages/a/.claude/rules/local.md")); + assert.ok(!claudeRoutes.includes(".cursor/rules/root.md")); + + const cursorRoutes = (await discoverAgentEntrypoints({ + workspace: path.join(repo, "packages/a"), + provider: "cursor", + topology, + })).map((entrypoint) => entrypoint.relativePath); + assert.ok(cursorRoutes.includes(".cursor/rules/root.md")); + assert.ok(cursorRoutes.includes("packages/a/.cursor/rules/local.mdc")); + assert.ok(cursorRoutes.includes(".github/copilot-instructions.md")); + assert.ok(!cursorRoutes.includes("CLAUDE.local.md")); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("topology-backed path discovery keeps ancestor and target scopes while excluding siblings", async () => { + const repo = await makeMonorepo(); + try { + const { topology } = await resolveWorkspaceTopology({ + workspace: path.join(repo, "packages/a"), + }); + const graph = await collectAgentInstructionGraph({ + workspace: path.join(repo, "packages/a"), + provider: "qoder", + topology, + }); + const routes = graph.entrypoints.map((entrypoint) => entrypoint.relativePath); + + assert.equal(graph.workspace, topology.gitRoot); + assert.ok(routes.includes("AGENTS.md")); + assert.ok(routes.includes(".qoder/rules/root.md")); + assert.ok(routes.includes("packages/a/AGENTS.md")); + assert.ok(routes.includes("packages/a/src/AGENTS.md")); + assert.ok(routes.includes("packages/a/.qoder/rules/local.md")); + assert.ok(!routes.includes(".ci/AGENTS.md")); + assert.ok(!routes.includes("packages/b/AGENTS.md")); + assert.ok(!routes.includes("CLAUDE.md")); + + const local = byRoute(graph.entrypoints, "packages/a/AGENTS.md"); + assert.equal(local.activation, "candidate"); + assert.equal(local.packageRoute, "packages/a"); + const localDocument = graph.documents.find((document) => document.relativePath === "packages/a/AGENTS.md"); + assert.equal(localDocument.activation, "candidate"); + assert.equal(localDocument.packageRoute, "packages/a"); + + const review = await runAgentLint({ + workspace: path.join(repo, "packages/a"), + provider: "qoder", + profile: "agents-md-review", + topology, + }); + const rootFinding = review.findings.find((finding) => finding.file === "AGENTS.md"); + const packageFinding = review.findings.find((finding) => finding.file === "packages/a/AGENTS.md"); + assert.equal(rootFinding?.ownerRoute, "."); + assert.equal(rootFinding?.packageRoute, "."); + assert.equal(packageFinding?.ownerRoute, "packages/a"); + assert.equal(packageFinding?.packageRoute, "packages/a"); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); diff --git a/test/better-harness-cli.test.mjs b/test/better-harness-cli.test.mjs index a60755e..5fa5c73 100644 --- a/test/better-harness-cli.test.mjs +++ b/test/better-harness-cli.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -199,6 +199,11 @@ test("better-harness CLI exposes command inventory as JSON", () => { harness.subcommands.find((subcommand) => subcommand.name === "evidence-bundle").script, "scripts/harness-analysis/evidence-bundle/cli.mjs", ); + assert.equal(harness.subcommands.find((subcommand) => subcommand.name === "workspace-topology").audience, "advanced"); + assert.equal( + harness.subcommands.find((subcommand) => subcommand.name === "workspace-topology").script, + "scripts/workspace-topology/cli.mjs", + ); assert.equal(harness.subcommands.find((subcommand) => subcommand.name === "render").audience, "advanced"); assert.equal(harness.subcommands.find((subcommand) => subcommand.name === "source").audience, "maintainer"); assert.equal(harness.subcommands.some((subcommand) => subcommand.name === "prepare"), false); @@ -248,6 +253,7 @@ test("better-harness CLI filters machine inventory by audience", () => { assert.equal(advancedCommands.some((command) => command.name === "core-change-watch"), false); const harness = advancedCommands.find((command) => command.name === "harness"); assert.equal(harness.subcommands.some((subcommand) => subcommand.name === "render"), true); + assert.equal(harness.subcommands.some((subcommand) => subcommand.name === "workspace-topology"), true); assert.equal(harness.subcommands.some((subcommand) => subcommand.name === "source"), false); }); @@ -308,6 +314,7 @@ test("better-harness CLI group help expands advanced and maintainer subcommands" const advanced = runBetterHarness(["harness", "--help", "--audience", "advanced"]); assert.equal(advanced.status, 0, advanced.stderr); const advancedCommands = listedSubcommands(advanced.stdout); + assert.equal(advancedCommands.includes("workspace-topology"), true); assert.equal(advancedCommands.includes("render"), true); assert.equal(advancedCommands.includes("preview-canvas"), true); assert.equal(advancedCommands.includes("validate-canvas"), true); @@ -328,6 +335,29 @@ test("better-harness CLI exposes Canvas preview help without starting a server", assert.notEqual(result.stdout, ""); }); +test("better-harness CLI dispatches workspace topology JSON with spaced paths", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness topology cli-")); + try { + await writeFixtureFile(root, "README.md", "# Standalone workspace\n"); + const result = runBetterHarness([ + "harness", + "workspace-topology", + "--workspace", + root, + "--json", + ]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.topology.target.kind, "standalone"); + assert.equal(payload.topology.requestedWorkspace, await realpath(root)); + assert.deepEqual(payload.analysisScope, { kind: "repo", route: ".", pathspecs: [] }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("better-harness Canvas preview resolves relative reports from the caller workspace", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-preview-cwd-")); try { diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index d49ae14..cb5025b 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import path from "node:path"; import test from "node:test"; import { @@ -12,9 +13,44 @@ import { collectAgentCustomize } from "../scripts/harness-analysis/evidence-bund const NOW = new Date("2026-07-24T08:00:00.000Z"); +function topologyResolution(workspace = ".", status = "complete") { + const absolute = path.resolve(workspace); + const topology = Object.freeze({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + status, + requestedWorkspace: absolute, + gitRoot: absolute, + target: { + kind: "repo-root", + route: ".", + memberRoute: null, + memberMatch: "none", + }, + members: { items: [], total: 0, omitted: 0, truncated: false }, + instructionScopes: { items: [], total: 0, omitted: 0, truncated: false }, + discovery: { + inventoryMode: "git", + ignoreMode: "git-index", + tracked: 1, + untracked: 0, + scanned: 1, + omitted: 0, + truncated: status !== "complete", + warnings: status === "complete" ? [] : [{ code: "inventory-truncated" }], + }, + }); + return Object.freeze({ + topology, + analysisScope: Object.freeze({ kind: "repo", route: ".", pathspecs: Object.freeze([]) }), + inventory: Object.freeze({ items: Object.freeze([]) }), + }); +} + function dependencies(overrides = {}) { return { now: () => NOW, + resolveWorkspaceTopology: async ({ workspace }) => topologyResolution(workspace), collectSessionEvidence: async () => availableLane({ kind: "session-core-facts", candidates: [] }), collectProjectHarness: async () => availableLane({ kind: "core-change-watch-evidence-pack" }), collectAgentCustomize: async () => availableLane({ kind: "agent-asset-baseline", status: "complete" }), @@ -33,7 +69,7 @@ test("evidence bundle freezes the three canonical lane names and normal scope", }, dependencies()); assert.equal(result.kind, EVIDENCE_BUNDLE_KIND); - assert.equal(result.schemaVersion, 1); + assert.equal(result.schemaVersion, 2); assert.equal(result.status, "complete"); assert.deepEqual(Object.keys(result.lanes), ["sessionEvidence", "projectHarness", "agentCustomize"]); assert.equal(result.context.provider, "codex"); @@ -45,9 +81,72 @@ test("evidence bundle freezes the three canonical lane names and normal scope", }); assert.equal(result.context.authority.includeUserHome, true); assert.equal(result.context.authority.includeMemories, false); + assert.equal(result.context.topology.target.kind, "repo-root"); + assert.deepEqual(result.context.analysisScope, { kind: "repo", route: ".", pathspecs: [] }); assert.equal(result.diagnostics.collectionMode, "frozen-context-multi-owner"); }); +test("evidence bundle resolves topology once and shares the frozen binding with every consumer", async () => { + let resolutions = 0; + let canonicalTopology; + const received = []; + const result = await collectEvidenceBundle({ workspace: ".", depth: "normal" }, dependencies({ + resolveWorkspaceTopology: async ({ workspace }) => { + resolutions += 1; + const resolution = topologyResolution(workspace); + canonicalTopology = resolution.topology; + return resolution; + }, + collectSessionEvidence: async (context) => { + received.push(context.topology); + return availableLane({ kind: "session-core-facts", candidates: [] }); + }, + collectProjectHarness: async (context) => { + received.push(context.topology); + return availableLane({ kind: "core-change-watch-evidence-pack" }); + }, + collectAgentCustomize: async (context) => { + received.push(context.topology); + return availableLane({ kind: "agent-asset-baseline", status: "complete" }); + }, + analyzeHarnessEvidence: async (options) => { + received.push(options.topology); + return { evidence: "bounded", summaryFacts: { dimensions: [] } }; + }, + })); + + assert.equal(result.status, "complete"); + assert.equal(resolutions, 1); + assert.equal(received.length, 4); + assert.ok(received.every((topology) => topology === canonicalTopology)); +}); + +test("topology truncation fails normal bundles and lowers quick bundles to partial", async () => { + const partialResolver = async ({ workspace }) => topologyResolution(workspace, "partial"); + const normal = await collectEvidenceBundle({ workspace: ".", depth: "normal" }, dependencies({ + resolveWorkspaceTopology: partialResolver, + })); + const quick = await collectEvidenceBundle({ workspace: ".", depth: "quick" }, dependencies({ + resolveWorkspaceTopology: partialResolver, + })); + + assert.equal(normal.status, "failed"); + assert.equal(quick.status, "partial"); + assert.equal(normal.diagnostics.topologyIncomplete, true); + assert.equal(normal.diagnostics.topologyStatus, "partial"); +}); + +test("evidence bundle rejects a frozen topology for a different workspace", () => { + const resolution = topologyResolution("."); + const mismatched = structuredClone(resolution.topology); + mismatched.requestedWorkspace = path.resolve("different-workspace"); + assert.throws(() => freezeEvidenceBundleContext({ + workspace: ".", + topology: mismatched, + analysisScope: resolution.analysisScope, + }, NOW), (error) => error?.code === "EVIDENCE_WORKSPACE_TOPOLOGY_MISMATCH"); +}); + test("normal bundles fail closed and redact collector error details", async () => { const result = await collectEvidenceBundle({ workspace: ".", depth: "normal" }, dependencies({ collectProjectHarness: async () => { @@ -62,6 +161,24 @@ test("normal bundles fail closed and redact collector error details", async () = assert.doesNotMatch(JSON.stringify(result), /Users\/example|secret/); }); +test("scoped Git coverage failure makes the project lane unavailable and fails normal bundles", async () => { + const normalDependencies = dependencies({ + buildEvidencePack: async () => { + throw Object.assign(new Error("fatal: bad revision with private path"), { + code: "GIT_COMMAND_FAILED", + }); + }, + }); + delete normalDependencies.collectProjectHarness; + const result = await collectEvidenceBundle({ workspace: ".", depth: "normal" }, normalDependencies); + + assert.equal(result.status, "failed"); + assert.equal(result.lanes.projectHarness.status, "unavailable"); + assert.equal(result.lanes.projectHarness.error.code, "GIT_COMMAND_FAILED"); + assert.equal(result.lanes.projectHarness.error.message, "project-harness evidence is unavailable"); + assert.ok(result.diagnostics.unavailableLanes.includes("projectHarness")); +}); + test("quick bundles retain an explicit partial lane without failing the lead", async () => { const result = await collectEvidenceBundle({ workspace: ".", depth: "quick" }, dependencies({ collectAgentCustomize: async () => ({ status: "partial", data: { kind: "agent-asset-baseline" } }), diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 0af8c02..8f56759 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -114,6 +114,10 @@ test("Step 1 establishes one provider-labelled evidence bundle", () => { assert.match(skill, /Apply both when both scopes are\s+authorized/); assert.match(skill, /Neither flag authorizes Memory bodies/); assert.match(skill, /from\s+one shared asset snapshot/); + assert.match(skill, /`bundle\.context\.topology\.target`/); + assert.match(skill, /report `kind`, `route`, and `packageRoute`/); + assert.match(skill, /`memberRoute` or `null`/); + assert.match(skill, /Providers must agree/); assert.match(skill, /bounded `lint`, `inventory`, and `integrity` envelopes/); assert.match(skill, /individual [\s\S]+command only to diagnose a named unavailable or truncated owner/); assert.doesNotMatch(skill, / agent-lint --workspace /); @@ -386,6 +390,16 @@ test("finding-bound fixes remain slash-command owned and independently reassesse assert.match(skill, /\[Finding-bound Fix\]\(references\/finding-bound-fix\.md\)/); assert.match(fix, /initiating handoff must explicitly invoke `\/better-harness`/); assert.match(fix, /better-harness-fix-output/); + assert.match(fix, /harness workspace-topology --workspace --json/); + assert.match(fix, /structured\s+`target`/); + assert.match(fix, /complete `kind`, `packageRoute`, and `ownerRoute`/); + assert.match(fix, /present-but-incomplete target is invalid/); + assert.match(fix, /repo-root\|workspace-member\|repo-subtree\|standalone/); + assert.match(fix, /mismatch fails closed/); + assert.match(fix, /`requestedWorkspace` for standalone/); + assert.match(fix, /smallest\s+owner for inspection, mutation,\s+and verification/); + assert.match(fix, /legacy report with no `target` field at all/); + assert.match(fix, /do not invent\s+`packageRoute` or `ownerRoute`/); assert.match(fix, /exactly one fresh read-only subagent/); assert.match(fix, /Agent Work Loop/); assert.match(fix, /asset-integrity/); diff --git a/test/core-change-watch-scope.test.mjs b/test/core-change-watch-scope.test.mjs new file mode 100644 index 0000000..1a4ca24 --- /dev/null +++ b/test/core-change-watch-scope.test.mjs @@ -0,0 +1,318 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + assertCompatibleAnalysisScope, + fileRoleFor, + fromAnalysisRelativePath, + isDependencyOrGenerated, + isPathInAnalysisScope, + listTrackedFiles, + literalGitPathspec, + publicAnalysisScope, + resolveAnalysisScope, + scopePathspecArgs, + toAnalysisRelativePath, +} from "../scripts/core-change-watch/common.mjs"; +import { buildEvidencePack } from "../scripts/core-change-watch/evidence-pack.mjs"; +import { collectBoundedGitHistory } from "../scripts/harness-analysis/learning-capture-evidence.mjs"; +import { scanTaskLoopRepositoryEvidence } from "../scripts/harness-analysis/task-loop-repository-evidence.mjs"; + +function runGit(cwd, args) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + assert.equal(result.status, 0, `git ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout; +} + +async function writeFixture(root, relativePath, contents = "export const value = true;\n") { + const absolutePath = path.join(root, ...relativePath.split("/")); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, contents, "utf8"); +} + +test("analysis scope resolves repo and nested targets to one public contract", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "better-harness-scope-")); + const packageRoot = path.join(repoRoot, "packages", "editor app"); + try { + await mkdir(packageRoot, { recursive: true }); + runGit(repoRoot, ["init", "--quiet"]); + const gitRoot = path.resolve(runGit(repoRoot, ["rev-parse", "--show-toplevel"]).trim()); + + const repoScope = resolveAnalysisScope({ repoRoot }); + assert.deepEqual(publicAnalysisScope(repoScope), { + kind: "repo", + route: ".", + pathspecs: [], + }); + assert.equal(repoScope.repoRoot, gitRoot); + assert.equal(repoScope.targetRoot, gitRoot); + assert.ok(Object.isFrozen(repoScope)); + assert.ok(Object.isFrozen(repoScope.pathspecs)); + + const packageScope = resolveAnalysisScope({ cwd: packageRoot }); + assert.deepEqual(publicAnalysisScope(packageScope), { + kind: "path", + route: "packages/editor app", + pathspecs: [":(top,literal)packages/editor app"], + }); + assert.equal(packageScope.repoRoot, gitRoot); + assert.equal(packageScope.targetRoot, path.join(gitRoot, "packages", "editor app")); + + assert.throws( + () => resolveAnalysisScope({ repoRoot, packageRelPath: "../other" }), + (error) => error.code === "INVALID_PACKAGE_SCOPE", + ); + assert.throws( + () => resolveAnalysisScope({ repoRoot, targetRoot: path.join(repoRoot, "..", "other") }), + (error) => error.code === "INVALID_PACKAGE_SCOPE", + ); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +}); + +test("scope path conversion is segment-aware and round trips local paths", () => { + const scope = publicAnalysisScope({ kind: "path", route: "packages/app" }); + + assert.equal(isPathInAnalysisScope("packages/app", scope), true); + assert.equal(isPathInAnalysisScope("packages/app/src/main.ts", scope), true); + assert.equal(isPathInAnalysisScope("packages/app-old/src/main.ts", scope), false); + assert.equal(isPathInAnalysisScope("../packages/app/src/main.ts", scope), false); + assert.equal(toAnalysisRelativePath("packages/app", scope), "."); + assert.equal(toAnalysisRelativePath("packages/app/src/main.ts", scope), "src/main.ts"); + assert.equal(fromAnalysisRelativePath(".", scope), "packages/app"); + assert.equal(fromAnalysisRelativePath("src/main.ts", scope), "packages/app/src/main.ts"); + + assert.throws( + () => toAnalysisRelativePath("packages/app-old/src/main.ts", scope), + (error) => error.code === "PATH_OUTSIDE_ANALYSIS_SCOPE", + ); + assert.throws( + () => fromAnalysisRelativePath("../other/src/main.ts", scope), + (error) => error.code === "PATH_OUTSIDE_ANALYSIS_SCOPE", + ); +}); + +test("Git scope arguments use top-level literal pathspecs", () => { + const route = "packages/app [legacy]"; + const scope = publicAnalysisScope({ kind: "path", route: `${route}/` }); + + assert.equal(literalGitPathspec(route), ":(top,literal)packages/app [legacy]"); + assert.deepEqual(scopePathspecArgs(scope), ["--", ":(top,literal)packages/app [legacy]"]); + assert.deepEqual(scopePathspecArgs(), ["--"]); + assert.throws( + () => publicAnalysisScope({ kind: "path", route, pathspecs: [route] }), + (error) => error.code === "INVALID_ANALYSIS_SCOPE", + ); +}); + +test("analysis scope compatibility fails closed for package and Git-root mismatches", () => { + const exampleRepo = path.join(tmpdir(), "better-harness-example-repo"); + const root = resolveAnalysisScope({ repoRoot: exampleRepo }); + const packageScope = resolveAnalysisScope({ + repoRoot: exampleRepo, + packageRelPath: "packages/app", + }); + + assert.equal(assertCompatibleAnalysisScope(null, root), true); + assert.equal(assertCompatibleAnalysisScope({ analysisScope: publicAnalysisScope(packageScope) }, packageScope), true); + assert.throws( + () => assertCompatibleAnalysisScope(null, packageScope, "project profile"), + (error) => error.code === "ANALYSIS_SCOPE_MISMATCH" && /project profile/u.test(error.message), + ); + assert.throws( + () => assertCompatibleAnalysisScope(root, packageScope), + (error) => error.code === "ANALYSIS_SCOPE_MISMATCH", + ); + assert.throws( + () => assertCompatibleAnalysisScope( + { ...packageScope, repoRoot: path.join(tmpdir(), "better-harness-other-repo") }, + packageScope, + ), + (error) => error.code === "ANALYSIS_SCOPE_MISMATCH", + ); +}); + +test("tracked-file discovery isolates a special-character package route", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "better-harness-git-scope-")); + const targetRoute = "packages/app [legacy]"; + const tracked = [ + `${targetRoute}/build/compiler.ts`, + `${targetRoute}/src/main.ts`, + "packages/app [legacy]-old/src/main.ts", + "packages/other/src/main.ts", + ]; + try { + runGit(repoRoot, ["init", "--quiet"]); + for (const filePath of tracked) { + await writeFixture(repoRoot, filePath); + } + runGit(repoRoot, ["add", "--all"]); + + const scope = resolveAnalysisScope({ repoRoot, packageRelPath: targetRoute }); + assert.deepEqual(listTrackedFiles(repoRoot, scope), [ + `${targetRoute}/build/compiler.ts`, + `${targetRoute}/src/main.ts`, + ]); + assert.deepEqual(listTrackedFiles(repoRoot).sort(), [...tracked].sort()); + assert.throws( + () => listTrackedFiles(path.join(repoRoot, "packages"), scope), + (error) => error.code === "ANALYSIS_SCOPE_MISMATCH", + ); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +}); + +test("build is eligible source while explicit artifact directories remain generated", () => { + assert.equal(isDependencyOrGenerated("build/compiler.ts"), false); + assert.equal(fileRoleFor("build/compiler.ts"), "source"); + assert.equal(isDependencyOrGenerated("dist/compiler.ts"), true); + assert.equal(fileRoleFor("dist/compiler.ts"), "generated"); +}); + +test("evidence pack keeps project, history, diff, drift, and recommendations inside one package", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "better-harness-pack-scope-")); + try { + runGit(repoRoot, ["init", "--quiet"]); + runGit(repoRoot, ["config", "user.email", "test@example.com"]); + runGit(repoRoot, ["config", "user.name", "Test User"]); + await writeFixture(repoRoot, "package.json", JSON.stringify({ workspaces: ["packages/*"] })); + await writeFixture(repoRoot, "AGENTS.md", "# root instructions\n"); + await writeFixture(repoRoot, "packages/a/package.json", JSON.stringify({ name: "package-a" })); + await writeFixture(repoRoot, "packages/a/AGENTS.md", "# package a\n"); + await writeFixture(repoRoot, "packages/a/README.md", "# Package A\n"); + await writeFixture(repoRoot, "packages/a/build/compiler.ts"); + await writeFixture(repoRoot, "packages/a/src/api/client.ts", "export const api = 1;\n"); + await writeFixture(repoRoot, "packages/a/src/api/client.test.ts", "export const tested = true;\n"); + await writeFixture(repoRoot, "packages/b/package.json", JSON.stringify({ + name: "package-b", + dependencies: { next: "^15.0.0" }, + })); + await writeFixture(repoRoot, "packages/b/src/app.tsx", "export const App = () => null;\n"); + runGit(repoRoot, ["add", "--all"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "initial"]); + + await writeFixture(repoRoot, "packages/a/src/api/client.ts", "export const api = 2;\n"); + runGit(repoRoot, ["add", "packages/a"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "change package a"]); + await writeFixture(repoRoot, "packages/b/src/app.tsx", "export const App = () => 'b';\n"); + runGit(repoRoot, ["add", "packages/b"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "change package b"]); + + await writeFixture(repoRoot, "packages/a/src/api/client.ts", "export const api = 3;\n"); + await writeFixture(repoRoot, "packages/a/src/new.ts", "export const fresh = true;\n"); + await writeFixture(repoRoot, "packages/b/docs/api.md", "# Sibling docs\n"); + await writeFixture(repoRoot, "packages/b/src/untracked.ts", "export const sibling = true;\n"); + + const pack = await buildEvidencePack({ + cwd: repoRoot, + packageRelPath: "packages/a", + maxCommits: 20, + maxCandidates: 10, + }); + + assert.deepEqual(pack.analysisScope, { + kind: "path", + route: "packages/a", + pathspecs: [":(top,literal)packages/a"], + }); + assert.equal(pack.projectProfile.projectInfo.name, "package-a"); + assert.equal(pack.projectProfile.projectInfo.readmeTitle, "Package A"); + assert.equal(pack.projectProfile.agentInstructions.rootCount, 1); + assert.ok(pack.projectProfile.manifests.every((item) => item.path.startsWith("packages/a/"))); + assert.ok(pack.projectProfile.frameworks.every((item) => item.name !== "nextjs")); + assert.ok(pack.projectProfile.languages.some((item) => item.language === "typescript")); + assert.equal(fileRoleFor("packages/a/build/compiler.ts"), "source"); + assert.ok(pack.historyProfile.hotFiles.every((item) => item.path.startsWith("packages/a/"))); + assert.equal(pack.historyProfile.range.analyzedCommits, 2); + assert.ok(pack.coreAnalysis.candidates.every((item) => item.path.startsWith("packages/a"))); + assert.ok(pack.diffImpact.changedFiles.every((item) => item.path.startsWith("packages/a/"))); + assert.ok(pack.diffImpact.changedFiles.some((item) => item.path === "packages/a/src/new.ts" && item.untracked)); + assert.ok(pack.changeDrift.changedFiles.every((item) => item.path.startsWith("packages/a/"))); + assert.ok(pack.changeDrift.findings.some((finding) => finding.driftType === "public-api-docs")); + assert.ok(pack.changeDrift.findings.every((finding) => + finding.triggerFiles.every((filePath) => filePath.startsWith("packages/a/")) + && finding.candidateCompanionFiles.every((filePath) => filePath.startsWith("packages/a/")))); + assert.ok(pack.recommendedReads.every((item) => item.path.startsWith("packages/a"))); + assert.ok(pack.followUpActions.every((action) => + action.files.every((file) => + String(typeof file === "string" ? file : file.path).startsWith("packages/a")))); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +}); + +test("scoped evidence fails closed when the requested Git base is invalid", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "better-harness-invalid-base-")); + try { + runGit(repoRoot, ["init", "--quiet"]); + runGit(repoRoot, ["config", "user.email", "test@example.com"]); + runGit(repoRoot, ["config", "user.name", "Test User"]); + await writeFixture(repoRoot, "packages/app/src/main.ts"); + runGit(repoRoot, ["add", "--all"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "initial"]); + + await assert.rejects( + buildEvidencePack({ + cwd: repoRoot, + packageRelPath: "packages/app", + baseRef: "refs/heads/definitely-missing", + }), + (error) => error?.code === "GIT_COMMAND_FAILED" + && /definitely-missing/u.test(error.message), + ); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +}); + +test("lead repository history and static evidence consume the frozen package scope", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "better-harness-lead-scope-")); + try { + runGit(repoRoot, ["init", "--quiet"]); + runGit(repoRoot, ["config", "user.email", "test@example.com"]); + runGit(repoRoot, ["config", "user.name", "Test User"]); + await writeFixture(repoRoot, "packages/a/package.json", JSON.stringify({ name: "a" })); + await writeFixture(repoRoot, "packages/a/src/a.ts"); + await writeFixture(repoRoot, "packages/b/package.json", JSON.stringify({ name: "b" })); + await writeFixture(repoRoot, "packages/b/.github/workflows/release.yml", "name: sibling\n"); + await writeFixture(repoRoot, "packages/b/src/b.ts"); + runGit(repoRoot, ["add", "--all"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "initial"]); + await writeFixture(repoRoot, "packages/a/src/a.test.ts"); + runGit(repoRoot, ["add", "packages/a"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "fix package a"]); + await writeFixture(repoRoot, "packages/b/src/b.test.ts"); + runGit(repoRoot, ["add", "packages/b"]); + runGit(repoRoot, ["commit", "--quiet", "-m", "fix package b"]); + + const scope = resolveAnalysisScope({ repoRoot, packageRelPath: "packages/a" }); + const history = collectBoundedGitHistory(path.join(repoRoot, "packages/a"), { + analysisScope: publicAnalysisScope(scope), + limit: 20, + }); + assert.equal(history.status, "complete"); + assert.ok(history.commits.flatMap((commit) => commit.files) + .every((file) => file.path.startsWith("packages/a/"))); + + const evidence = scanTaskLoopRepositoryEvidence({ + workspace: path.join(repoRoot, "packages/a"), + analysisScope: publicAnalysisScope(scope), + }); + assert.ok((evidence.aiAgentPractice.coverageRows ?? []) + .flatMap((row) => row.paths ?? []) + .every((file) => file.startsWith("packages/a/"))); + assert.doesNotMatch(JSON.stringify(evidence), /packages\/b/u); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } +}); diff --git a/test/fixtures/scripts-refactor-contract/harness-help.txt b/test/fixtures/scripts-refactor-contract/harness-help.txt index 346d9fb..3d1a468 100644 --- a/test/fixtures/scripts-refactor-contract/harness-help.txt +++ b/test/fixtures/scripts-refactor-contract/harness-help.txt @@ -9,6 +9,7 @@ Subcommands: Audience: maintainer evidence-bundle Collect the three specialist lanes and lead evidence in one frozen-context bundle + workspace-topology Resolve the Git-aware workspace target and member topology analyze Return a neutral, budgeted Harness evidence brief checkup Scan agent customizations and produce a read-only cleanup plan selection-profile Build a privacy-safe session selection profile @@ -25,6 +26,7 @@ Subcommands: Examples: better-harness harness analyze --workspace . --language en --format json better-harness harness checkup --phase scan --provider qoder --workspace . --json + better-harness harness workspace-topology --workspace . --json better-harness harness source --workspace . --source /report.source.json --language en better-harness harness render --findings /findings.json --mode qoder-canvas --out .qoder/better-harness --target . --validate --json better-harness harness preview-canvas /report.canvas.tsx --open diff --git a/test/fixtures/scripts-refactor-contract/root-help.txt b/test/fixtures/scripts-refactor-contract/root-help.txt index 25e8f1d..456db3e 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -15,9 +15,10 @@ Commands: Workflows harness Check Better Harness readiness reports and Canvas outputs - subcommands: evidence-bundle, analyze, checkup, selection-profile, - source, task-loop-report, render, preview-canvas, report-quality, - repair-findings, record-fix-output, validate-canvas + subcommands: evidence-bundle, workspace-topology, analyze, checkup, + selection-profile, source, task-loop-report, render, + preview-canvas, report-quality, repair-findings, record-fix-output, + validate-canvas Project Evidence session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, and Qwen diff --git a/test/harness-canvas-validation.test.mjs b/test/harness-canvas-validation.test.mjs index b2f7aca..7a4ecd6 100644 --- a/test/harness-canvas-validation.test.mjs +++ b/test/harness-canvas-validation.test.mjs @@ -791,9 +791,11 @@ test("installed-like Canvas validation resolves its transform runtime from --sdk await cp(path.resolve("scripts/harness-analysis"), installedAnalysis, { recursive: true }); await cp(path.resolve("scripts/agent-guardrails"), path.join(installedRoot, "scripts", "agent-guardrails"), { recursive: true }); + await cp(path.resolve("scripts/core-change-watch"), path.join(installedRoot, "scripts", "core-change-watch"), { recursive: true }); await cp(path.resolve("scripts/coding-agent-practices/asset-eval"), path.join(installedRoot, "scripts", "coding-agent-practices", "asset-eval"), { recursive: true }); await cp(path.resolve("scripts/coding-agent-practices/checkup"), path.join(installedRoot, "scripts", "coding-agent-practices", "checkup"), { recursive: true }); await cp(path.resolve("scripts/session-analysis"), path.join(installedRoot, "scripts", "session-analysis"), { recursive: true }); + await cp(path.resolve("scripts/workspace-topology"), path.join(installedRoot, "scripts", "workspace-topology"), { recursive: true }); await cp(path.resolve("templates/canvas"), path.join(installedRoot, "templates", "canvas"), { recursive: true }); await writeFixture(path.join(sdkRoot, "package.json"), '{"name":"fixture-canvas-sdk"}\n'); await writeFixture(sdkDeclarationsPath, 'export { AreaChart, BarChart, Button, Callout, Card, CardBody, CardHeader, CollapsibleSection, Dialog, Divider, Fluency, Grid, IconButton, ImprovementKataCard, LineChart, MetricsGrid, Progress, RiskHeatmap, Row, SendToChatButton, Stack, Table, Tag, Text, useCanvasAction } from "./core.js";\n'); diff --git a/test/harness-findings-repair.test.mjs b/test/harness-findings-repair.test.mjs index ae95be9..473e070 100644 --- a/test/harness-findings-repair.test.mjs +++ b/test/harness-findings-repair.test.mjs @@ -121,6 +121,20 @@ test("repairFindingsJsonData normalizes to the minimal findings contract", () => assert.equal(validation.status, "pass", validation.errors.join("\n")); }); +test("repairFindingsJsonData preserves a valid structured finding target", () => { + const input = validFindingsJson(); + input.findings[0].target = { + kind: "workspace-member", + packageRoute: "packages/app", + ownerRoute: "packages/app", + }; + const repaired = repairFindingsJsonData(input, { targetPath: "/tmp/fixture-project" }); + + assert.deepEqual(repaired.data.findings[0].target, input.findings[0].target); + const validation = evaluateFindingsJson(JSON.stringify(repaired.data), null); + assert.equal(validation.status, "pass", validation.errors.join("\n")); +}); + test("repairFindingsJsonData preserves structurally complete task-loop result contracts regardless of version metadata", () => { const report = (reportContractVersion) => ({ summary: { diff --git a/test/harness-report-render-cli.test.mjs b/test/harness-report-render-cli.test.mjs index d51eeeb..9a96ee5 100644 --- a/test/harness-report-render-cli.test.mjs +++ b/test/harness-report-render-cli.test.mjs @@ -10,6 +10,7 @@ import { evaluateHtmlReport, renderHtml } from "../scripts/harness-analysis/rend import { renderCanvasTsx } from "../scripts/harness-analysis/renderers/qoder-canvas.mjs"; import { buildTaskLoopSourceCandidate } from "../scripts/harness-analysis/task-loop-source.mjs"; import { applyEpisodeReviews } from "../scripts/harness-analysis/episode-evidence-review.mjs"; +import { projectTaskLoopFindings } from "../scripts/harness-analysis/task-loop-report.mjs"; const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); const renderPath = path.join(process.cwd(), "scripts", "harness-analysis", "render-report.mjs"); @@ -279,6 +280,53 @@ function runNode(args, options = {}) { }); } +function git(cwd, args) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + assert.equal(result.status, 0, result.stderr); +} + +async function createRenderMonorepo(root, { partial = false } = {}) { + git(root, ["init", "-q"]); + git(root, ["config", "user.email", "test@example.com"]); + git(root, ["config", "user.name", "Test User"]); + await writeJson(path.join(root, "package.json"), { + private: true, + workspaces: partial ? ["packages/*", "../outside/*"] : ["packages/*"], + }); + for (const name of ["a", "b"]) { + await writeJson(path.join(root, "packages", name, "package.json"), { name }); + } + git(root, ["add", "."]); + git(root, ["commit", "-q", "-m", "fixture"]); +} + +function targetFindingData({ packageRoute = "packages/a" } = {}) { + const data = sampleFindings(); + data.findings = data.findings.map((finding) => ({ + ...finding, + target: { + kind: "workspace-member", + packageRoute, + ownerRoute: packageRoute, + }, + })); + return data; +} + +function taskLoopFindingsAtVersion(version) { + const findings = projectTaskLoopFindings(reviewedTaskLoopSource(), { + projectName: "render-source-project", + direct: true, + }); + findings.summary.reportContractVersion = version; + for (const finding of findings.findings) delete finding.target; + return findings; +} + function parseRun(stdout) { const payload = JSON.parse(stdout); assert.equal(payload.kind, "harness-report-render"); @@ -350,6 +398,138 @@ test("render command defaults new writes to Better Harness Qoder Canvas output", }); }); +test("render keeps legacy v1 package findings readable without target metadata", async () => { + await withTempDir("better-harness-render-legacy-package-", async (root) => { + await createRenderMonorepo(root); + const packageRoot = path.join(root, "packages", "a"); + const findingsPath = path.join(root, "legacy.findings.json"); + await writeJson(findingsPath, taskLoopFindingsAtVersion(1)); + + const result = runNode([ + renderPath, + "--findings", findingsPath, + "--mode", "markdown", + "--out", path.join(root, "runs"), + "--target", packageRoot, + "--json", + ]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = parseRun(result.stdout); + assert.equal(payload.status, "pass"); + }); +}); + +test("render requires target metadata for the new contract in a package workspace", async () => { + await withTempDir("better-harness-render-new-package-", async (root) => { + await createRenderMonorepo(root); + const findingsPath = path.join(root, "current.findings.json"); + await writeJson(findingsPath, taskLoopFindingsAtVersion(26)); + + const result = runNode([ + renderPath, + "--findings", findingsPath, + "--mode", "markdown", + "--out", path.join(root, "runs"), + "--target", path.join(root, "packages", "a"), + "--json", + ]); + + assert.equal(result.status, 1); + const payload = parseRun(result.stdout); + assert.equal(payload.error.code, "INVALID_FINDINGS"); + assert.ok(payload.error.details.some((error) => + /findings\[0\]\.target is required for this workspace target/u.test(error))); + }); +}); + +test("render rejects structured target metadata without an explicit target workspace", async () => { + await withTempDir("better-harness-render-target-required-", async (root) => { + const findingsPath = path.join(root, "target.findings.json"); + await writeJson(findingsPath, targetFindingData()); + + const result = runNode([ + renderPath, + "--findings", findingsPath, + "--mode", "markdown", + "--out", path.join(root, "runs"), + "--json", + ]); + + assert.equal(result.status, 1); + assert.equal(parseRun(result.stdout).error.code, "MISSING_RENDER_TARGET"); + }); +}); + +test("render rejects structured target metadata when topology discovery is partial", async () => { + await withTempDir("better-harness-render-target-partial-", async (root) => { + await createRenderMonorepo(root, { partial: true }); + const findingsPath = path.join(root, "target.findings.json"); + await writeJson(findingsPath, targetFindingData()); + + const result = runNode([ + renderPath, + "--findings", findingsPath, + "--mode", "markdown", + "--out", path.join(root, "runs"), + "--target", path.join(root, "packages", "a"), + "--json", + ]); + + assert.equal(result.status, 1); + assert.equal(parseRun(result.stdout).error.code, "RENDER_WORKSPACE_TOPOLOGY_INCOMPLETE"); + }); +}); + +test("render rejects structured target metadata for a different package", async () => { + await withTempDir("better-harness-render-target-mismatch-", async (root) => { + await createRenderMonorepo(root); + const findingsPath = path.join(root, "target.findings.json"); + await writeJson(findingsPath, targetFindingData({ packageRoute: "packages/a" })); + + const result = runNode([ + renderPath, + "--findings", findingsPath, + "--mode", "markdown", + "--out", path.join(root, "runs"), + "--target", path.join(root, "packages", "b"), + "--json", + ]); + + assert.equal(result.status, 1); + const payload = parseRun(result.stdout); + assert.equal(payload.error.code, "INVALID_FINDINGS"); + assert.ok(payload.error.details.some((error) => /packageRoute does not match/u.test(error))); + }); +}); + +test("render accepts and preserves structured target metadata for the selected package", async () => { + await withTempDir("better-harness-render-target-match-", async (root) => { + await createRenderMonorepo(root); + const findingsPath = path.join(root, "target.findings.json"); + const outDir = path.join(root, "runs"); + await writeJson(findingsPath, targetFindingData({ packageRoute: "packages/a" })); + + const result = runNode([ + renderPath, + "--findings", findingsPath, + "--mode", "markdown", + "--out", outDir, + "--target", path.join(root, "packages", "a"), + "--json", + ]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = parseRun(result.stdout); + const rendered = JSON.parse(readFileSync(path.join(payload.runDir, "findings.json"), "utf8")); + assert.deepEqual(rendered.findings[0].target, { + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: "packages/a", + }); + }); +}); + test("render command rejects an unsupported mode", async () => { await withTempDir("better-harness-unsupported-mode-", async (root) => { const findingsPath = path.join(root, "input.findings.json"); diff --git a/test/host-plugin-artifact.test.mjs b/test/host-plugin-artifact.test.mjs index 656af47..0c07f5e 100644 --- a/test/host-plugin-artifact.test.mjs +++ b/test/host-plugin-artifact.test.mjs @@ -44,6 +44,12 @@ test("runtime bundle includes the project license and runtime docs", async () => "CONTRIBUTING.md", "case-studies/factory/model/factory-readiness.md", "docs/glossary.md", + "scripts/workspace-topology/cli.mjs", + "scripts/workspace-topology/contract.mjs", + "scripts/workspace-topology/finding-target.mjs", + "scripts/workspace-topology/index.mjs", + "scripts/workspace-topology/inventory.mjs", + "scripts/workspace-topology/manifests.mjs", "vendor/tree-sitter-wasm/LICENSE", "vendor/esbuild-wasm/LICENSE.md", ]) { diff --git a/test/maturity-models.test.mjs b/test/maturity-models.test.mjs index 557b55c..370608b 100644 --- a/test/maturity-models.test.mjs +++ b/test/maturity-models.test.mjs @@ -7,6 +7,7 @@ import { AGENT_WORK_LOOP_DIMENSIONS, AGENT_WORK_LOOP_MODEL_ID, AGENT_WORK_LOOP_REPORT_CONTRACT_VERSION, + FINDING_TARGET_REPORT_CONTRACT_VERSION, agentWorkLoopDimensionScoreCeiling, scoreAgentWorkLoopDimension, scoreAgentWorkLoopEvidence, @@ -66,7 +67,8 @@ test("Agent Work Loop runtime metadata stays separate from the review model", () ]) assert.match(software, new RegExp(heading)); assert.equal(AGENT_WORK_LOOP_MODEL_ID, "agent-work-loop-v4"); - assert.equal(AGENT_WORK_LOOP_REPORT_CONTRACT_VERSION, 25); + assert.equal(FINDING_TARGET_REPORT_CONTRACT_VERSION, 26); + assert.equal(AGENT_WORK_LOOP_REPORT_CONTRACT_VERSION, 26); assert.match(workLoop, /^# Agent Work Loop\n/); assert.match(workLoop, /## Review map/); assert.match(workLoop, /## Evidence, findings, and scores/); diff --git a/test/plugin-manifests.test.mjs b/test/plugin-manifests.test.mjs index 57c4328..204a3ae 100644 --- a/test/plugin-manifests.test.mjs +++ b/test/plugin-manifests.test.mjs @@ -248,6 +248,8 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.match(verifyScript, /package\/CONTRIBUTING\.md/u); assert.match(verifyScript, /package\/case-studies\/factory\/model\/factory-readiness\.md/u); assert.match(verifyScript, /package\/docs\/glossary\.md/u); + assert.match(verifyScript, /package\/scripts\/workspace-topology\/cli\.mjs/u); + assert.match(verifyScript, /package\/scripts\/workspace-topology\/finding-target\.mjs/u); assert.match(verifyScript, /^\s*"LICENSE",$/mu); assert.match(verifyScript, /vendor\/tree-sitter-wasm\/LICENSE/u); assert.match(verifyScript, /vendor\/esbuild-wasm\/LICENSE\.md/u); diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index 4eaed8f..1a0bb87 100644 --- a/test/scripts-refactor-contract.test.mjs +++ b/test/scripts-refactor-contract.test.mjs @@ -70,17 +70,17 @@ test("scripts refactor contract freezes machine-readable CLI output", () => { { label: "command inventory", args: ["commands", "--json"], - sha256: "6a015f7a50ccf60bcdc02f490e16790f011e84925e12440f4967812aad16e408", + sha256: "dd0fba822d542170ddfe15c45a1054832fa3cbf81ae8728171ee9ee62b8b7d31", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "cc5788593fe6f305db290974d4c808647b190a2850c621433f3269dc416ab179", + sha256: "c77474e6b2f62e7bc7a42b4a677335df6a212e407ce5de55060f3d91e46ba891", }, { label: "Harness command description", args: ["command", "describe", "harness", "--json"], - sha256: "1efeceb766dbdfd97750a1e402f3892d0a08a374659945741084b75783bbd6a4", + sha256: "7bfdf90239d7c021dc97375a6b82c40ea435847fe57e2774fcdc56ec7c56bacd", }, ]; diff --git a/test/session-workspace-match.test.mjs b/test/session-workspace-match.test.mjs new file mode 100644 index 0000000..433ad7d --- /dev/null +++ b/test/session-workspace-match.test.mjs @@ -0,0 +1,303 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SESSION_WORKSPACE_SCOPE_KIND, + WORKSPACE_CWD_MATCH, + WORKSPACE_PATH_CLASS, + WORKSPACE_QUALIFICATION_STATUS, + WORKSPACE_SESSION_MATCH, + classifyWorkspaceCwd, + classifyWorkspacePathFact, + qualifyWorkspaceSession, + summarizeWorkspaceQualifications, + validateWorkspaceMatchTopology, +} from "../scripts/session-analysis/workspace-match.mjs"; + +function memberTopology(overrides = {}) { + return { + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/repo/extensions/assistant", + gitRoot: "/repo", + target: { + kind: "workspace-member", + route: "extensions/assistant", + memberRoute: "extensions/assistant", + memberMatch: "exact", + }, + ...overrides, + }; +} + +test("validates and normalizes the session-critical workspace topology", () => { + const scope = validateWorkspaceMatchTopology(memberTopology({ + requestedWorkspace: "/repo/extensions/assistant/", + gitRoot: "/repo/", + })); + + assert.equal(scope.kind, SESSION_WORKSPACE_SCOPE_KIND); + assert.equal(scope.requestedWorkspace, "/repo/extensions/assistant"); + assert.equal(scope.gitRoot, "/repo"); + assert.equal(scope.target.kind, "workspace-member"); + assert.equal(Object.isFrozen(scope), true); + assert.equal(Object.isFrozen(scope.target), true); +}); + +test("rejects topology mismatches without echoing path values", () => { + const invalid = [ + { ...memberTopology(), kind: "wrong" }, + { ...memberTopology(), schemaVersion: 2 }, + { ...memberTopology(), requestedWorkspace: "relative/package" }, + { ...memberTopology(), requestedWorkspace: "/repo/extensions/other" }, + { + ...memberTopology(), + target: { ...memberTopology().target, route: "extensions/../assistant" }, + }, + { + ...memberTopology(), + target: { ...memberTopology().target, memberRoute: "extensions/other" }, + }, + ]; + + for (const topology of invalid) { + assert.throws( + () => validateWorkspaceMatchTopology(topology), + (error) => error.code === "INVALID_WORKSPACE_TOPOLOGY" + && !String(error.message).includes("/repo/extensions/assistant"), + ); + } +}); + +test("validates repo-root, repo-subtree, and standalone invariants", () => { + const root = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/repo", + gitRoot: "/repo", + target: { kind: "repo-root", route: ".", memberRoute: null, memberMatch: "none" }, + }); + const subtree = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/repo/tools/checks", + gitRoot: "/repo", + target: { kind: "repo-subtree", route: "tools/checks", memberRoute: null, memberMatch: "none" }, + }); + const standalone = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/standalone", + gitRoot: null, + target: { kind: "standalone", route: ".", memberRoute: null, memberMatch: "none" }, + }); + + assert.equal(root.target.kind, "repo-root"); + assert.equal(subtree.target.kind, "repo-subtree"); + assert.equal(standalone.gitRoot, null); +}); + +test("classifies direct CWDs, exact Git-root candidates, and sibling prefixes safely", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + + assert.equal(classifyWorkspaceCwd("/repo/extensions/assistant", scope), WORKSPACE_CWD_MATCH.DIRECT); + assert.equal(classifyWorkspaceCwd("/repo/extensions/assistant/src", scope), WORKSPACE_CWD_MATCH.DIRECT); + assert.equal(classifyWorkspaceCwd("/repo", scope), WORKSPACE_CWD_MATCH.ROOT_CANDIDATE); + assert.equal(classifyWorkspaceCwd("/repo/extensions/assistant-tools", scope), WORKSPACE_CWD_MATCH.UNMATCHED); + assert.equal(classifyWorkspaceCwd("/repo/extensions", scope), WORKSPACE_CWD_MATCH.UNMATCHED); + assert.equal(classifyWorkspaceCwd("relative", scope), WORKSPACE_CWD_MATCH.UNMATCHED); +}); + +test("does not create root candidates for repo-root, subtree, or standalone targets", () => { + const rootScope = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/repo", + gitRoot: "/repo", + target: { kind: "repo-root", route: ".", memberRoute: null, memberMatch: "none" }, + }); + const subtreeScope = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/repo/tools", + gitRoot: "/repo", + target: { kind: "repo-subtree", route: "tools", memberRoute: null, memberMatch: "none" }, + }); + const standaloneScope = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "/standalone", + gitRoot: null, + target: { kind: "standalone", route: ".", memberRoute: null, memberMatch: "none" }, + }); + + assert.equal(classifyWorkspaceCwd("/repo", rootScope), WORKSPACE_CWD_MATCH.DIRECT); + assert.equal(classifyWorkspaceCwd("/repo", subtreeScope), WORKSPACE_CWD_MATCH.UNMATCHED); + assert.equal(classifyWorkspaceCwd("/standalone", standaloneScope), WORKSPACE_CWD_MATCH.DIRECT); +}); + +test("classifies absolute and relative path facts with segment-safe containment", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + + assert.equal(classifyWorkspacePathFact("/repo/extensions/assistant/src/a.ts", scope), WORKSPACE_PATH_CLASS.TARGET); + assert.equal( + classifyWorkspacePathFact("extensions/assistant/src/a.ts", scope, { cwd: "/repo" }), + WORKSPACE_PATH_CLASS.TARGET, + ); + assert.equal( + classifyWorkspacePathFact({ filePath: "src/a.ts", cwd: "/repo/extensions/assistant" }, scope), + WORKSPACE_PATH_CLASS.TARGET, + ); + assert.equal( + classifyWorkspacePathFact("/repo/extensions/assistant-tools/src/a.ts", scope), + WORKSPACE_PATH_CLASS.GIT_OTHER, + ); + assert.equal( + classifyWorkspacePathFact("extensions/assistant/../other/a.ts", scope, { cwd: "/repo" }), + WORKSPACE_PATH_CLASS.GIT_OTHER, + ); + assert.equal(classifyWorkspacePathFact("/outside/a.ts", scope), WORKSPACE_PATH_CLASS.OUTSIDE_GIT); + assert.equal(classifyWorkspacePathFact("", scope, { cwd: "/repo" }), WORKSPACE_PATH_CLASS.UNRESOLVED); + assert.equal(classifyWorkspacePathFact("relative.ts", scope), WORKSPACE_PATH_CLASS.UNRESOLVED); +}); + +test("keeps direct-CWD behavior independent of root preflight state", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + const qualification = qualifyWorkspaceSession({ + cwd: "/repo/extensions/assistant/src", + pathFacts: [], + truncated: true, + }, scope); + + assert.equal(qualification.qualified, true); + assert.equal(qualification.status, WORKSPACE_QUALIFICATION_STATUS.DIRECT); + assert.equal(qualification.workspaceMatch, WORKSPACE_SESSION_MATCH.DIRECT_CWD); + assert.equal(qualification.diagnostics.basis, "cwd"); + assert.equal(qualification.diagnostics.truncated, false); +}); + +test("qualifies a root-CWD session only from positive target-only path facts", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + const qualification = qualifyWorkspaceSession({ + cwd: "/repo", + pathFacts: [ + { path: "extensions/assistant/src/a.ts" }, + { filePath: "/repo/extensions/assistant/test/a.test.ts" }, + ], + }, scope); + + assert.equal(qualification.qualified, true); + assert.equal(qualification.status, WORKSPACE_QUALIFICATION_STATUS.TARGET_ONLY); + assert.equal(qualification.workspaceMatch, WORKSPACE_SESSION_MATCH.ROOT_CWD); + assert.deepEqual(qualification.diagnostics.pathFacts, { + observed: 2, + target: 2, + foreign: 0, + unresolved: 0, + }); + const serialized = JSON.stringify(qualification); + assert.doesNotMatch(serialized, /extensions|assistant|\/repo/u); +}); + +test("rejects mixed root-CWD activity without exposing the foreign path", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + const qualification = qualifyWorkspaceSession({ + cwd: "/repo", + pathFacts: [ + "extensions/assistant/src/a.ts", + "extensions/other/src/b.ts", + "/outside/private.txt", + ], + }, scope); + + assert.equal(qualification.qualified, false); + assert.equal(qualification.status, WORKSPACE_QUALIFICATION_STATUS.MIXED); + assert.equal(qualification.workspaceMatch, null); + assert.deepEqual(qualification.diagnostics.pathFacts, { + observed: 3, + target: 1, + foreign: 2, + unresolved: 0, + }); + assert.doesNotMatch(JSON.stringify(qualification), /other|outside|private/u); +}); + +test("distinguishes no-target, ambiguous, truncated, and unmatched root candidates", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + const noTarget = qualifyWorkspaceSession({ cwd: "/repo", pathFacts: ["extensions/other/a.ts"] }, scope); + const ambiguous = qualifyWorkspaceSession({ + cwd: "/repo", + pathFacts: ["extensions/assistant/a.ts", { path: "", cwd: "/repo" }], + }, scope); + const truncated = qualifyWorkspaceSession({ + cwd: "/repo", + pathFacts: ["extensions/assistant/a.ts", "extensions/other/a.ts"], + truncated: true, + }, scope); + const unmatched = qualifyWorkspaceSession({ + cwd: "/repo/extensions/other", + pathFacts: ["/repo/extensions/assistant/a.ts"], + }, scope); + + assert.equal(noTarget.status, WORKSPACE_QUALIFICATION_STATUS.NO_TARGET); + assert.equal(ambiguous.status, WORKSPACE_QUALIFICATION_STATUS.AMBIGUOUS); + assert.equal(truncated.status, WORKSPACE_QUALIFICATION_STATUS.TRUNCATED); + assert.equal(unmatched.status, WORKSPACE_QUALIFICATION_STATUS.UNMATCHED_CWD); + assert.equal([noTarget, ambiguous, truncated, unmatched].every((item) => !item.qualified), true); +}); + +test("supports Windows paths without relying on the current host platform", () => { + const scope = validateWorkspaceMatchTopology({ + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace: "C:\\repo\\extensions\\assistant", + gitRoot: "C:\\repo", + target: { + kind: "workspace-member", + route: "extensions/assistant", + memberRoute: "extensions/assistant", + memberMatch: "exact", + }, + }); + + assert.equal(classifyWorkspaceCwd("c:\\REPO", scope), WORKSPACE_CWD_MATCH.ROOT_CANDIDATE); + assert.equal( + classifyWorkspacePathFact("extensions\\assistant\\src\\a.ts", scope, { cwd: "C:\\repo" }), + WORKSPACE_PATH_CLASS.TARGET, + ); + assert.equal( + classifyWorkspacePathFact("C:\\repo\\extensions\\assistant-tools\\a.ts", scope), + WORKSPACE_PATH_CLASS.GIT_OTHER, + ); +}); + +test("aggregates privacy-safe qualification diagnostics", () => { + const scope = validateWorkspaceMatchTopology(memberTopology()); + const qualifications = [ + qualifyWorkspaceSession({ cwd: "/repo/extensions/assistant" }, scope), + qualifyWorkspaceSession({ cwd: "/repo", pathFacts: ["extensions/assistant/a.ts"] }, scope), + qualifyWorkspaceSession({ cwd: "/repo", pathFacts: ["extensions/other/a.ts"] }, scope), + qualifyWorkspaceSession({ + cwd: "/repo", + pathFacts: ["extensions/assistant/a.ts", "extensions/other/a.ts"], + }, scope), + qualifyWorkspaceSession({ cwd: "/repo", pathFacts: [{ path: "" }] }, scope), + qualifyWorkspaceSession({ cwd: "/repo", pathFacts: [], truncated: true }, scope), + qualifyWorkspaceSession({ cwd: "/repo/extensions/other" }, scope), + ]; + const summary = summarizeWorkspaceQualifications(qualifications); + + assert.deepEqual(summary, { + basis: "workspace-match-aggregate", + sessions: 7, + qualified: { directCwd: 1, rootCwd: 1 }, + omitted: { + unmatchedCwd: 1, + noTargetActivity: 1, + mixedActivity: 1, + ambiguousActivity: 1, + truncatedPreflight: 1, + }, + }); + assert.doesNotMatch(JSON.stringify(summary), /repo|extensions|assistant/u); +}); diff --git a/test/session-workspace-provider.test.mjs b/test/session-workspace-provider.test.mjs new file mode 100644 index 0000000..80d0c1d --- /dev/null +++ b/test/session-workspace-provider.test.mjs @@ -0,0 +1,584 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { CodexSessionAnalyzer } from "../scripts/session-analysis/platforms/codex.mjs"; +import { + ClaudeSessionAnalyzer, + workspaceToClaudeSlugVariants, +} from "../scripts/session-analysis/platforms/claude.mjs"; +import { + CursorSessionAnalyzer, + workspaceToCursorSlugVariants, +} from "../scripts/session-analysis/platforms/cursor.mjs"; +import { + QoderSessionAnalyzer, + workspaceToQoderSlug, +} from "../scripts/session-analysis/platforms/qoder.mjs"; +import { + bindSessionWorkspaceCwds, + hydrateWorkspaceSelection, + markSessionReadCoverage, + qualifyWorkspaceSessionInventory, + runProviderCommand, + withWorkspaceMatchDiagnostics, + workspaceMatchScopeFromOptions, +} from "../scripts/session-analysis/provider-runner.mjs"; + +async function writeJsonl(filePath, rows) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`); +} + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value)}\n`); +} + +function memberTopology(gitRoot, requestedWorkspace) { + const route = path.relative(gitRoot, requestedWorkspace).split(path.sep).join("/"); + return { + kind: "better-harness.workspace-topology", + schemaVersion: 1, + requestedWorkspace, + gitRoot, + target: { + kind: "workspace-member", + route, + memberRoute: route, + memberMatch: "exact", + }, + }; +} + +function codexRows(sessionId, cwd, paths = [], prompt = null) { + const rows = [{ + timestamp: "2026-07-20T10:00:00.000Z", + type: "session_meta", + payload: { id: sessionId, cwd }, + }]; + if (prompt) { + rows.push({ + timestamp: "2026-07-20T10:00:01.000Z", + type: "event_msg", + payload: { type: "user_message", message: prompt }, + }); + } + for (const [index, filePath] of paths.entries()) { + rows.push({ + timestamp: `2026-07-20T10:00:${String(index + 2).padStart(2, "0")}.000Z`, + type: "response_item", + payload: { + type: "function_call", + name: "read_file", + arguments: JSON.stringify({ path: filePath }), + }, + }); + } + return rows; +} + +function qoderRows(sessionId, cwd, paths = [], prompt = null) { + const rows = [{ + sessionId, + type: "user", + cwd, + timestamp: "2026-07-20T10:00:00.000Z", + message: prompt ?? "Inspect the selected workspace", + }]; + for (const [index, filePath] of paths.entries()) { + rows.push({ + sessionId, + type: "tool.requested", + cwd, + timestamp: `2026-07-20T10:00:${String(index + 2).padStart(2, "0")}.000Z`, + toolName: "Read", + args: { path: filePath }, + }); + } + return rows; +} + +function claudeRows(sessionId, cwd, paths = [], prompt = null) { + const rows = [{ + type: "user", + sessionId, + cwd, + timestamp: "2026-07-20T10:00:00.000Z", + message: { + role: "user", + content: [{ type: "text", text: prompt ?? "Inspect the selected workspace" }], + }, + }]; + for (const [index, filePath] of paths.entries()) { + rows.push({ + type: "assistant", + sessionId, + cwd, + timestamp: `2026-07-20T10:00:${String(index + 2).padStart(2, "0")}.000Z`, + message: { + role: "assistant", + content: [{ + type: "tool_use", + id: `tool-${index + 1}`, + name: "Read", + input: { file_path: filePath }, + }], + }, + }); + } + return rows; +} + +function cursorRows(paths = [], prompt = null) { + const rows = [{ + role: "user", + message: { + content: [{ type: "text", text: prompt ?? "Inspect the selected workspace" }], + }, + }]; + for (const [index, filePath] of paths.entries()) { + rows.push({ + role: "assistant", + message: { + content: [{ + type: "tool_use", + id: `tool-${index + 1}`, + name: "Read", + input: { file_path: filePath }, + }], + }, + }); + } + return rows; +} + +async function writeCursorMeta(home, sessionId, cwd) { + await writeJson(path.join(home, "chats", "workspace-hash", sessionId, "meta.json"), { + schemaVersion: 1, + cwd, + createdAtMs: Date.parse("2026-07-20T10:00:00.000Z"), + updatedAtMs: Date.parse("2026-07-20T10:05:00.000Z"), + hasConversation: true, + }); +} + +test("shared runner qualifies before selection and drops a root session that becomes mixed after hydration", async () => { + const topology = memberTopology("/repo", "/repo/packages/app"); + const workspaceScope = workspaceMatchScopeFromOptions({ topology }); + const direct = bindSessionWorkspaceCwds({ sessionId: "direct-private" }, ["/repo/packages/app"]); + const root = bindSessionWorkspaceCwds({ sessionId: "root-private" }, ["/repo"]); + let rootReads = 0; + const analyzer = { + async readSession(session, _scope, options) { + if (session.sessionId === "direct-private") return markSessionReadCoverage([], { truncated: false }); + rootReads += 1; + const events = rootReads === 1 + ? [{ cwd: "/repo", filePath: "packages/app/src/a.ts" }] + : [ + { cwd: "/repo", filePath: "packages/app/src/a.ts" }, + { cwd: "/repo", filePath: "packages/other/src/b.ts" }, + ]; + return options.workspacePreflightMaxLines + ? { events, truncated: false } + : markSessionReadCoverage(events, { truncated: false }); + }, + mergeSession(events, session) { + return { ...session, eventCount: events.length }; + }, + }; + const scope = { workspace: topology.requestedWorkspace, _workspaceMatchScope: workspaceScope }; + const workspaceRun = await qualifyWorkspaceSessionInventory({ + analyzer, + sessions: [direct, root], + scope, + options: {}, + }); + + assert.deepEqual(workspaceRun.sessions.map((session) => session.workspaceMatch), ["direct-cwd", "root-cwd"]); + const hydration = await hydrateWorkspaceSelection({ + analyzer, + selection: { + sessions: workspaceRun.sessions, + strategy: "all-eligible", + requestedStrategy: "all-eligible", + eligibleCount: 2, + analyzedCount: 2, + strata: [], + }, + scope, + workspaceRun, + }); + assert.deepEqual(hydration.detailedSessions.map((session) => session.sessionId), ["direct-private"]); + assert.equal(hydration.selection.analyzedCount, 1); + assert.equal(hydration.hydrationQualifications[0].status, "mixed"); + + const diagnostics = withWorkspaceMatchDiagnostics({}, workspaceRun, hydration.hydrationQualifications) + .sessionWorkspaceMatch; + assert.equal(diagnostics.preflight.qualified.directCwd, 1); + assert.equal(diagnostics.preflight.qualified.rootCwd, 1); + assert.equal(diagnostics.hydration.omitted.mixedActivity, 1); + assert.doesNotMatch(JSON.stringify(diagnostics), /repo|packages|private|\.ts/u); +}); + +test("shared runner leaves an inventory untouched when topology is absent", async () => { + const sessions = [{ sessionId: "legacy", workspace: "/repo/packages/app" }]; + let reads = 0; + const workspaceRun = await qualifyWorkspaceSessionInventory({ + analyzer: { async readSession() { reads += 1; return []; } }, + sessions, + scope: { workspace: "/repo/packages/app", _workspaceMatchScope: null }, + }); + + assert.equal(workspaceRun.enabled, false); + assert.equal(workspaceRun.sessions, sessions); + assert.equal(reads, 0); + assert.equal(withWorkspaceMatchDiagnostics({ ok: true }, workspaceRun).sessionWorkspaceMatch, undefined); +}); + +test("show and events recheck root sessions after full hydration", async () => { + const topology = memberTopology("/repo", "/repo/packages/app"); + const workspaceScope = workspaceMatchScopeFromOptions({ topology }); + const session = bindSessionWorkspaceCwds({ + sessionId: "root-private", + workspaceMatch: "root-cwd", + }, ["/repo"]); + const analyzer = { + async analyze() { + return withWorkspaceMatchDiagnostics({ sessions: [session] }, { + enabled: true, + qualifications: [{ qualified: true, workspaceMatch: "root-cwd" }], + }); + }, + async resolveScope() { + return { workspace: topology.requestedWorkspace, _workspaceMatchScope: workspaceScope }; + }, + async readSession(_session, _scope, options) { + assert.equal(options.workspacePreflightMaxLines, undefined); + return markSessionReadCoverage([ + { cwd: "/repo", filePath: "packages/app/src/a.ts" }, + { cwd: "/repo", filePath: "packages/other/src/b.ts" }, + ], { truncated: false }); + }, + mergeSession(events, source) { + return { ...source, eventCount: events.length }; + }, + }; + + const result = await runProviderCommand(analyzer, "events", { + workspace: topology.requestedWorkspace, + topology, + workspacePreflightMaxLines: 1, + "session-id": session.sessionId, + }); + + assert.deepEqual(result.sessions, []); + assert.equal(result.sessionWorkspaceMatch.hydration.omitted.mixedActivity, 1); + assert.doesNotMatch(JSON.stringify(result.sessionWorkspaceMatch), /repo|packages|private|\.ts/u); +}); + +test("Codex admits direct and target-only root sessions while omitting mixed and prompt-only roots", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-codex-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".codex"); + const sessionRoot = path.join(home, "sessions", "2026", "07", "20"); + const topology = memberTopology(gitRoot, workspace); + await writeJsonl(path.join(sessionRoot, "direct-private.jsonl"), codexRows("direct-private", workspace)); + await writeJsonl( + path.join(sessionRoot, "root-target-private.jsonl"), + codexRows("root-target-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ); + await writeJsonl( + path.join(sessionRoot, "root-mixed-private.jsonl"), + codexRows("root-mixed-private", gitRoot, [path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeJsonl( + path.join(sessionRoot, "root-prompt-private.jsonl"), + codexRows("root-prompt-private", gitRoot, [], `Please inspect ${path.join(workspace, "src", "prompt-only.ts")}`), + ); + + const analyzer = new CodexSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); + + const bounded = await analyzer.analyze({ + command: "sessions", + workspace, + home, + topology, + workspacePreflightMaxLines: 1, + }); + assert.deepEqual(bounded.sessions.map((session) => session.sessionId), ["direct-private"]); + assert.equal(bounded.sessionWorkspaceMatch.preflight.omitted.truncatedPreflight, 3); + + const facts = await analyzer.analyze({ + command: "facts", + workspace, + home, + topology, + selection: "all-eligible", + until: "2026-07-21T00:00:00.000Z", + }); + assert.equal(facts.sessionWorkspaceMatch.preflight.qualified.rootCwd, 1); + assert.doesNotMatch( + JSON.stringify(facts.sessionWorkspaceMatch), + new RegExp(`${path.basename(workspace)}|${path.basename(sibling)}|private`, "u"), + ); +}); + +test("Qoder scans only target and Git-root transcript identities and keeps legacy discovery unchanged", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-qoder-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".qoder"); + const topology = memberTopology(gitRoot, workspace); + const targetRoot = path.join(home, "projects", workspaceToQoderSlug(workspace)); + const repositoryRoot = path.join(home, "projects", workspaceToQoderSlug(gitRoot)); + const siblingRoot = path.join(home, "projects", workspaceToQoderSlug(sibling)); + await writeJsonl(path.join(targetRoot, "direct-private.jsonl"), qoderRows("direct-private", workspace)); + await writeJsonl( + path.join(repositoryRoot, "root-target-private.jsonl"), + qoderRows("root-target-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ); + await writeJsonl( + path.join(repositoryRoot, "root-mixed-private.jsonl"), + qoderRows("root-mixed-private", gitRoot, [path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeJsonl( + path.join(repositoryRoot, "root-prompt-private.jsonl"), + qoderRows("root-prompt-private", gitRoot, [], `Read ${path.join(workspace, "prompt-only.ts")}`), + ); + await writeJsonl( + path.join(siblingRoot, "sibling-private.jsonl"), + qoderRows("sibling-private", sibling, [path.join(workspace, "should-not-join.ts")]), + ); + + const analyzer = new QoderSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessions.some((session) => session.sessionId === "sibling-private"), false); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); + + const facts = await analyzer.analyze({ + command: "facts", + workspace, + home, + topology, + selection: "all-eligible", + until: "2026-07-21T00:00:00.000Z", + }); + assert.equal(facts.sessionWorkspaceMatch.preflight.qualified.rootCwd, 1); + assert.doesNotMatch( + JSON.stringify(facts.sessionWorkspaceMatch), + new RegExp(`${path.basename(workspace)}|${path.basename(sibling)}|private`, "u"), + ); +}); + +test("Claude binds all transcript CWDs and rejects prompt-only, mixed, or conflicting root activity", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-claude-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".claude"); + const topology = memberTopology(gitRoot, workspace); + const targetRoot = path.join(home, "projects", workspaceToClaudeSlugVariants(workspace)[0]); + const repositoryRoot = path.join(home, "projects", workspaceToClaudeSlugVariants(gitRoot)[0]); + const siblingRoot = path.join(home, "projects", workspaceToClaudeSlugVariants(sibling)[0]); + await writeJsonl(path.join(targetRoot, "direct-private.jsonl"), claudeRows("direct-private", workspace)); + await writeJsonl( + path.join(repositoryRoot, "root-target-private.jsonl"), + claudeRows("root-target-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ); + await writeJsonl( + path.join(repositoryRoot, "root-mixed-private.jsonl"), + claudeRows("root-mixed-private", gitRoot, [path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeJsonl( + path.join(repositoryRoot, "root-prompt-private.jsonl"), + claudeRows("root-prompt-private", gitRoot, [], `Read ${path.join(workspace, "prompt-only.ts")}`), + ); + await writeJsonl( + path.join(repositoryRoot, "root-conflicting-cwd-private.jsonl"), + [ + ...claudeRows("root-conflicting-cwd-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ...claudeRows("root-conflicting-cwd-private", sibling).slice(0, 1), + ], + ); + await writeJsonl( + path.join(siblingRoot, "sibling-private.jsonl"), + claudeRows("sibling-private", sibling, [path.join(workspace, "should-not-join.ts")]), + ); + + const analyzer = new ClaudeSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessions.some((session) => session.sessionId === "sibling-private"), false); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.unmatchedCwd, 1); + + const bounded = await analyzer.analyze({ + command: "sessions", + workspace, + home, + topology, + workspacePreflightMaxLines: 1, + }); + assert.deepEqual(bounded.sessions.map((session) => session.sessionId), ["direct-private"]); + assert.equal(bounded.sessionWorkspaceMatch.preflight.omitted.truncatedPreflight, 2); + assert.equal(bounded.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); + + await writeJsonl( + path.join(repositoryRoot, "root-audit-mixed-private.jsonl"), + claudeRows("root-audit-mixed-private", gitRoot, [path.join(workspace, "src", "audit-target.ts")]), + ); + await writeJsonl(path.join(home, "audit", "audit.jsonl"), [{ + event: "tool_input", + session_id: "root-audit-mixed-private", + timestamp: "2026-07-20T10:10:00.000Z", + cwd: gitRoot, + toolName: "Read", + toolUseId: "audit-read-private", + input: { file_path: path.join(sibling, "audit-sibling.ts") }, + }]); + + const facts = await analyzer.analyze({ + command: "facts", + workspace, + home, + topology, + selection: "all-eligible", + until: "2026-07-21T00:00:00.000Z", + }); + assert.equal(facts.sessionWorkspaceMatch.preflight.qualified.rootCwd, 2); + assert.equal(facts.sessionWorkspaceMatch.hydration.omitted.mixedActivity, 1); + assert.doesNotMatch( + JSON.stringify(facts.sessionWorkspaceMatch), + new RegExp(`${path.basename(workspace)}|${path.basename(sibling)}|private`, "u"), + ); +}); + +test("Cursor requires chat metadata CWD and does not scan sibling transcript identities", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-cursor-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".cursor"); + const topology = memberTopology(gitRoot, workspace); + const transcriptPath = (identity, sessionId) => path.join( + home, + "projects", + workspaceToCursorSlugVariants(identity)[0], + "agent-transcripts", + sessionId, + `${sessionId}.jsonl`, + ); + await writeJsonl(transcriptPath(workspace, "direct-private"), cursorRows()); + await writeJsonl( + transcriptPath(gitRoot, "root-target-private"), + cursorRows([path.join(workspace, "src", "a.ts")]), + ); + await writeJsonl( + transcriptPath(gitRoot, "root-mixed-private"), + cursorRows([path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeJsonl( + transcriptPath(gitRoot, "root-prompt-private"), + cursorRows([], `Read ${path.join(workspace, "prompt-only.ts")}`), + ); + await writeJsonl( + transcriptPath(gitRoot, "root-no-meta-private"), + cursorRows([path.join(workspace, "src", "no-meta.ts")]), + ); + await writeJsonl( + transcriptPath(sibling, "sibling-private"), + cursorRows([path.join(workspace, "should-not-join.ts")]), + ); + await writeCursorMeta(home, "direct-private", workspace); + await writeCursorMeta(home, "root-target-private", gitRoot); + await writeCursorMeta(home, "root-mixed-private", gitRoot); + await writeCursorMeta(home, "root-prompt-private", gitRoot); + await writeCursorMeta(home, "sibling-private", sibling); + + const analyzer = new CursorSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessions.some((session) => session.sessionId === "sibling-private"), false); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.unmatchedCwd, 1); + + const bounded = await analyzer.analyze({ + command: "sessions", + workspace, + home, + topology, + workspacePreflightMaxLines: 1, + }); + assert.deepEqual(bounded.sessions.map((session) => session.sessionId), ["direct-private"]); + assert.equal(bounded.sessionWorkspaceMatch.preflight.omitted.truncatedPreflight, 2); + assert.equal(bounded.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); + + await writeJsonl( + transcriptPath(gitRoot, "root-audit-mixed-private"), + cursorRows([path.join(workspace, "src", "audit-target.ts")]), + ); + await writeCursorMeta(home, "root-audit-mixed-private", gitRoot); + await writeJsonl(path.join(home, "audit", "audit.jsonl"), [{ + _event: "preToolUse", + session_id: "root-audit-mixed-private", + conversation_id: "root-audit-mixed-private", + timestamp: "2026-07-20T10:10:00.000Z", + cwd: gitRoot, + tool_name: "Read", + tool_use_id: "audit-read-private", + tool_input: { file_path: path.join(sibling, "audit-sibling.ts") }, + }]); + + const facts = await analyzer.analyze({ + command: "facts", + workspace, + home, + topology, + selection: "all-eligible", + until: "2026-07-21T00:00:00.000Z", + }); + assert.equal(facts.sessionWorkspaceMatch.preflight.qualified.rootCwd, 2); + assert.equal(facts.sessionWorkspaceMatch.hydration.omitted.mixedActivity, 1); + assert.doesNotMatch( + JSON.stringify(facts.sessionWorkspaceMatch), + new RegExp(`${path.basename(workspace)}|${path.basename(sibling)}|private`, "u"), + ); +}); diff --git a/test/task-loop-report.test.mjs b/test/task-loop-report.test.mjs index d69f55b..3b4df9e 100644 --- a/test/task-loop-report.test.mjs +++ b/test/task-loop-report.test.mjs @@ -195,6 +195,42 @@ async function withTempDir(fn) { } } +function memberTopology(gitRoot, memberRoute) { + const members = ["packages/a", "packages/b"].map((route) => ({ + route, + kind: "manifest", + discoveredBy: ["package.json"], + })); + return { + kind: "better-harness.workspace-topology", + schemaVersion: 1, + status: "complete", + requestedWorkspace: path.join(gitRoot, ...memberRoute.split("/")), + gitRoot, + target: { + kind: "workspace-member", + route: memberRoute, + memberRoute, + memberMatch: "exact", + }, + members: { + items: members, + total: members.length, + omitted: 0, + truncated: false, + }, + instructionScopes: { items: [], total: 0, omitted: 0, truncated: false }, + discovery: { + tracked: 2, + untracked: 0, + scanned: 2, + omitted: 0, + truncated: false, + warnings: [], + }, + }; +} + function reportSource({ includeControlledOutcome = true, includeLifecycleDecision = includeControlledOutcome, @@ -1217,7 +1253,7 @@ test("task-loop projection carries AI-reviewed dimension scores with evidence", assert.equal(findings.summary.modelId, "agent-work-loop-v4"); assert.equal(findings.summary.locale, "en"); - assert.equal(findings.summary.reportContractVersion, 25); + assert.equal(findings.summary.reportContractVersion, 26); assert.deepEqual(findings.summary.assignmentSummaries, []); assert.equal(findings.summary.evidenceMode, "session-rich"); assert.equal(findings.summary.overview, "Protected changes still need linked delivery proof."); @@ -1825,7 +1861,7 @@ test("Chinese Bavi projection localizes all five dimensions, fifteen checks, ben const subdimensions = dimensions.flatMap((dimension) => dimension.subdimensions); assert.equal(findings.summary.modelId, "agent-work-loop-v4"); - assert.equal(findings.summary.reportContractVersion, 25); + assert.equal(findings.summary.reportContractVersion, 26); assert.equal(findings.summary.locale, "zh-CN"); assert.equal(findings.summary.overview, "路径在真实任务中用过,但还没有关联与该问题相符的结果。"); assert.deepEqual(dimensions.map((dimension) => dimension.label), ["任务理解", "可控执行", "改动验证", "可靠交付", "经验沉淀"]); @@ -2756,6 +2792,201 @@ test("render merge rejects modified analyzer-owned Canvas facts", () => { ); }); +test("task-loop findings preserve structured package targets through split and merge", () => { + const source = reportSource(); + const target = { + kind: "workspace-member", + packageRoute: "packages/app", + ownerRoute: "packages/app", + }; + source.repositoryEvidence.findingTarget = target; + + const full = projectTaskLoopFindings(source); + assert.ok(full.findings.length > 0); + assert.ok(full.findings.every((finding) => ( + finding.target.kind === target.kind + && finding.target.packageRoute === target.packageRoute + && finding.target.ownerRoute === target.ownerRoute + ))); + assert.deepEqual(validateTaskLoopFindings(full), []); + + const split = splitTaskLoopFindings(full); + assert.ok(split.findings.findings.every((finding) => finding.target.packageRoute === "packages/app")); + assert.ok(split.canvas.findings.every((finding) => !Object.hasOwn(finding, "target"))); + const merged = mergeTaskLoopCanvasData(split.findings, split.canvas); + assert.ok(merged.findings.every((finding) => finding.target.ownerRoute === "packages/app")); +}); + +test("practice findings preserve root and package owners through final projection", () => { + const topology = memberTopology(path.resolve("/tmp/better-harness-owner-projection"), "packages/a"); + const practice = projectAgentLintPracticeEvidence({ + topology, + instructionReview: { + profile: "agents-md-review", + findings: [{ + id: "missing-local-reference", + severity: "warning", + file: "AGENTS.md", + packageRoute: ".", + ownerRoute: ".", + }, { + id: "missing-local-reference", + severity: "warning", + file: "packages/a/AGENTS.md", + packageRoute: "packages/a", + ownerRoute: "packages/a", + }], + }, + }); + + assert.equal(practice.findings.length, 2); + assert.deepEqual(practice.findings.map((finding) => finding.target.ownerRoute), [".", "packages/a"]); + assert.notEqual(practice.findings[0].id, practice.findings[1].id); + + const source = reportSource({ includeRepositoryFinding: false, interventionLedger: [] }); + source.repositoryEvidence.findingTarget = { + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: "packages/a", + }; + source.repositoryEvidence.findings = practice.findings; + const projected = projectTaskLoopFindings(source); + + assert.deepEqual(projected.findings.map((finding) => finding.target.ownerRoute), [".", "packages/a"]); + assert.deepEqual(validateTaskLoopFindings(projected), []); + + source.repositoryEvidence.findings[0].target = null; + const invalid = projectTaskLoopFindings(source); + assert.equal(invalid.findings[0].target, null); + assert.match(validateTaskLoopFindings(invalid).join("; "), /target must be an object/u); +}); + +test("record-fix-output rejects a finding bound to a sibling package", async () => { + await withTempDir(async (root) => { + const workspace = path.join(root, "packages", "b"); + const runDir = path.join(workspace, ".qoder", "better-harness", "run"); + const findingsPath = path.join(runDir, "findings.json"); + const resultPath = path.join(root, "result.json"); + const source = reportSource(); + source.repositoryEvidence.findingTarget = { + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: "packages/a", + }; + const split = splitTaskLoopFindings(projectTaskLoopFindings(source)); + await mkdir(runDir, { recursive: true }); + await writeFile(findingsPath, `${JSON.stringify(split.findings, null, 2)}\n`); + await writeFile(path.join(runDir, "canvas.json"), `${JSON.stringify(split.canvas, null, 2)}\n`); + await writeFile(resultPath, JSON.stringify({ + actualOutput: [], + assignmentSummary: assignmentSummary(), + })); + + await assert.rejects(recordFixOutput({ + workspace, + findings: findingsPath, + findingId: split.findings.findings[0].id, + expectedRevision: 0, + result: resultPath, + topology: memberTopology(root, "packages/b"), + }), (error) => error?.code === "FINDING_TARGET_MISMATCH" + && /packageRoute does not match/u.test(error.message)); + }); +}); + +test("record-fix-output rejects ownerless package targets and partial topology", async () => { + await withTempDir(async (root) => { + const workspace = path.join(root, "packages", "a"); + const runDir = path.join(workspace, ".qoder", "better-harness", "run"); + const findingsPath = path.join(runDir, "findings.json"); + const resultPath = path.join(root, "result.json"); + const source = reportSource(); + source.repositoryEvidence.findingTarget = { + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: null, + }; + const split = splitTaskLoopFindings(projectTaskLoopFindings(source)); + await mkdir(runDir, { recursive: true }); + await writeFile(findingsPath, `${JSON.stringify(split.findings, null, 2)}\n`); + await writeFile(path.join(runDir, "canvas.json"), `${JSON.stringify(split.canvas, null, 2)}\n`); + await writeFile(resultPath, JSON.stringify({ + actualOutput: [], + assignmentSummary: assignmentSummary(), + })); + + await assert.rejects(recordFixOutput({ + workspace, + findings: findingsPath, + findingId: split.findings.findings[0].id, + expectedRevision: 0, + result: resultPath, + topology: memberTopology(root, "packages/a"), + }), (error) => error?.code === "FINDING_TARGET_MISMATCH" + && /ownerRoute is required/u.test(error.message)); + + for (const finding of split.findings.findings) finding.target.ownerRoute = "packages/a"; + await writeFile(findingsPath, `${JSON.stringify(split.findings, null, 2)}\n`); + const partialTopology = { + ...memberTopology(root, "packages/a"), + status: "partial", + }; + await assert.rejects(recordFixOutput({ + workspace, + findings: findingsPath, + findingId: split.findings.findings[0].id, + expectedRevision: 0, + result: resultPath, + topology: partialTopology, + }), (error) => error?.code === "FINDING_TARGET_TOPOLOGY_INCOMPLETE"); + }); +}); + +test("record-fix-output records an ancestor-owned result from a package callback", async () => { + await withTempDir(async (root) => { + const workspace = path.join(root, "packages", "a"); + const runDir = path.join(workspace, ".qoder", "better-harness", "run"); + const findingsPath = path.join(runDir, "findings.json"); + const resultPath = path.join(root, "result.json"); + await mkdir(runDir, { recursive: true }); + await writeFile(path.join(root, "AGENTS.md"), "# Root owner\n"); + const source = reportSource(); + source.repositoryEvidence.findingTarget = { + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: ".", + }; + const split = splitTaskLoopFindings(projectTaskLoopFindings(source)); + await writeFile(findingsPath, `${JSON.stringify(split.findings, null, 2)}\n`); + await writeFile(path.join(runDir, "canvas.json"), `${JSON.stringify(split.canvas, null, 2)}\n`); + const finding = split.findings.findings[0]; + await writeFile(resultPath, JSON.stringify({ + actualOutput: [{ + action: "updated", + artifact: finding.expectedArtifact, + name: "Root Agent guidance", + scope: "Project", + path: "AGENTS.md", + summary: "Updated the inherited root guidance and verified the package workflow.", + }], + assignmentSummary: assignmentSummary(), + })); + + const recorded = await recordFixOutput({ + workspace, + findings: findingsPath, + findingId: finding.id, + expectedRevision: 0, + result: resultPath, + topology: memberTopology(root, "packages/a"), + }); + + assert.equal(recorded.status, "pass"); + const updated = JSON.parse(await readFile(findingsPath, "utf8")); + assert.equal(updated.findings[0].actualOutput[0].path, "AGENTS.md"); + }); +}); + test("record-fix-output replaces one latest result and increments its revision", async () => { await withTempDir(async (root) => { const workspace = path.join(root, "workspace"); diff --git a/test/task-loop-source.test.mjs b/test/task-loop-source.test.mjs index fc74a3f..17a3812 100644 --- a/test/task-loop-source.test.mjs +++ b/test/task-loop-source.test.mjs @@ -563,6 +563,7 @@ test("default practice projection keeps effective global hooks and installed plu { surface: "Rules", scopes: ["Project"], count: 1 }, { surface: "Hooks", scopes: ["Global"], count: 11 }, { surface: "Plugins", scopes: ["Plugin"], count: 10 }, + { surface: "Skills", scopes: ["Inherited"], count: 2 }, { surface: "MCP", scopes: ["Global"], count: 3 }, { surface: "Skills", scopes: ["Global"], count: 20 }, { surface: "Memories", scopes: ["Project"], count: 4 }, @@ -572,11 +573,11 @@ test("default practice projection keeps effective global hooks and installed plu assert.deepEqual( projectPracticeCoverageRows(inventory).map((row) => row.surface), - ["Rules", "Hooks", "Plugins", "Memories", "Custom Agents"], + ["Rules", "Hooks", "Plugins", "Skills", "Memories", "Custom Agents"], ); const customAgents = projectPracticeCoverageRows(inventory).find((row) => row.surface === "Custom Agents"); assert.deepEqual(customAgents, { surface: "Custom Agents", scopes: ["Project"], count: 0, paths: [] }); - assert.equal(projectPracticeCoverageRows(inventory, true).length, 7); + assert.equal(projectPracticeCoverageRows(inventory, true).length, 8); }); test("session source bridge projects only relevant change validation", () => { diff --git a/test/workspace-topology.test.mjs b/test/workspace-topology.test.mjs new file mode 100644 index 0000000..d77ed64 --- /dev/null +++ b/test/workspace-topology.test.mjs @@ -0,0 +1,422 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { main as topologyMain } from "../scripts/workspace-topology/cli.mjs"; +import { + WORKSPACE_TOPOLOGY_KIND, + findingTargetErrors, + findingTargetFromTopology, + ownerRouteForPath, + resolveWorkspaceTopology, + validateWorkspaceTopology, +} from "../scripts/workspace-topology/index.mjs"; + +function git(cwd, args) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed\n${result.stderr}`); + } + return result.stdout.trim(); +} + +async function writeFixtureFile(root, route, content = "") { + const absolute = path.join(root, ...route.split("/")); + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, content); +} + +async function makeGitRepo(files, afterCommit = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-topology-")); + git(root, ["init", "-q"]); + git(root, ["config", "user.email", "test@example.com"]); + git(root, ["config", "user.name", "Test User"]); + for (const [route, content] of Object.entries(files)) { + await writeFixtureFile(root, route, content); + } + git(root, ["add", "."]); + git(root, ["commit", "-q", "-m", "fixture"]); + for (const [route, content] of Object.entries(afterCommit)) { + await writeFixtureFile(root, route, content); + } + return root; +} + +function memberRoutes(result) { + return result.topology.members.items.map((member) => member.route); +} + +test("workspace topology resolves manifest members, instruction scopes, ignored files, and package targets", async () => { + const repo = await makeGitRepo({ + ".gitignore": ".build/\nout*/\n", + "package.json": JSON.stringify({ + workspaces: ["packages/*", "!packages/excluded"], + }), + "AGENTS.md": "# root\n", + "CLAUDE.local.md": "# root local Claude\n", + ".claude/rules/root.mdc": "# root Claude rule\n", + ".cursor/rules/root.md": "# root Cursor rule\n", + ".qoder/rules/root.mdc": "# root Qoder rule\n", + ".github/copilot-instructions.md": "# root Copilot instructions\n", + ".ci/AGENTS.md": "# operational scope\n", + "packages/a/package.json": JSON.stringify({ name: "a" }), + "packages/a/AGENTS.md": "# package a\n", + "packages/a/.claude/rules/local.md": "# package Claude rule\n", + "packages/a/.cursor/rules/local.mdc": "# package Cursor rule\n", + "packages/a/.qoder/rules/local.mdc": "# package Qoder rule\n", + "packages/a/src/index.ts": "export const a = true;\n", + "packages/b/package.json": JSON.stringify({ name: "b" }), + "packages/excluded/package.json": JSON.stringify({ name: "excluded" }), + "extensions/chat/AGENTS.md": "# convention member\n", + "src/product/AGENTS.md": "# product member\n", + "cli/AGENTS.md": "# cli member\n", + "build/source.ts": "export const trackedBuildSource = true;\n", + }, { + ".build/extensions/chat/AGENTS.md": "# ignored copy\n", + "out-build/packages/a/AGENTS.md": "# ignored output\n", + "packages/c/package.json": JSON.stringify({ name: "c" }), + "packages/c/AGENTS.md": "# untracked package\n", + }); + + try { + const rootResult = await resolveWorkspaceTopology({ workspace: repo }); + assert.equal(rootResult.topology.kind, WORKSPACE_TOPOLOGY_KIND); + assert.equal(rootResult.topology.status, "complete"); + assert.equal(rootResult.topology.target.kind, "repo-root"); + assert.deepEqual(memberRoutes(rootResult), [ + "cli", + "extensions/chat", + "packages/a", + "packages/b", + "packages/c", + "src/product", + ]); + assert.ok(!memberRoutes(rootResult).includes(".ci")); + assert.ok(!memberRoutes(rootResult).includes("packages/excluded")); + assert.ok(rootResult.inventory.items.some((item) => + item.route === "build/source.ts" && item.provenance === "tracked")); + assert.ok(rootResult.inventory.items.some((item) => + item.route === "packages/c/package.json" && item.provenance === "untracked")); + assert.ok(rootResult.inventory.items.every((item) => + !item.route.startsWith(".build/") && !item.route.startsWith("out-build/"))); + assert.ok(rootResult.topology.instructionScopes.items.some((item) => + item.route === "AGENTS.md" && item.provider === "qoder" && item.activation === "effective")); + assert.ok(rootResult.topology.instructionScopes.items.some((item) => + item.route === "packages/a/AGENTS.md" + && item.provider === "qoder" + && item.activation === "candidate")); + for (const [route, provider] of [ + ["CLAUDE.local.md", "claude"], + [".claude/rules/root.mdc", "claude"], + [".cursor/rules/root.md", "cursor"], + [".qoder/rules/root.mdc", "qoder"], + [".github/copilot-instructions.md", "cursor"], + ["packages/a/.claude/rules/local.md", "claude"], + ["packages/a/.cursor/rules/local.mdc", "cursor"], + ["packages/a/.qoder/rules/local.mdc", "qoder"], + ]) { + assert.ok(rootResult.topology.instructionScopes.items.some((item) => + item.route === route && item.provider === provider), `${provider}:${route}`); + } + + const packageResult = await resolveWorkspaceTopology({ + workspace: path.join(repo, "packages/a"), + }); + assert.deepEqual(packageResult.topology.target, { + kind: "workspace-member", + route: "packages/a", + memberRoute: "packages/a", + memberMatch: "exact", + }); + assert.deepEqual(packageResult.analysisScope, { + kind: "path", + route: "packages/a", + pathspecs: [":(top,literal)packages/a"], + }); + assert.equal(ownerRouteForPath(packageResult.topology, "packages/a/src/index.ts"), "packages/a"); + assert.equal(ownerRouteForPath(packageResult.topology, "packages/b/package.json"), "packages/b"); + const findingTarget = findingTargetFromTopology(packageResult.topology); + assert.deepEqual(findingTarget, { + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: "packages/a", + }); + assert.deepEqual(findingTargetErrors(findingTarget, { + topology: packageResult.topology, + required: true, + }), []); + assert.throws( + () => findingTargetFromTopology(packageResult.topology, { ownerRoute: null }), + /ownerRoute is required/u, + ); + assert.match(findingTargetErrors({ + ...findingTarget, + ownerRoute: "packages/b", + }, { + topology: packageResult.topology, + }).join("; "), /outside the frozen target route/u); + assert.match(findingTargetErrors({ + ...findingTarget, + ownerRoute: ".", + }, { + topology: rootResult.topology, + }).join("; "), /kind does not match/u); + validateWorkspaceTopology(packageResult.topology); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("finding target rejects traversal and cross-package topology mismatches", () => { + assert.match(findingTargetErrors(null).join("; "), /must be an object/u); + assert.match(findingTargetErrors({ + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: null, + }, { + requireOwnerRoute: true, + }).join("; "), /ownerRoute is required/u); + + const target = { + kind: "workspace-member", + packageRoute: "../packages/a", + ownerRoute: "packages/a", + }; + assert.match(findingTargetErrors(target).join("; "), /must not escape its root/u); + + const topology = { + kind: WORKSPACE_TOPOLOGY_KIND, + schemaVersion: 1, + status: "complete", + requestedWorkspace: path.resolve("packages/b"), + gitRoot: path.resolve("."), + target: { + kind: "workspace-member", + route: "packages/b", + memberRoute: "packages/b", + memberMatch: "exact", + }, + members: { + items: [ + { route: "packages/a", kind: "manifest", discoveredBy: ["package.json"] }, + { route: "packages/b", kind: "manifest", discoveredBy: ["package.json"] }, + ], + total: 2, + omitted: 0, + truncated: false, + }, + instructionScopes: { items: [], total: 0, omitted: 0, truncated: false }, + discovery: { + tracked: 1, + untracked: 0, + scanned: 1, + omitted: 0, + truncated: false, + warnings: [], + }, + }; + assert.match(findingTargetErrors({ + kind: "workspace-member", + packageRoute: "packages/a", + ownerRoute: "packages/a", + }, { topology }).join("; "), /packageRoute does not match/u); +}); + +test("workspace topology combines pnpm, Go, and Cargo members with exclusions", async () => { + const repo = await makeGitRepo({ + "pnpm-workspace.yaml": "packages:\n - 'web/*'\n", + "web/app/package.json": JSON.stringify({ name: "web-app" }), + "go.work": "go 1.23\n\nuse (\n ./go/service\n)\n", + "go/service/go.mod": "module example.com/service\n", + "Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/excluded\"]\n", + "crates/core/Cargo.toml": "[package]\nname = \"core\"\nversion = \"0.1.0\"\n", + "crates/excluded/Cargo.toml": "[package]\nname = \"excluded\"\nversion = \"0.1.0\"\n", + }); + + try { + const result = await resolveWorkspaceTopology({ workspace: repo }); + assert.deepEqual(memberRoutes(result), [ + "crates/core", + "go/service", + "web/app", + ]); + assert.equal(result.topology.members.items.find((member) => member.route === "crates/core").kind, "manifest"); + assert.ok(result.topology.members.items.find((member) => + member.route === "go/service").discoveredBy.some((source) => source === "go.work")); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("workspace topology ignores escaping workspace patterns and reports partial coverage", async () => { + const repo = await makeGitRepo({ + "package.json": JSON.stringify({ + workspaces: ["packages/*", "../outside/*"], + }), + "packages/app/package.json": JSON.stringify({ name: "app" }), + }); + + try { + const result = await resolveWorkspaceTopology({ workspace: repo }); + assert.deepEqual(memberRoutes(result), ["packages/app"]); + assert.equal(result.topology.status, "partial"); + assert.ok(result.topology.discovery.warnings.some((item) => + item.code === "workspace-pattern-outside-root" && item.route === "package.json")); + assert.match(findingTargetErrors({ + kind: "repo-root", + packageRoute: null, + ownerRoute: ".", + }, { topology: result.topology }).join("; "), /requires complete workspace topology/u); + assert.throws( + () => findingTargetFromTopology(result.topology), + (error) => error?.code === "INVALID_FINDING_TARGET" + && /requires complete workspace topology/u.test(error.message), + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("workspace topology distinguishes repo subtrees and standalone targets", async () => { + const repo = await makeGitRepo({ + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "packages/app/package.json": JSON.stringify({ name: "app" }), + "docs/guide.md": "# Guide\n", + }); + const standalone = await mkdtemp(path.join(os.tmpdir(), "better-harness-standalone-")); + await writeFixtureFile(standalone, "src/index.ts", "export const value = true;\n"); + + try { + const subtree = await resolveWorkspaceTopology({ workspace: path.join(repo, "docs") }); + assert.deepEqual(subtree.topology.target, { + kind: "repo-subtree", + route: "docs", + memberRoute: null, + memberMatch: "none", + }); + + const isolated = await resolveWorkspaceTopology({ workspace: standalone }); + assert.equal(isolated.topology.gitRoot, null); + assert.equal(isolated.topology.target.kind, "standalone"); + assert.deepEqual(isolated.analysisScope, { kind: "repo", route: ".", pathspecs: [] }); + assert.equal(isolated.topology.discovery.inventoryMode, "filesystem-fallback"); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(standalone, { recursive: true, force: true }); + } +}); + +test("workspace topology preserves target ownership while reporting bounded inventory", async () => { + const repo = await makeGitRepo({ + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "aaa.txt": "a\n", + "bbb.txt": "b\n", + "packages/late/package.json": JSON.stringify({ name: "late" }), + "packages/late/src/index.ts": "export const late = true;\n", + }); + + try { + const result = await resolveWorkspaceTopology({ + workspace: path.join(repo, "packages/late"), + maxFiles: 2, + }); + assert.equal(result.topology.status, "partial"); + assert.equal(result.topology.discovery.truncated, true); + assert.ok(result.topology.discovery.omitted > 0); + assert.ok(result.inventory.items.length <= 2); + assert.equal( + result.topology.discovery.scanned + result.topology.discovery.omitted, + result.topology.discovery.tracked + result.topology.discovery.untracked, + ); + assert.ok(result.topology.discovery.warnings.some((item) => + item.code === "inventory-count-lower-bound")); + assert.equal(result.topology.target.memberRoute, "packages/late"); + assert.ok(memberRoutes(result).includes("packages/late")); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + +test("workspace topology resolves a symlinked package through its real path", async () => { + const repo = await makeGitRepo({ + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "packages/app/package.json": JSON.stringify({ name: "app" }), + }); + const links = await mkdtemp(path.join(os.tmpdir(), "better-harness-topology-links-")); + const link = path.join(links, "app-link"); + await symlink(path.join(repo, "packages/app"), link, "dir"); + + try { + const result = await resolveWorkspaceTopology({ workspace: link }); + assert.equal(result.topology.requestedWorkspace, await realpath(path.join(repo, "packages/app"))); + assert.equal(result.topology.target.memberRoute, "packages/app"); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(links, { recursive: true, force: true }); + } +}); + +test("workspace topology does not follow a tracked structural symlink outside the Git root", async () => { + const repo = await makeGitRepo({ + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "packages/app/package.json": JSON.stringify({ name: "app" }), + }); + const outside = await mkdtemp(path.join(os.tmpdir(), "better-harness-topology-outside-")); + await writeFixtureFile(outside, "package.json", JSON.stringify({ name: "outside" })); + await mkdir(path.join(repo, "packages", "escape"), { recursive: true }); + await symlink( + path.join(outside, "package.json"), + path.join(repo, "packages", "escape", "package.json"), + ); + git(repo, ["add", "packages/escape/package.json"]); + git(repo, ["commit", "-q", "-m", "add structural symlink fixture"]); + + try { + const result = await resolveWorkspaceTopology({ workspace: repo }); + assert.equal(memberRoutes(result).includes("packages/escape"), false); + assert.equal(result.topology.status, "partial"); + assert.ok(result.topology.discovery.warnings.some((item) => + item.code === "structure-entry-unsafe" && item.route === "packages/escape/package.json")); + } finally { + await rm(repo, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + +test("workspace topology CLI help avoids probing and JSON output is parser-safe", async () => { + let called = false; + let help = ""; + const helpStatus = await topologyMain(["--help"], { + resolveWorkspaceTopology: async () => { + called = true; + throw new Error("must not run"); + }, + stdout: { write: (value) => { help += value; } }, + }); + assert.equal(helpStatus, 0); + assert.equal(called, false); + assert.match(help, /--workspace/); + + const repo = await makeGitRepo({ "README.md": "# fixture\n" }); + try { + const result = spawnSync(process.execPath, [ + path.join(process.cwd(), "scripts/workspace-topology/cli.mjs"), + "--workspace", + repo, + "--json", + ], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.topology.target.kind, "repo-root"); + assert.equal(parsed.analysisScope.kind, "repo"); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); From cb957fb4360e7c437fe9252ceb384825b50b81dc Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 30 Jul 2026 08:32:06 +0800 Subject: [PATCH 2/5] fix(harness): canonicalize workspace path identity Canonicalize both sides of filesystem identity checks so symlink, case, and Windows 8.3 path aliases do not escape package scope or reject a valid render target. Parse explicit topology CLI boolean values strictly instead of relying on string truthiness. Updates docs/specs/2026-07-25-monorepo-workspace-support.md with AC13. The focused regressions passed 102/102, the full suite passed 926/926, and pack, doc-link, and preview smoke checks passed. Co-authored-by: Codex (GPT 5.6 Sol) --- .../2026-07-25-monorepo-workspace-support.md | 19 +++++++++++ scripts/core-change-watch/common.mjs | 24 +++++++++++--- scripts/harness-analysis/render-report.mjs | 14 ++++++-- scripts/workspace-topology/cli.mjs | 15 ++++++++- test/core-change-watch-scope.test.mjs | 26 ++++++++++++++- test/harness-report-render-cli.test.mjs | 29 +++++++++++++++- test/workspace-topology.test.mjs | 33 ++++++++++++++++++- 7 files changed, 148 insertions(+), 12 deletions(-) diff --git a/docs/specs/2026-07-25-monorepo-workspace-support.md b/docs/specs/2026-07-25-monorepo-workspace-support.md index 31b4a62..06614dc 100644 --- a/docs/specs/2026-07-25-monorepo-workspace-support.md +++ b/docs/specs/2026-07-25-monorepo-workspace-support.md @@ -275,6 +275,11 @@ Git root exists), so an inherited root owner remains openable without `..`. conventions. - **AC12 — Skill contract:** `/better-harness` Step 1 states the resolved target kind and route, and instructs repairs to use the finding owner route. +- **AC13 — Canonical path and flag identity:** Analysis scope and render target + checks treat real-path aliases, including Windows 8.3/long-name variants and + symlinks, as one filesystem identity without changing stable public routes. + `workspace-topology` parses `--json=` and + `--help=` as booleans and rejects unsupported boolean values. ## Non-goals @@ -335,6 +340,10 @@ Git root exists), so an inherited root owner remains openable without `..`. ### Slice 5 — Verification and readiness +- Canonicalize both sides of filesystem-identity comparisons while keeping + reader-visible workspace paths and Git routes deterministic. +- Add strict boolean parsing for the topology CLI and regression tests for + explicit false values. - Run AC-focused tests and all existing capability tests. - Run `npm test`, `npm run pack:verify`, and `npm run preview`. - Smoke-test `http://localhost:58575/health` and `/canvas-module.js`; inspect @@ -353,6 +362,7 @@ Git root exists), so an inherited root owner remains openable without `..`. | AC8, AC10 | Evidence Bundle dependency-spy tests proving one topology resolution, object identity/binding, lead propagation, and partial/failed coverage. | | AC9 | Finding schema, report projection, renderer, persistence, and wrong-package rejection tests. | | AC12 | Skill contract assertions and doc-link graph validation. | +| AC13 | `test/core-change-watch-scope.test.mjs`, `test/harness-report-render-cli.test.mjs`, and `test/workspace-topology.test.mjs` covering canonical aliases, render target identity, and explicit boolean flag values. | Baseline and final commands: @@ -388,6 +398,15 @@ npm run preview ## Evidence Log +- 2026-07-30 review hardening: PR review identified non-canonical render target + comparison and string-truthy boolean flags. Windows CI exposed 25 cascading + failures where NTFS 8.3 and long-name paths represented the same temporary + repository. AC13 now uses native real-path identity on both sides, preserves + stable routes, and parses explicit boolean values strictly. Focused review + and CI regressions passed `102/102`, the full suite passed `926/926`, pack + verification passed with 316 npm and 342 runtime-zip entries, doc-link checks + passed `6/6`, and both preview endpoints loaded. The Windows result remains + external PR check evidence rather than a locally inferred claim. - 2026-07-25: initial representative-monorepo spike recorded E1–E5. - 2026-07-29: source audit added E6/E7 and found that provider session matchers are private, root transcript discovery precedes hydration, project scoping is diff --git a/scripts/core-change-watch/common.mjs b/scripts/core-change-watch/common.mjs index 4341e69..f0d3aad 100644 --- a/scripts/core-change-watch/common.mjs +++ b/scripts/core-change-watch/common.mjs @@ -284,7 +284,19 @@ function canonicalAbsolutePath(value) { missingSegments.unshift(path.basename(existingAncestor)); existingAncestor = parent; } - return path.resolve(realpathSync(existingAncestor), ...missingSegments); + const nativeRealpath = typeof realpathSync.native === "function" + ? realpathSync.native(existingAncestor) + : realpathSync(existingAncestor); + return path.resolve(nativeRealpath, ...missingSegments); +} + +function canonicalPathIdentity(value) { + const canonical = canonicalAbsolutePath(value); + return process.platform === "win32" ? canonical.toLowerCase() : canonical; +} + +function sameCanonicalPath(left, right) { + return canonicalPathIdentity(left) === canonicalPathIdentity(right); } function unwrapAnalysisScope(value) { @@ -329,7 +341,9 @@ function normalizeRepoRelativePath(value, { } function routeForTarget(repoRoot, targetRoot) { - const relative = path.relative(repoRoot, targetRoot); + const canonicalRepoRoot = canonicalAbsolutePath(repoRoot); + const canonicalTargetRoot = canonicalAbsolutePath(targetRoot); + const relative = path.relative(canonicalRepoRoot, canonicalTargetRoot); if (relative === "") { return "."; } @@ -528,7 +542,7 @@ export function assertCompatibleAnalysisScope(actual, expected, owner = "analysi const expectedValue = unwrapAnalysisScope(expected); const expectedRepoRoot = expectedValue?.repoRoot; const sameRepoRoot = actualRepoRoot === undefined || expectedRepoRoot === undefined - || canonicalAbsolutePath(actualRepoRoot) === canonicalAbsolutePath(expectedRepoRoot); + || sameCanonicalPath(actualRepoRoot, expectedRepoRoot); if (!sameRepoRoot || actualScope.kind !== expectedScope.kind || actualScope.route !== expectedScope.route) { @@ -544,7 +558,7 @@ export function listTrackedFiles(repoRoot, analysisScope = null) { const resolvedRepoRoot = canonicalAbsolutePath(repoRoot); const scopeValue = unwrapAnalysisScope(analysisScope); if (scopeValue?.repoRoot !== undefined - && canonicalAbsolutePath(scopeValue.repoRoot) !== resolvedRepoRoot) { + && !sameCanonicalPath(scopeValue.repoRoot, resolvedRepoRoot)) { throw analysisScopeError( `tracked-file Git root ${resolvedRepoRoot} does not match analysis scope Git root ${scopeValue.repoRoot}`, "ANALYSIS_SCOPE_MISMATCH", @@ -561,7 +575,7 @@ export function listUntrackedFiles(repoRoot, analysisScope = null) { const resolvedRepoRoot = canonicalAbsolutePath(repoRoot); const scopeValue = unwrapAnalysisScope(analysisScope); if (scopeValue?.repoRoot !== undefined - && canonicalAbsolutePath(scopeValue.repoRoot) !== resolvedRepoRoot) { + && !sameCanonicalPath(scopeValue.repoRoot, resolvedRepoRoot)) { throw analysisScopeError( `untracked-file Git root ${resolvedRepoRoot} does not match analysis scope Git root ${scopeValue.repoRoot}`, "ANALYSIS_SCOPE_MISMATCH", diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index f1851df..cae9499 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -28,6 +28,12 @@ import { QODER_CANVAS_FILE, renderQoderCanvas, } from "./renderers/qoder-canvas.mjs"; + +function filesystemPathIdentity(value) { + const normalized = path.normalize(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + const HELP = `Usage: better-harness harness render (--source | --findings ) --mode --out --target [options] Render reviewed findings data into deterministic report artifacts. @@ -335,9 +341,11 @@ export async function renderReport(options) { ? (await resolveWorkspaceTopology({ workspace: options.target })).topology : undefined); if (options.target && topology) { - const requestedTarget = await realpath(path.resolve(options.target)); - const topologyTarget = path.resolve(topology.requestedWorkspace); - if (requestedTarget !== topologyTarget) { + const [requestedTarget, topologyTarget] = await Promise.all([ + realpath(path.resolve(options.target)), + realpath(path.resolve(topology.requestedWorkspace)), + ]); + if (filesystemPathIdentity(requestedTarget) !== filesystemPathIdentity(topologyTarget)) { throw Object.assign(new Error("--target does not match the frozen workspace topology"), { code: "RENDER_WORKSPACE_TOPOLOGY_MISMATCH", }); diff --git a/scripts/workspace-topology/cli.mjs b/scripts/workspace-topology/cli.mjs index 19d27da..c812280 100644 --- a/scripts/workspace-topology/cli.mjs +++ b/scripts/workspace-topology/cli.mjs @@ -28,7 +28,20 @@ export function parseArgs(argv = []) { const body = arg.slice(2); const equal = body.indexOf("="); if (equal !== -1) { - options[body.slice(0, equal)] = body.slice(equal + 1); + const name = body.slice(0, equal); + const value = body.slice(equal + 1); + if (name === "json" || name === "help") { + const normalized = value.toLowerCase(); + if (normalized !== "true" && normalized !== "false") { + throw Object.assign( + new Error(`--${name} expects true or false`), + { code: "INVALID_BOOLEAN_OPTION" }, + ); + } + options[name] = normalized === "true"; + } else { + options[name] = value; + } continue; } if (body === "json" || body === "help") { diff --git a/test/core-change-watch-scope.test.mjs b/test/core-change-watch-scope.test.mjs index 1a4ca24..a6b03ef 100644 --- a/test/core-change-watch-scope.test.mjs +++ b/test/core-change-watch-scope.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; @@ -79,6 +79,30 @@ test("analysis scope resolves repo and nested targets to one public contract", a } }); +test("analysis scope treats real-path aliases as one repository identity", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "better-harness-scope-canonical-")); + const linksRoot = await mkdtemp(path.join(tmpdir(), "better-harness-scope-links-")); + const repoLink = path.join(linksRoot, "repo-link"); + const packageRoot = path.join(repoRoot, "packages", "app"); + try { + await mkdir(packageRoot, { recursive: true }); + runGit(repoRoot, ["init", "--quiet"]); + await symlink(repoRoot, repoLink, "dir"); + + const scope = resolveAnalysisScope({ + repoRoot: repoLink, + targetRoot: packageRoot, + }); + + assert.equal(scope.route, "packages/app"); + assert.equal(scope.repoRoot, await realpath(repoRoot)); + assert.equal(scope.targetRoot, await realpath(packageRoot)); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + await rm(linksRoot, { recursive: true, force: true }); + } +}); + test("scope path conversion is segment-aware and round trips local paths", () => { const scope = publicAnalysisScope({ kind: "path", route: "packages/app" }); diff --git a/test/harness-report-render-cli.test.mjs b/test/harness-report-render-cli.test.mjs index 9a96ee5..e108036 100644 --- a/test/harness-report-render-cli.test.mjs +++ b/test/harness-report-render-cli.test.mjs @@ -1,12 +1,13 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { evaluateHtmlReport, renderHtml } from "../scripts/harness-analysis/renderers/html.mjs"; +import { renderReport } from "../scripts/harness-analysis/render-report.mjs"; import { renderCanvasTsx } from "../scripts/harness-analysis/renderers/qoder-canvas.mjs"; import { buildTaskLoopSourceCandidate } from "../scripts/harness-analysis/task-loop-source.mjs"; import { applyEpisodeReviews } from "../scripts/harness-analysis/episode-evidence-review.mjs"; @@ -481,6 +482,32 @@ test("render rejects structured target metadata when topology discovery is parti }); }); +test("render canonicalizes both target and frozen topology workspace identities", async () => { + await withTempDir("better-harness-render-target-alias-", async (root) => { + await createRenderMonorepo(root); + const packageRoot = path.join(root, "packages", "a"); + const packageLink = path.join(root, "package-a-link"); + const findingsPath = path.join(root, "target.findings.json"); + await symlink(packageRoot, packageLink, "dir"); + await writeJson(findingsPath, targetFindingData()); + + await assert.rejects( + () => renderReport({ + findings: findingsPath, + mode: "markdown", + out: path.join(root, "runs"), + target: packageRoot, + topology: { + status: "partial", + requestedWorkspace: packageLink, + target: { kind: "workspace-member" }, + }, + }), + (error) => error.code === "RENDER_WORKSPACE_TOPOLOGY_INCOMPLETE", + ); + }); +}); + test("render rejects structured target metadata for a different package", async () => { await withTempDir("better-harness-render-target-mismatch-", async (root) => { await createRenderMonorepo(root); diff --git a/test/workspace-topology.test.mjs b/test/workspace-topology.test.mjs index d77ed64..05aedcc 100644 --- a/test/workspace-topology.test.mjs +++ b/test/workspace-topology.test.mjs @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { main as topologyMain } from "../scripts/workspace-topology/cli.mjs"; +import { main as topologyMain, parseArgs as parseTopologyArgs } from "../scripts/workspace-topology/cli.mjs"; import { WORKSPACE_TOPOLOGY_KIND, findingTargetErrors, @@ -404,6 +404,37 @@ test("workspace topology CLI help avoids probing and JSON output is parser-safe" assert.equal(called, false); assert.match(help, /--workspace/); + assert.deepEqual(parseTopologyArgs(["--json=false", "--help=true"]), { + json: false, + help: true, + }); + assert.throws( + () => parseTopologyArgs(["--json=1"]), + (error) => error.code === "INVALID_BOOLEAN_OPTION", + ); + + let falseFlagOutput = ""; + const falseFlagStatus = await topologyMain([ + "--workspace", "/unused", + "--json=false", + "--help=false", + ], { + resolveWorkspaceTopology: async () => ({ + topology: { + status: "complete", + target: { kind: "standalone", route: "." }, + gitRoot: null, + members: { items: [], total: 0 }, + instructionScopes: { items: [], total: 0 }, + discovery: { inventoryMode: "filesystem", scanned: 0, omitted: 0 }, + }, + analysisScope: { kind: "repo", route: "." }, + }), + stdout: { write: (value) => { falseFlagOutput += value; } }, + }); + assert.equal(falseFlagStatus, 0); + assert.match(falseFlagOutput, /^Workspace topology: complete/u); + const repo = await makeGitRepo({ "README.md": "# fixture\n" }); try { const result = spawnSync(process.execPath, [ From 07b81c28a2e38d24d4da47f3e0b225c2bf6e46c2 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 30 Jul 2026 16:50:50 +0800 Subject: [PATCH 3/5] docs(spec): define topology review hardening Extend the monorepo topology spec with current-host parity, exact frozen-context binding, and hermetic Git fixture acceptance criteria before implementation. The new AC14-AC15 scope the review findings against the current main branch and preserve the existing AC1-AC13 evidence.\n\nSpec: docs/specs/2026-07-25-monorepo-workspace-support.md\n\nCo-authored-by: Codex (GPT 5.6 Sol) --- .../2026-07-25-monorepo-workspace-support.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/specs/2026-07-25-monorepo-workspace-support.md b/docs/specs/2026-07-25-monorepo-workspace-support.md index 06614dc..2c2ac9a 100644 --- a/docs/specs/2026-07-25-monorepo-workspace-support.md +++ b/docs/specs/2026-07-25-monorepo-workspace-support.md @@ -3,7 +3,8 @@ ## Traceability - Spec ID: `2026-07-25-monorepo-workspace-support` -- Status: Implemented and locally validated. +- Status: In progress; AC1-AC13 are implemented, and AC14-AC15 cover the + current review hardening before merge. - Implementation branch: `feat/monorepo-workspace-topology` - Story: unavailable; this is a maintainer-requested product correctness change. - AI involvement: Codex implemented the change and used independent delegated @@ -280,6 +281,16 @@ Git root exists), so an inherited root owner remains openable without `..`. symlinks, as one filesystem identity without changing stable public routes. `workspace-topology` parses `--json=` and `--help=` as booleans and rejects unsupported boolean values. +- **AC14 — Supported-host topology parity:** Every provider accepted by the + Evidence Bundle and report pipeline retains its applicable instruction + inventory and consumes the frozen topology for package session attribution. + Syncing a newly supported host from `main` must not silently restore flat-CWD + collection or return an empty topology-backed instruction graph. +- **AC15 — Frozen binding and hermetic fixtures:** Topology validation proves + that `target.route` resolves from `gitRoot` to `requestedWorkspace`, and the + Evidence Bundle accepts only the exact analysis scope derived from that + topology. Git-backed fixtures produce the same tracked instruction inventory + when a developer has global ignore rules such as `*.local.md`. ## Non-goals @@ -351,6 +362,18 @@ Git root exists), so an inherited root owner remains openable without `..`. - Run Change Traceability Review in Review Readiness mode over the full local diff, including generated files and staged/unstaged separation. +### Slice 6 — Current-main review hardening + +- Merge the current `main` host-adapter changes without dropping topology, + report, fixture, or CLI contracts on either side of the conflict. +- Define topology instruction semantics for every currently supported host and + make each session adapter bind the frozen workspace scope before discovery, + qualification, and hydration. +- Validate topology/workspace/analysis-scope identity at the public contract + boundary rather than relying on individual downstream consumers. +- Isolate temporary Git repositories from developer-global ignore rules when a + fixture intentionally commits a normally local-only instruction file. + ## Test and Review Evidence | AC | Required automated evidence | @@ -363,6 +386,8 @@ Git root exists), so an inherited root owner remains openable without `..`. | AC9 | Finding schema, report projection, renderer, persistence, and wrong-package rejection tests. | | AC12 | Skill contract assertions and doc-link graph validation. | | AC13 | `test/core-change-watch-scope.test.mjs`, `test/harness-report-render-cli.test.mjs`, and `test/workspace-topology.test.mjs` covering canonical aliases, render target identity, and explicit boolean flag values. | +| AC14 | Topology-backed Agent lint and session-provider tests for Qwen Code, GitHub Copilot, and Pi, plus existing Qoder, Codex, Claude, and Cursor coverage. | +| AC15 | Negative topology/Evidence Bundle contract tests for mismatched routes and pathspecs; Git fixture tests run with a global `*.local.md` ignore rule. | Baseline and final commands: @@ -395,6 +420,13 @@ npm run preview direct diagnostic commands remain independently runnable. - **Spec drift:** implementation slices must map changed modules and tests back to AC ids during Review Readiness. +- **Host drift:** the set of supported Evidence Bundle/report/session providers + must stay aligned with topology instruction and workspace-match semantics; + unsupported topology behavior fails unavailable instead of falling back to a + flat workspace silently. +- **Injected context drift:** a caller-supplied topology or analysis scope is + rejected unless its absolute target identity, route, kind, and literal Git + pathspecs are the exact derived values. ## Evidence Log From ba9b2fa55115f9096dcdfef87dcd173e120c3d63 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 30 Jul 2026 17:08:37 +0800 Subject: [PATCH 4/5] fix(harness): bind topology across supported hosts Preserve monorepo instruction discovery and package-session qualification for Qwen Code, GitHub Copilot, and Pi after syncing current main. Reject forged topology and analysis-scope bindings, and isolate Git fixtures from developer-global ignore rules. The AC1-AC15 spec maps the changed provider, topology, bundle, and fixture contracts. The final default-environment suite passed 977/977; pack verification, direct doc and Skill checks, and preview endpoint smoke tests also passed. Co-authored-by: Codex (GPT 5.6 Sol) --- .../2026-07-25-monorepo-workspace-support.md | 26 ++- scripts/agent-lint/index.mjs | 8 +- .../evidence-bundle/contract.mjs | 13 +- .../session-analysis/platforms/copilot.mjs | 54 ++++- scripts/session-analysis/platforms/pi.mjs | 73 +++++- scripts/session-analysis/platforms/qwen.mjs | 81 ++++++- scripts/workspace-topology/contract.mjs | 32 ++- scripts/workspace-topology/index.mjs | 1 + scripts/workspace-topology/inventory.mjs | 5 +- scripts/workspace-topology/manifests.mjs | 29 ++- test/agent-lint-monorepo.test.mjs | 33 +++ test/better-harness-evidence-bundle.test.mjs | 16 +- test/session-workspace-provider.test.mjs | 212 ++++++++++++++++++ test/workspace-topology.test.mjs | 30 +++ 14 files changed, 563 insertions(+), 50 deletions(-) diff --git a/docs/specs/2026-07-25-monorepo-workspace-support.md b/docs/specs/2026-07-25-monorepo-workspace-support.md index 2c2ac9a..44fbded 100644 --- a/docs/specs/2026-07-25-monorepo-workspace-support.md +++ b/docs/specs/2026-07-25-monorepo-workspace-support.md @@ -3,8 +3,8 @@ ## Traceability - Spec ID: `2026-07-25-monorepo-workspace-support` -- Status: In progress; AC1-AC13 are implemented, and AC14-AC15 cover the - current review hardening before merge. +- Status: Implemented and locally validated through AC15; external CI remains + to be rerun for the updated PR head. - Implementation branch: `feat/monorepo-workspace-topology` - Story: unavailable; this is a maintainer-requested product correctness change. - AI involvement: Codex implemented the change and used independent delegated @@ -35,7 +35,7 @@ The 2026-07-25 spike found: | E1 | `asset-baseline --workspace ` with Qoder | Nested `AGENTS.md` files were not represented in the effective asset baseline. | | E2 | `discoverAgentEntrypoints` with Codex | The filesystem walk included ignored `.build/**` and `out*/**` copies, skipped tracked `build/**` source, and had no file cap. | | E3 | Package-target asset baseline | Root instructions and provider assets disappeared because owner routes outside the requested workspace were discarded. | -| E4 | Session matching on all four providers | Matching was based on direct CWD prefixes. Package targets missed root-CWD sessions, while admitting a whole mixed session would contaminate package facts. | +| E4 | Provider session matching | Matching was based on direct CWD prefixes. Package targets missed root-CWD sessions, while admitting a whole mixed session would contaminate package facts. | | E5 | `core-change-watch` | `resolveRepoRoot` widened tracked files, history, diff, drift, and companion candidates to the whole repository. | | E6 | Evidence Bundle lead | Specialist lanes and the lead analyzer independently rescanned the same flat workspace, so fixing only the lanes would not fix the final report. | | E7 | Finding validation | Durable finding contracts did not contain a structured target/package route, while rejecting unsupported fields. | @@ -184,8 +184,11 @@ The initial capability matrix follows the current provider contracts: |----------|--------------------------------| | Codex | Root-to-target `AGENTS.md` ancestry. | | Claude | Ancestor `CLAUDE.md`; descendant instructions remain on-demand/candidate until file activity establishes their scope. | -| Cursor | Scoped `.cursor/rules`; nested `AGENTS.md` remains candidate unless the installed provider contract proves activation. | +| Cursor | Scoped `.cursor/rules`; nested `AGENTS.md` and root Copilot instructions remain candidate unless the installed provider contract proves activation. | | Qoder | Root project `AGENTS.md` and project Rules; nested `AGENTS.md` remains candidate unless the installed provider contract proves activation. | +| Qwen Code | Root-to-target `QWEN.md` and `AGENTS.md` ancestry. | +| GitHub Copilot | Root-to-target `AGENTS.md` ancestry and root `.github/copilot-instructions.md`; path-scoped `.github/instructions/*.instructions.md` remains candidate until `applyTo` matching is represented structurally. | +| Pi | Root-to-target `AGENTS.md` ancestry. | Assets preserve both ownership and applicability: @@ -253,7 +256,7 @@ Git root exists), so an inherited root owner remains openable without `..`. nested guides with owner routes. Package-target baselines retain the ordered root-to-target inheritance chain without double counting and distinguish effective from candidate assets. -- **AC5 — Session bridging:** All four providers retain direct-CWD behavior, +- **AC5 — Session bridging:** All supported providers retain direct-CWD behavior, discover root-CWD candidates before selection, include target-only activity as `root-cwd`, and omit mixed, ambiguous, or truncated candidates from package facts. @@ -380,7 +383,7 @@ Git root exists), so an inherited root owner remains openable without `..`. |----|-----------------------------| | AC1–AC3, AC11 | `test/workspace-topology.test.mjs`, including root/member/subtree/standalone, manifest variants, ignored/untracked files, cap boundaries, symlink escape, special characters, and CLI JSON. | | AC4 | Agent lint, agent customize, and asset baseline tests with root/intermediate/package assets, candidate activation, owner routes, and no duplicates. | -| AC5 | Shared matcher tests plus four-provider root-CWD fixtures covering direct, target-only, no-target, mixed, truncated, and post-hydration foreign activity. | +| AC5 | Shared matcher tests plus supported-provider root-CWD fixtures covering direct, target-only, no-target, mixed, truncated, and post-hydration foreign activity. | | AC6–AC7 | Real temporary Git repository and command-spy tests covering history, diff, untracked, rename boundary, companions, noisy sibling, and tracked `build/**`. | | AC8, AC10 | Evidence Bundle dependency-spy tests proving one topology resolution, object identity/binding, lead propagation, and partial/failed coverage. | | AC9 | Finding schema, report projection, renderer, persistence, and wrong-package rejection tests. | @@ -430,6 +433,17 @@ npm run preview ## Evidence Log +- 2026-07-30 current-main hardening: merged `origin/main` and preserved both + sides of the four overlapping report/session/test contracts. Topology and + session attribution now cover Codex, Qoder, Claude, Cursor, Qwen Code, + GitHub Copilot, and Pi. Frozen topology and analysis-scope mismatches fail + closed, and temporary Git fixtures ignore developer-global Git configuration. + The conflict-focused suite passed `58/58`, the expanded topology/session + suite passed `62/62`, the broad affected suite passed `228/228`, and the + default-environment full suite passed `977/977`. Pack verification passed + with 331 npm and 354 runtime-zip entries, direct doc/Skill checks passed + `18/18`, preview `/health` returned `ok`, and `/canvas-module.js` returned + JavaScript with HTTP 200. - 2026-07-30 review hardening: PR review identified non-canonical render target comparison and string-truthy boolean flags. Windows CI exposed 25 cascading failures where NTFS 8.3 and long-name paths represented the same temporary diff --git a/scripts/agent-lint/index.mjs b/scripts/agent-lint/index.mjs index e111441..f7e3c95 100644 --- a/scripts/agent-lint/index.mjs +++ b/scripts/agent-lint/index.mjs @@ -482,7 +482,9 @@ async function collectNestedEntrypoints(workspace, maxEntrypointDepth, provider) } function topologyScopeOwnerRoute(route) { - if (route === ".claude/CLAUDE.md" || route === ".github/copilot-instructions.md") return "."; + if (route === ".claude/CLAUDE.md" + || route === ".github/copilot-instructions.md" + || route.startsWith(".github/instructions/")) return "."; for (const marker of ["/.claude/rules/", "/.cursor/rules/", "/.qoder/rules/"]) { const normalized = `/${route}`; const index = normalized.indexOf(marker); @@ -497,12 +499,16 @@ function topologyScopeOwnerRoute(route) { function topologyScopeSourceKind(route) { const base = path.posix.basename(route); if (route === ".github/copilot-instructions.md") return "copilot-instructions"; + if (route.startsWith(".github/instructions/") && route.endsWith(".instructions.md")) { + return "copilot-instructions"; + } if (route.includes("/.claude/rules/") || route.startsWith(".claude/rules/")) return "claude-rule"; if (route.includes("/.cursor/rules/") || route.startsWith(".cursor/rules/")) return "cursor-rule"; if (route.includes("/.qoder/rules/") || route.startsWith(".qoder/rules/")) return "qoder-rule"; if (base === "AGENTS.md") return route === "AGENTS.md" ? "agents-md" : "nested-agent-guide"; if (base === "CLAUDE.md") return route === "CLAUDE.md" ? "claude-md" : "nested-agent-guide"; if (base === "CLAUDE.local.md") return "claude-local"; + if (base === "QWEN.md") return "qwen-md-context"; return "nested-agent-guide"; } diff --git a/scripts/harness-analysis/evidence-bundle/contract.mjs b/scripts/harness-analysis/evidence-bundle/contract.mjs index e535e8c..cfbe731 100644 --- a/scripts/harness-analysis/evidence-bundle/contract.mjs +++ b/scripts/harness-analysis/evidence-bundle/contract.mjs @@ -1,7 +1,10 @@ import { existsSync, realpathSync } from "node:fs"; import path from "node:path"; -import { validateWorkspaceTopology } from "../../workspace-topology/index.mjs"; +import { + analysisScopeFromTopology, + validateWorkspaceTopology, +} from "../../workspace-topology/index.mjs"; export const EVIDENCE_BUNDLE_KIND = "better-harness.evidence-bundle"; export const EVIDENCE_BUNDLE_SCHEMA_VERSION = 2; @@ -81,8 +84,12 @@ export function freezeEvidenceBundleContext(options = {}, now = new Date()) { code: "INVALID_EVIDENCE_ANALYSIS_SCOPE", }); } - if (analysisScope.route !== topology.target.route - || !Array.isArray(analysisScope.pathspecs)) { + const expectedScope = analysisScopeFromTopology(topology); + if (analysisScope.kind !== expectedScope.kind + || analysisScope.route !== expectedScope.route + || !Array.isArray(analysisScope.pathspecs) + || analysisScope.pathspecs.length !== expectedScope.pathspecs.length + || analysisScope.pathspecs.some((item, index) => item !== expectedScope.pathspecs[index])) { throw Object.assign(new Error("analysisScope is not bound to topology target"), { code: "EVIDENCE_ANALYSIS_SCOPE_MISMATCH", }); diff --git a/scripts/session-analysis/platforms/copilot.mjs b/scripts/session-analysis/platforms/copilot.mjs index b9a528e..a32f601 100644 --- a/scripts/session-analysis/platforms/copilot.mjs +++ b/scripts/session-analysis/platforms/copilot.mjs @@ -9,12 +9,17 @@ import { parseArgs, parseBooleanFlag } from "../cli.mjs"; import { forEachJsonLine, isDirectory, pathExists } from "../fs.mjs"; import { expandHome, normalizeWorkspace } from "../paths.mjs"; import { + bindSessionWorkspaceCwds, emitProviderResult, + markSessionReadCoverage, runProviderAnalysis, runProviderCommand, + sessionWorkspaceCwd, + workspaceMatchScopeFromOptions, } from "../provider-runner.mjs"; import { parseResultFacts } from "../result-facts.mjs"; import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; const TRANSCRIPT_FILE = "events.jsonl"; const WORKSPACE_FILE = "workspace.yaml"; @@ -25,6 +30,11 @@ function isWorkspaceMatch(candidate, workspace) { return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + /** * Read the `cwd` binding from a Copilot session's `workspace.yaml`. * @@ -371,7 +381,7 @@ function transcriptEvents(raw, sourceRef, options) { return events; } -async function probeSessionDirectory(sessionDir, workspace) { +async function probeSessionDirectory(sessionDir, scope) { const transcriptPath = path.join(sessionDir, TRANSCRIPT_FILE); const descriptor = parseWorkspaceDescriptor(await readWorkspaceDescriptor(sessionDir)); const summary = { @@ -384,7 +394,8 @@ async function probeSessionDirectory(sessionDir, workspace) { requestRecords: 0, firstSeen: null, lastSeen: null, - workspaceMatch: isWorkspaceMatch(descriptor.cwd, workspace), + workspaceMatch: !scope._workspaceMatchScope && isWorkspaceMatch(descriptor.cwd, scope.workspace), + cwds: descriptor.cwd ? [descriptor.cwd] : [], }; if (!summary.transcriptAvailable) { return summary; @@ -404,11 +415,15 @@ async function probeSessionDirectory(sessionDir, workspace) { const cwd = data?.context?.cwd; if (cwd) { summary.cwd = summary.cwd ?? cwd; - if (isWorkspaceMatch(cwd, workspace)) summary.workspaceMatch = true; + if (!summary.cwds.includes(cwd)) summary.cwds.push(cwd); + if (!scope._workspaceMatchScope && isWorkspaceMatch(cwd, scope.workspace)) summary.workspaceMatch = true; } } mergeTimeRange(summary, inferTimestamp(raw)); }); + if (scope._workspaceMatchScope) { + summary.workspaceMatch = summary.cwds.some((cwd) => isScopedWorkspaceMatch(cwd, scope)); + } return summary; } @@ -519,6 +534,7 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); const home = path.resolve(expandHome( options.home ?? options.copilotHome ?? options["copilot-home"] ?? process.env.COPILOT_HOME ?? "~/.copilot", )); @@ -532,6 +548,7 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { untilTime: until.time, sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), + _workspaceMatchScope: workspaceMatchScope, }; } @@ -567,7 +584,7 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { for (const entry of entries) { const sessionDir = path.join(transcriptRoot.path, entry.name); if (!entry.isDirectory() && !(await isDirectory(sessionDir))) continue; - const probe = await probeSessionDirectory(sessionDir, scope.workspace); + const probe = await probeSessionDirectory(sessionDir, scope); if (probe?.workspaceMatch) matched.push(probe); } @@ -580,7 +597,7 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { }); const inWindow = inWindowProbes - .map((probe) => ({ + .map((probe) => bindSessionWorkspaceCwds({ sessionId: probe.sessionId, workspace: scope.workspace, firstSeen: probe.firstSeen, @@ -596,7 +613,7 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { lastSeen: probe.lastSeen, }, ], - })) + }, probe.cwds)) .sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0)); scope._copilotSourceCoverage = buildCopilotSourceCoverage({ scope, roots, matched, inWindow, inWindowProbes }); @@ -613,9 +630,20 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { async readSession(session, scope, options = {}) { const events = []; + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : null; for (const ref of session.sourceRefs ?? []) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } if (!ref.path.endsWith(".jsonl")) continue; - await forEachJsonLine(ref.path, (raw, line) => { + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { for (const event of this.normalizeEvents( raw, { ...ref, sessionId: session.sessionId, line }, @@ -623,11 +651,19 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer { )) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } - }); + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return dedupeEvents(events).sort((left, right) => + const sorted = dedupeEvents(events) + .map((event) => event.cwd || !identityCwd ? event : { ...event, cwd: identityCwd }) + .sort((left, right) => (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); } async analysisWarnings(scope, _roots, _sessions) { diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index fec09ef..0dbfb62 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -10,12 +10,17 @@ import { parseArgs, parseBooleanFlag } from "../cli.mjs"; import { forEachJsonLine, isDirectory, pathExists, walkFiles } from "../fs.mjs"; import { expandHome, normalizeWorkspace } from "../paths.mjs"; import { + bindSessionWorkspaceCwds, emitProviderResult, + markSessionReadCoverage, runProviderAnalysis, runProviderCommand, + sessionWorkspaceCwd, + workspaceMatchScopeFromOptions, } from "../provider-runner.mjs"; import { parseResultFacts } from "../result-facts.mjs"; import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; function isWorkspaceMatch(candidate, workspace) { if (!candidate) return false; @@ -23,6 +28,11 @@ function isWorkspaceMatch(candidate, workspace) { return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + export function workspaceToPiSessionDirVariants(workspace) { const expanded = expandHome(workspace ?? process.cwd()); const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); @@ -220,13 +230,14 @@ function transcriptEvents(raw, sourceRef, options) { return events; } -async function probeTranscript(filePath, workspace) { +async function probeTranscript(filePath, scope) { const summary = { sessionId: sessionIdFromFileName(filePath), firstSeen: null, lastSeen: null, workspaceMatch: false, validHeader: false, + cwd: null, }; let firstRecord = true; await forEachJsonLine(filePath, (raw) => { @@ -234,12 +245,13 @@ async function probeTranscript(filePath, workspace) { firstRecord = false; // Pi requires the first parsed record to be the session header. Do not // let a later injected header qualify an otherwise foreign transcript. - if (raw?.type !== "session" || typeof raw.id !== "string" || !isWorkspaceMatch(raw.cwd, workspace)) { + if (raw?.type !== "session" || typeof raw.id !== "string" || !isScopedWorkspaceMatch(raw.cwd, scope)) { return false; } summary.validHeader = true; summary.workspaceMatch = true; summary.sessionId = raw.id; + summary.cwd = raw.cwd; } else if (raw?.type === "session") { // Multiple headers are not a valid Pi session and can splice content // from different workspaces, so reject the whole file fail-closed. @@ -261,7 +273,9 @@ function addRef(sessions, sessionId, workspace, ref) { lastSeen: null, sourceKinds: new Set(), sourceRefs: [], + workspaceCwds: new Set(), }; + if (typeof ref.cwd === "string" && ref.cwd.length > 0) session.workspaceCwds.add(ref.cwd); session.sourceKinds.add(ref.kind); session.sourceRefs.push(ref); mergeTimeRange(session, ref.firstSeen ?? ref.timestamp); @@ -270,7 +284,11 @@ function addRef(sessions, sessionId, workspace, ref) { } function finalizeSession(session) { - return { ...session, sourceKinds: [...session.sourceKinds].sort() }; + const { workspaceCwds, ...publicSession } = session; + return bindSessionWorkspaceCwds( + { ...publicSession, sourceKinds: [...session.sourceKinds].sort() }, + [...workspaceCwds], + ); } async function listSessionDirectories(sessionsRoot, variants) { @@ -332,6 +350,7 @@ export class PiSessionAnalyzer extends SessionAnalyzer { const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); // Pi resolves its agent dir from PI_CODING_AGENT_DIR (default ~/.pi/agent). const home = path.resolve(expandHome( options.home ?? options.piHome ?? options["pi-home"] ?? process.env.PI_CODING_AGENT_DIR ?? "~/.pi/agent", @@ -343,13 +362,18 @@ export class PiSessionAnalyzer extends SessionAnalyzer { home, sessionsDir: sessionDirContract.dir, sessionDirMode: sessionDirContract.mode, - _workspaceDirVariants: workspaceToPiSessionDirVariants(workspace), + _workspaceDirVariantSets: [...new Set([ + workspace, + workspaceMatchScope?.requestedWorkspace, + workspaceMatchScope?.target.kind === "workspace-member" ? workspaceMatchScope.gitRoot : null, + ].filter(Boolean))].map((candidate) => workspaceToPiSessionDirVariants(candidate)), since: since.label, sinceTime: since.time, until: until.label, untilTime: until.time, sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), + _workspaceMatchScope: workspaceMatchScope, }; } @@ -357,14 +381,17 @@ export class PiSessionAnalyzer extends SessionAnalyzer { const custom = scope.sessionDirMode === "custom"; const matchingDirs = custom ? [] - : await listSessionDirectories(scope.sessionsDir, scope._workspaceDirVariants); + : [...new Set((await Promise.all( + scope._workspaceDirVariantSets.map((variants) => listSessionDirectories(scope.sessionsDir, variants)), + )).flat())]; + const primaryVariants = scope._workspaceDirVariantSets[0]; const root = { id: "pi-sessions", kind: "pi-session-jsonl", role: "session-transcript", path: custom ? scope.sessionsDir - : (matchingDirs[0] ?? path.join(scope.sessionsDir, scope._workspaceDirVariants.exact)), + : (matchingDirs[0] ?? path.join(scope.sessionsDir, primaryVariants.exact)), paths: [scope.sessionsDir], optional: false, enabled: true, @@ -393,7 +420,9 @@ export class PiSessionAnalyzer extends SessionAnalyzer { // tree nests them under workspace-keyed ---- directories. const candidateDirs = custom ? [sessionsRoot] - : await listSessionDirectories(sessionsRoot, scope._workspaceDirVariants); + : [...new Set((await Promise.all( + scope._workspaceDirVariantSets.map((variants) => listSessionDirectories(sessionsRoot, variants)), + )).flat())]; for (const dirPath of candidateDirs) { let realDir; try { realDir = realpathSync.native(dirPath); } catch { realDir = path.resolve(dirPath); } @@ -403,7 +432,7 @@ export class PiSessionAnalyzer extends SessionAnalyzer { for (const filePath of files) { // Both modes qualify each transcript by the session-header cwd, so a // shared custom directory never leaks foreign-workspace sessions. - const probe = await probeTranscript(filePath, scope.workspace); + const probe = await probeTranscript(filePath, scope); if (!probe.validHeader || !probe.workspaceMatch || !withinTimeRange(probe.lastSeen ?? probe.firstSeen, scope)) continue; addRef(sessions, probe.sessionId, scope.workspace, { kind: transcriptRoot.kind, @@ -411,6 +440,7 @@ export class PiSessionAnalyzer extends SessionAnalyzer { path: filePath, firstSeen: probe.firstSeen, lastSeen: probe.lastSeen, + cwd: probe.cwd, }); } } @@ -429,19 +459,38 @@ export class PiSessionAnalyzer extends SessionAnalyzer { async readSession(session, scope, options = {}) { const events = []; + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : null; for (const ref of session.sourceRefs ?? []) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } if (!ref.path.endsWith(".jsonl")) continue; - await forEachJsonLine(ref.path, (raw, line) => { - if (raw?.type === "session" && raw?.cwd && !isWorkspaceMatch(raw.cwd, scope.workspace)) return; + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { + if (raw?.type === "session" && raw?.cwd && !isScopedWorkspaceMatch(raw.cwd, scope)) return; for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } - }); + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return events.sort((left, right) => + const sorted = events + .map((event) => event.cwd || !identityCwd ? event : { ...event, cwd: identityCwd }) + .sort((left, right) => (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0) || Number(left.evidenceRef?.seq ?? 0) - Number(right.evidenceRef?.seq ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); } async analyze(options = {}) { diff --git a/scripts/session-analysis/platforms/qwen.mjs b/scripts/session-analysis/platforms/qwen.mjs index da6b50a..9c70dcf 100644 --- a/scripts/session-analysis/platforms/qwen.mjs +++ b/scripts/session-analysis/platforms/qwen.mjs @@ -9,12 +9,17 @@ import { parseArgs, parseBooleanFlag } from "../cli.mjs"; import { forEachJsonLine, pathExists, walkFiles } from "../fs.mjs"; import { expandHome, normalizeWorkspace } from "../paths.mjs"; import { + bindSessionWorkspaceCwds, emitProviderResult, + markSessionReadCoverage, runProviderAnalysis, runProviderCommand, + sessionWorkspaceCwd, + workspaceMatchScopeFromOptions, } from "../provider-runner.mjs"; import { parseResultFacts } from "../result-facts.mjs"; import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; function isWorkspaceMatch(candidate, workspace) { if (!candidate) return false; @@ -22,6 +27,11 @@ function isWorkspaceMatch(candidate, workspace) { return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); } +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + export function workspaceToQwenSlugVariants(workspace) { const expanded = expandHome(workspace ?? process.cwd()); const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); @@ -216,13 +226,25 @@ function transcriptEvents(raw, sourceRef, options) { return events; } -async function probeTranscript(filePath, workspace) { - const summary = { sessionId: path.basename(filePath, ".jsonl"), firstSeen: null, lastSeen: null, workspaceMatch: false }; +async function probeTranscript(filePath, scope) { + const cwdCandidates = new Set(); + const summary = { + sessionId: path.basename(filePath, ".jsonl"), + firstSeen: null, + lastSeen: null, + workspaceMatch: false, + cwds: [], + }; await forEachJsonLine(filePath, (raw) => { summary.sessionId = inferSessionId(raw, summary.sessionId); - if (isWorkspaceMatch(raw?.cwd, workspace)) summary.workspaceMatch = true; + if (typeof raw?.cwd === "string" && raw.cwd.length > 0) cwdCandidates.add(raw.cwd); + if (!scope._workspaceMatchScope && isWorkspaceMatch(raw?.cwd, scope.workspace)) summary.workspaceMatch = true; mergeTimeRange(summary, inferTimestamp(raw)); }); + summary.cwds = [...cwdCandidates]; + if (scope._workspaceMatchScope) { + summary.workspaceMatch = summary.cwds.some((cwd) => isScopedWorkspaceMatch(cwd, scope)); + } return summary; } @@ -235,7 +257,11 @@ function addRef(sessions, sessionId, workspace, ref) { lastSeen: null, sourceKinds: new Set(), sourceRefs: [], + workspaceCwds: new Set(), }; + for (const cwd of ref.cwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) session.workspaceCwds.add(cwd); + } session.sourceKinds.add(ref.kind); session.sourceRefs.push(ref); mergeTimeRange(session, ref.firstSeen ?? ref.timestamp); @@ -244,7 +270,11 @@ function addRef(sessions, sessionId, workspace, ref) { } function finalizeSession(session) { - return { ...session, sourceKinds: [...session.sourceKinds].sort() }; + const { workspaceCwds, ...publicSession } = session; + return bindSessionWorkspaceCwds( + { ...publicSession, sourceKinds: [...session.sourceKinds].sort() }, + [...workspaceCwds], + ); } function dedupeEvents(events) { @@ -269,6 +299,12 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { const since = normalizeCliDate(options.since, false); const until = normalizeCliDate(options.until, true); const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); + const transcriptWorkspaces = [...new Set([ + workspace, + workspaceMatchScope?.requestedWorkspace, + workspaceMatchScope?.target.kind === "workspace-member" ? workspaceMatchScope.gitRoot : null, + ].filter(Boolean))]; const home = path.resolve(expandHome(options.home ?? options.qwenHome ?? options["qwen-home"] ?? process.env.QWEN_HOME ?? "~/.qwen")); // Qwen separates config home (QWEN_HOME / ~/.qwen) from runtime data // (QWEN_RUNTIME_DIR). Session transcripts live under the runtime dir. @@ -280,13 +316,16 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { workspace, home, runtimeDir, - _workspaceSlugVariants: workspaceToQwenSlugVariants(workspace), + _workspaceSlugVariants: [...new Set( + transcriptWorkspaces.flatMap((candidate) => workspaceToQwenSlugVariants(candidate)), + )], since: since.label, sinceTime: since.time, until: until.label, untilTime: until.time, sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), + _workspaceMatchScope: workspaceMatchScope, }; } @@ -325,7 +364,7 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { seenRoots.add(realRoot); const files = await walkFiles(rootPath, { maxDepth: 2, limit: 20_000, match: (file) => file.endsWith(".jsonl") }); for (const filePath of files) { - const probe = await probeTranscript(filePath, scope.workspace); + const probe = await probeTranscript(filePath, scope); if (!probe.workspaceMatch || !withinTimeRange(probe.lastSeen ?? probe.firstSeen, scope)) continue; addRef(sessions, probe.sessionId, scope.workspace, { kind: transcriptRoot.kind, @@ -333,6 +372,7 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { path: filePath, firstSeen: probe.firstSeen, lastSeen: probe.lastSeen, + cwds: probe.cwds, }); } } @@ -350,20 +390,41 @@ export class QwenSessionAnalyzer extends SessionAnalyzer { async readSession(session, scope, options = {}) { const events = []; + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const identityCwd = scope._workspaceMatchScope + ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) + : null; + const rootCandidate = scope._workspaceMatchScope + && classifyWorkspaceCwd(identityCwd, scope._workspaceMatchScope) === WORKSPACE_CWD_MATCH.ROOT_CANDIDATE; for (const ref of session.sourceRefs ?? []) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } if (!ref.path.endsWith(".jsonl")) continue; - await forEachJsonLine(ref.path, (raw, line) => { + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { if (inferSessionId(raw, session.sessionId) !== session.sessionId) return; - if (raw?.cwd && !isWorkspaceMatch(raw.cwd, scope.workspace)) return; + if (!rootCandidate && raw?.cwd && !isScopedWorkspaceMatch(raw.cwd, scope)) return; for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } - }); + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } } - return dedupeEvents(events).sort((left, right) => + const sorted = dedupeEvents(events) + .map((event) => event.cwd || !identityCwd ? event : { ...event, cwd: identityCwd }) + .sort((left, right) => (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0) || Number(left.evidenceRef?.seq ?? 0) - Number(right.evidenceRef?.seq ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); } async analyze(options = {}) { diff --git a/scripts/workspace-topology/contract.mjs b/scripts/workspace-topology/contract.mjs index 8206d8a..44b5be9 100644 --- a/scripts/workspace-topology/contract.mjs +++ b/scripts/workspace-topology/contract.mjs @@ -12,13 +12,29 @@ const TARGET_KINDS = new Set([ const MEMBER_MATCHES = new Set(["exact", "descendant", "none"]); const STATUS_VALUES = new Set(["complete", "partial"]); const MEMBER_KINDS = new Set(["manifest", "convention"]); -const INSTRUCTION_PROVIDERS = new Set(["codex", "qoder", "claude", "cursor"]); +const INSTRUCTION_PROVIDERS = new Set([ + "codex", + "qoder", + "claude", + "cursor", + "qwen", + "copilot", + "pi", +]); const INSTRUCTION_ACTIVATIONS = new Set(["effective", "candidate"]); function contractError(message, code = "INVALID_WORKSPACE_TOPOLOGY") { return Object.assign(new Error(message), { code }); } +function sameAbsolutePath(left, right) { + const leftPath = path.resolve(String(left)); + const rightPath = path.resolve(String(right)); + return process.platform === "win32" + ? leftPath.toLowerCase() === rightPath.toLowerCase() + : leftPath === rightPath; +} + export function normalizeRoute(value, name = "route") { let route = String(value ?? "").replaceAll("\\", "/").trim(); route = route.replace(/^\.\/+/u, "").replace(/\/+$/u, ""); @@ -146,6 +162,20 @@ export function workspaceTopologyErrors(topology) { && (target.memberRoute !== null || target.memberMatch !== "none")) { errors.push(`${target.kind} target must not declare member ownership`); } + if (topology.gitRoot !== null + && path.isAbsolute(String(topology.gitRoot ?? "")) + && path.isAbsolute(String(topology.requestedWorkspace ?? ""))) { + try { + const expectedWorkspace = target.route === "." + ? topology.gitRoot + : path.resolve(topology.gitRoot, ...normalizeRoute(target.route, "target.route").split("/")); + if (!sameAbsolutePath(expectedWorkspace, topology.requestedWorkspace)) { + errors.push("target.route must resolve from gitRoot to requestedWorkspace"); + } + } catch { + // Canonical route errors above already describe this failure. + } + } } errors.push(...countEnvelopeErrors(topology.members, "members")); diff --git a/scripts/workspace-topology/index.mjs b/scripts/workspace-topology/index.mjs index 0119e9a..cc1ee3c 100644 --- a/scripts/workspace-topology/index.mjs +++ b/scripts/workspace-topology/index.mjs @@ -169,6 +169,7 @@ export function ownerRouteForPath(topology, filePath) { export { WORKSPACE_TOPOLOGY_KIND, WORKSPACE_TOPOLOGY_SCHEMA_VERSION, + analysisScopeFromTopology, normalizeRoute, routeContains, validateWorkspaceTopology, diff --git a/scripts/workspace-topology/inventory.mjs b/scripts/workspace-topology/inventory.mjs index da895ac..ef22d95 100644 --- a/scripts/workspace-topology/inventory.mjs +++ b/scripts/workspace-topology/inventory.mjs @@ -41,6 +41,7 @@ function isStructuralRoute(route) { "AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", + "QWEN.md", "Cargo.toml", "go.mod", "go.work", @@ -48,6 +49,7 @@ function isStructuralRoute(route) { "pnpm-workspace.yaml", ]).has(base) || route === ".github/copilot-instructions.md" + || (route.startsWith(".github/instructions/") && route.endsWith(".instructions.md")) || route.includes("/.claude/rules/") || route.startsWith(".claude/rules/") || route.includes("/.cursor/rules/") @@ -57,9 +59,10 @@ function isStructuralRoute(route) { } const STRUCTURAL_PATHSPECS = Object.freeze([ - "AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "Cargo.toml", "go.mod", "go.work", + "AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", "QWEN.md", "Cargo.toml", "go.mod", "go.work", "package.json", "pnpm-workspace.yaml", ".github/copilot-instructions.md", ":(top,glob)**/AGENTS.md", ":(top,glob)**/CLAUDE.md", ":(top,glob)**/CLAUDE.local.md", + ":(top,glob)**/QWEN.md", ":(top,glob).github/instructions/**/*.instructions.md", ":(top,glob)**/Cargo.toml", ":(top,glob)**/go.mod", ":(top,glob)**/go.work", ":(top,glob)**/package.json", ":(top,glob)**/pnpm-workspace.yaml", ":(top,glob)**/.claude/rules/**", ":(top,glob)**/.cursor/rules/**", diff --git a/scripts/workspace-topology/manifests.mjs b/scripts/workspace-topology/manifests.mjs index 8b107e8..b0725e5 100644 --- a/scripts/workspace-topology/manifests.mjs +++ b/scripts/workspace-topology/manifests.mjs @@ -195,8 +195,9 @@ function candidateConventionRoute(instructionRoute, manifestParents) { function scopeActivation(provider, ownerRoute, targetRoute) { const appliesToTarget = routeContains(ownerRoute, targetRoute); - if (provider === "codex") return appliesToTarget ? "effective" : "candidate"; - if (provider === "claude") return appliesToTarget ? "effective" : "candidate"; + if (new Set(["codex", "claude", "qwen", "copilot", "pi"]).has(provider)) { + return appliesToTarget ? "effective" : "candidate"; + } if (provider === "qoder") return ownerRoute === "." ? "effective" : "candidate"; return "candidate"; } @@ -297,7 +298,9 @@ export async function discoverWorkspaceStructure({ return base === "AGENTS.md" || base === "CLAUDE.md" || base === "CLAUDE.local.md" + || base === "QWEN.md" || item.route === ".github/copilot-instructions.md" + || (item.route.startsWith(".github/instructions/") && item.route.endsWith(".instructions.md")) || ((item.route.includes("/.claude/rules/") || item.route.startsWith(".claude/rules/")) && new Set([".md", ".mdc"]).has(extension)) || ((item.route.includes("/.cursor/rules/") || item.route.startsWith(".cursor/rules/")) @@ -311,13 +314,25 @@ export async function discoverWorkspaceStructure({ let ownerRoute = dirnameRoute(item.route); const rows = []; if (base === "AGENTS.md") { - rows.push(["codex", ownerRoute], ["qoder", ownerRoute], ["cursor", ownerRoute]); + rows.push( + ["codex", ownerRoute], + ["qoder", ownerRoute], + ["cursor", ownerRoute], + ["qwen", ownerRoute], + ["copilot", ownerRoute], + ["pi", ownerRoute], + ); } else if (base === "CLAUDE.md" || base === "CLAUDE.local.md") { if (item.route === ".claude/CLAUDE.md") ownerRoute = "."; rows.push(["claude", ownerRoute]); + } else if (base === "QWEN.md") { + rows.push(["qwen", ownerRoute]); } else if (item.route === ".github/copilot-instructions.md") { ownerRoute = "."; - rows.push(["cursor", ownerRoute]); + rows.push(["cursor", ownerRoute], ["copilot", ownerRoute]); + } else if (item.route.startsWith(".github/instructions/") && item.route.endsWith(".instructions.md")) { + ownerRoute = "."; + rows.push(["copilot", ownerRoute]); } else if (item.route.includes("/.claude/rules/") || item.route.startsWith(".claude/rules/")) { ownerRoute = normalizeRoute(item.route.split("/.claude/rules/")[0] || "."); rows.push(["claude", ownerRoute]); @@ -332,13 +347,15 @@ export async function discoverWorkspaceStructure({ instructionScopes.push({ route: item.route, provider, - activation: item.route === ".github/copilot-instructions.md" + activation: provider === "cursor" && item.route === ".github/copilot-instructions.md" + ? "candidate" + : item.route.startsWith(".github/instructions/") ? "candidate" : scopeActivation(provider, scopeRoute, targetRoute), }); } - if (base === "AGENTS.md" || base === "CLAUDE.md") { + if (base === "AGENTS.md" || base === "CLAUDE.md" || base === "QWEN.md") { const conventionRoute = candidateConventionRoute(item.route, packageMarkerParents); if (conventionRoute) { const covered = [...members.values()].some((member) => routeContains(member.route, conventionRoute)); diff --git a/test/agent-lint-monorepo.test.mjs b/test/agent-lint-monorepo.test.mjs index 9e846cd..d65483f 100644 --- a/test/agent-lint-monorepo.test.mjs +++ b/test/agent-lint-monorepo.test.mjs @@ -16,6 +16,12 @@ function git(cwd, args) { const result = spawnSync("git", args, { cwd, encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: "1", + XDG_CONFIG_HOME: path.join(cwd, ".git-test-xdg"), + }, stdio: ["ignore", "pipe", "pipe"], }); if (result.status !== 0) { @@ -36,9 +42,11 @@ async function makeMonorepo() { "AGENTS.md": "# Root agents\n", "CLAUDE.md": "# Root Claude\n", "CLAUDE.local.md": "# Root local Claude\n", + "QWEN.md": "# Root Qwen\n", ".claude/rules/root.mdc": "# Root Claude rule\n", ".cursor/rules/root.md": "# Root Cursor rule\n", ".github/copilot-instructions.md": "# Root Copilot instructions\n", + ".github/instructions/backend.instructions.md": "---\napplyTo: packages/a/**\n---\n# Backend\n", ".qoder/rules/root.md": "# Root Qoder rule\n", ".qoder/rules/root.mdc": "# Root Qoder mdc rule\n", ".ci/AGENTS.md": "# CI agents\n", @@ -119,6 +127,31 @@ test("topology-backed root discovery inventories provider scopes without a neste assert.ok(cursorRoutes.includes("packages/a/.cursor/rules/local.mdc")); assert.ok(cursorRoutes.includes(".github/copilot-instructions.md")); assert.ok(!cursorRoutes.includes("CLAUDE.local.md")); + + const qwenRoutes = (await discoverAgentEntrypoints({ + workspace: path.join(repo, "packages/a"), + provider: "qwen", + topology, + })).map((entrypoint) => entrypoint.relativePath); + assert.ok(qwenRoutes.includes("AGENTS.md")); + assert.ok(qwenRoutes.includes("QWEN.md")); + + const copilotRoutes = (await discoverAgentEntrypoints({ + workspace: path.join(repo, "packages/a"), + provider: "copilot", + topology, + })).map((entrypoint) => entrypoint.relativePath); + assert.ok(copilotRoutes.includes("AGENTS.md")); + assert.ok(copilotRoutes.includes(".github/copilot-instructions.md")); + assert.ok(copilotRoutes.includes(".github/instructions/backend.instructions.md")); + + const piRoutes = (await discoverAgentEntrypoints({ + workspace: path.join(repo, "packages/a"), + provider: "pi", + topology, + })).map((entrypoint) => entrypoint.relativePath); + assert.ok(piRoutes.includes("AGENTS.md")); + assert.ok(!piRoutes.includes("CLAUDE.md")); } finally { await rm(repo, { recursive: true, force: true }); } diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index 6e1739d..93ee4fc 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -144,7 +144,21 @@ test("evidence bundle rejects a frozen topology for a different workspace", () = workspace: ".", topology: mismatched, analysisScope: resolution.analysisScope, - }, NOW), (error) => error?.code === "EVIDENCE_WORKSPACE_TOPOLOGY_MISMATCH"); + }, NOW), (error) => error?.code === "INVALID_WORKSPACE_TOPOLOGY" + && /target\.route must resolve from gitRoot to requestedWorkspace/u.test(error.message)); +}); + +test("evidence bundle rejects analysis pathspecs that are not derived from the frozen topology", () => { + const resolution = topologyResolution("."); + assert.throws(() => freezeEvidenceBundleContext({ + workspace: ".", + topology: resolution.topology, + analysisScope: { + kind: "repo", + route: ".", + pathspecs: [":(top,literal)scripts"], + }, + }, NOW), (error) => error?.code === "EVIDENCE_ANALYSIS_SCOPE_MISMATCH"); }); test("normal bundles fail closed and redact collector error details", async () => { diff --git a/test/session-workspace-provider.test.mjs b/test/session-workspace-provider.test.mjs index 80d0c1d..4db5c95 100644 --- a/test/session-workspace-provider.test.mjs +++ b/test/session-workspace-provider.test.mjs @@ -17,6 +17,15 @@ import { QoderSessionAnalyzer, workspaceToQoderSlug, } from "../scripts/session-analysis/platforms/qoder.mjs"; +import { + QwenSessionAnalyzer, + workspaceToQwenSlugVariants, +} from "../scripts/session-analysis/platforms/qwen.mjs"; +import { CopilotSessionAnalyzer } from "../scripts/session-analysis/platforms/copilot.mjs"; +import { + PiSessionAnalyzer, + workspaceToPiSessionDirVariants, +} from "../scripts/session-analysis/platforms/pi.mjs"; import { bindSessionWorkspaceCwds, hydrateWorkspaceSelection, @@ -155,6 +164,88 @@ function cursorRows(paths = [], prompt = null) { return rows; } +function qwenRows(sessionId, cwd, paths = [], prompt = null) { + const rows = [{ + type: "user", + sessionId, + cwd, + timestamp: "2026-07-20T10:00:00.000Z", + message: { role: "user", parts: [{ text: prompt ?? "Inspect the selected workspace" }] }, + }]; + for (const [index, filePath] of paths.entries()) { + rows.push({ + type: "assistant", + sessionId, + cwd, + timestamp: `2026-07-20T10:00:${String(index + 2).padStart(2, "0")}.000Z`, + message: { + role: "model", + parts: [{ functionCall: { id: `tool-${index + 1}`, name: "Read", args: { path: filePath } } }], + }, + }); + } + return rows; +} + +function copilotRows(sessionId, cwd, paths = [], prompt = null) { + const rows = [ + { + type: "session.start", + timestamp: "2026-07-20T10:00:00.000Z", + data: { sessionId, context: { cwd } }, + }, + { + type: "user.message", + timestamp: "2026-07-20T10:00:01.000Z", + data: { content: prompt ?? "Inspect the selected workspace" }, + }, + ]; + for (const [index, filePath] of paths.entries()) { + rows.push({ + type: "tool.execution_start", + timestamp: `2026-07-20T10:00:${String(index + 2).padStart(2, "0")}.000Z`, + data: { toolCallId: `tool-${index + 1}`, toolName: "Read", arguments: { path: filePath } }, + }); + } + return rows; +} + +function piRows(sessionId, cwd, paths = [], prompt = null) { + const rows = [ + { type: "session", version: 3, id: sessionId, cwd, timestamp: "2026-07-20T10:00:00.000Z" }, + { + type: "message", + id: `${sessionId}-user`, + timestamp: "2026-07-20T10:00:01.000Z", + message: { role: "user", content: [{ type: "text", text: prompt ?? "Inspect the selected workspace" }] }, + }, + ]; + if (paths.length > 0) { + rows.push({ + type: "message", + id: `${sessionId}-assistant`, + timestamp: "2026-07-20T10:00:02.000Z", + message: { + role: "assistant", + content: paths.map((filePath, index) => ({ + type: "toolCall", + id: `tool-${index + 1}`, + name: "read", + arguments: { path: filePath }, + })), + }, + }); + } + return rows; +} + +async function writeCopilotSession(home, sessionId, cwd, rows) { + const sessionDir = path.join(home, "session-state", sessionId); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "workspace.yaml"), `id: ${sessionId}\ncwd: ${cwd}\n`); + await writeJsonl(path.join(sessionDir, "events.jsonl"), rows); +} + async function writeCursorMeta(home, sessionId, cwd) { await writeJson(path.join(home, "chats", "workspace-hash", sessionId, "meta.json"), { schemaVersion: 1, @@ -582,3 +673,124 @@ test("Cursor requires chat metadata CWD and does not scan sibling transcript ide new RegExp(`${path.basename(workspace)}|${path.basename(sibling)}|private`, "u"), ); }); + +test("Qwen scans package and Git-root transcript identities with topology qualification", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-qwen-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".qwen"); + const topology = memberTopology(gitRoot, workspace); + const transcript = (identity, sessionId) => path.join( + home, + "projects", + workspaceToQwenSlugVariants(identity)[0], + "chats", + `${sessionId}.jsonl`, + ); + await writeJsonl(transcript(workspace, "direct-private"), qwenRows("direct-private", workspace)); + await writeJsonl( + transcript(gitRoot, "root-target-private"), + qwenRows("root-target-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ); + await writeJsonl( + transcript(gitRoot, "root-mixed-private"), + qwenRows("root-mixed-private", gitRoot, [path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeJsonl( + transcript(gitRoot, "root-prompt-private"), + qwenRows("root-prompt-private", gitRoot, [], `Read ${path.join(workspace, "prompt-only.ts")}`), + ); + + const analyzer = new QwenSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); +}); + +test("Copilot qualifies Git-root sessions from trusted tool paths", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-copilot-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".copilot"); + const topology = memberTopology(gitRoot, workspace); + await writeCopilotSession(home, "direct-private", workspace, copilotRows("direct-private", workspace)); + await writeCopilotSession( + home, + "root-target-private", + gitRoot, + copilotRows("root-target-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ); + await writeCopilotSession( + home, + "root-mixed-private", + gitRoot, + copilotRows("root-mixed-private", gitRoot, [path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeCopilotSession( + home, + "root-prompt-private", + gitRoot, + copilotRows("root-prompt-private", gitRoot, [], `Read ${path.join(workspace, "prompt-only.ts")}`), + ); + + const analyzer = new CopilotSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); +}); + +test("Pi scans package and Git-root session directories with topology qualification", async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), "session-pi-root-cwd-")); + const gitRoot = path.join(fixture, "repo"); + const workspace = path.join(gitRoot, "packages", "app"); + const sibling = path.join(gitRoot, "packages", "other"); + const home = path.join(fixture, ".pi", "agent"); + const topology = memberTopology(gitRoot, workspace); + const transcript = (identity, sessionId) => path.join( + home, + "sessions", + workspaceToPiSessionDirVariants(identity).exact, + `2026-07-20T10-00-00-000Z_${sessionId}.jsonl`, + ); + await writeJsonl(transcript(workspace, "direct-private"), piRows("direct-private", workspace)); + await writeJsonl( + transcript(gitRoot, "root-target-private"), + piRows("root-target-private", gitRoot, [path.join(workspace, "src", "a.ts")]), + ); + await writeJsonl( + transcript(gitRoot, "root-mixed-private"), + piRows("root-mixed-private", gitRoot, [path.join(workspace, "src", "a.ts"), path.join(sibling, "b.ts")]), + ); + await writeJsonl( + transcript(gitRoot, "root-prompt-private"), + piRows("root-prompt-private", gitRoot, [], `Read ${path.join(workspace, "prompt-only.ts")}`), + ); + + const analyzer = new PiSessionAnalyzer(); + const legacy = await analyzer.analyze({ command: "sessions", workspace, home }); + assert.deepEqual(legacy.sessions.map((session) => session.sessionId), ["direct-private"]); + + const result = await analyzer.analyze({ command: "sessions", workspace, home, topology }); + assert.deepEqual( + Object.fromEntries(result.sessions.map((session) => [session.sessionId, session.workspaceMatch])), + { "direct-private": "direct-cwd", "root-target-private": "root-cwd" }, + ); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.mixedActivity, 1); + assert.equal(result.sessionWorkspaceMatch.preflight.omitted.noTargetActivity, 1); +}); diff --git a/test/workspace-topology.test.mjs b/test/workspace-topology.test.mjs index 05aedcc..0073533 100644 --- a/test/workspace-topology.test.mjs +++ b/test/workspace-topology.test.mjs @@ -19,6 +19,12 @@ function git(cwd, args) { const result = spawnSync("git", args, { cwd, encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_NOSYSTEM: "1", + XDG_CONFIG_HOME: path.join(cwd, ".git-test-xdg"), + }, stdio: ["ignore", "pipe", "pipe"], }); if (result.status !== 0) { @@ -61,10 +67,12 @@ test("workspace topology resolves manifest members, instruction scopes, ignored }), "AGENTS.md": "# root\n", "CLAUDE.local.md": "# root local Claude\n", + "QWEN.md": "# root Qwen\n", ".claude/rules/root.mdc": "# root Claude rule\n", ".cursor/rules/root.md": "# root Cursor rule\n", ".qoder/rules/root.mdc": "# root Qoder rule\n", ".github/copilot-instructions.md": "# root Copilot instructions\n", + ".github/instructions/backend.instructions.md": "---\napplyTo: src/**\n---\n# backend\n", ".ci/AGENTS.md": "# operational scope\n", "packages/a/package.json": JSON.stringify({ name: "a" }), "packages/a/AGENTS.md": "# package a\n", @@ -114,10 +122,13 @@ test("workspace topology resolves manifest members, instruction scopes, ignored && item.activation === "candidate")); for (const [route, provider] of [ ["CLAUDE.local.md", "claude"], + ["QWEN.md", "qwen"], [".claude/rules/root.mdc", "claude"], [".cursor/rules/root.md", "cursor"], [".qoder/rules/root.mdc", "qoder"], [".github/copilot-instructions.md", "cursor"], + [".github/copilot-instructions.md", "copilot"], + [".github/instructions/backend.instructions.md", "copilot"], ["packages/a/.claude/rules/local.md", "claude"], ["packages/a/.cursor/rules/local.mdc", "cursor"], ["packages/a/.qoder/rules/local.mdc", "qoder"], @@ -125,6 +136,10 @@ test("workspace topology resolves manifest members, instruction scopes, ignored assert.ok(rootResult.topology.instructionScopes.items.some((item) => item.route === route && item.provider === provider), `${provider}:${route}`); } + for (const provider of ["qwen", "copilot", "pi"]) { + assert.ok(rootResult.topology.instructionScopes.items.some((item) => + item.route === "AGENTS.md" && item.provider === provider && item.activation === "effective")); + } const packageResult = await resolveWorkspaceTopology({ workspace: path.join(repo, "packages/a"), @@ -229,6 +244,21 @@ test("finding target rejects traversal and cross-package topology mismatches", ( }, { topology }).join("; "), /packageRoute does not match/u); }); +test("workspace topology rejects a target route that does not identify the requested workspace", async () => { + const resolved = await resolveWorkspaceTopology({ workspace: process.cwd() }); + const mismatched = structuredClone(resolved.topology); + mismatched.target = { + kind: "repo-subtree", + route: "scripts", + memberRoute: null, + memberMatch: "none", + }; + assert.throws( + () => validateWorkspaceTopology(mismatched), + /target\.route must resolve from gitRoot to requestedWorkspace/u, + ); +}); + test("workspace topology combines pnpm, Go, and Cargo members with exclusions", async () => { const repo = await makeGitRepo({ "pnpm-workspace.yaml": "packages:\n - 'web/*'\n", From ebdcfa6b19bbba669becc9831b0266c4f144c834 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 30 Jul 2026 17:13:35 +0800 Subject: [PATCH 5/5] fix(test): use Git null device on Windows Git for Windows rejects Node os.devNull as a GIT_CONFIG_GLOBAL path because it expands to the Win32 device form. Use the native NUL alias while preserving the existing hermetic global-config isolation on other platforms. The affected fixture suite passed 12/12 and the full local suite passed 977/977. The Windows GitHub Actions job provides the cross-platform verification for this branch. Co-authored-by: Codex (GPT 5.6 Sol) --- docs/specs/2026-07-25-monorepo-workspace-support.md | 3 +-- test/agent-lint-monorepo.test.mjs | 2 +- test/workspace-topology.test.mjs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/specs/2026-07-25-monorepo-workspace-support.md b/docs/specs/2026-07-25-monorepo-workspace-support.md index 44fbded..efef0b8 100644 --- a/docs/specs/2026-07-25-monorepo-workspace-support.md +++ b/docs/specs/2026-07-25-monorepo-workspace-support.md @@ -3,8 +3,7 @@ ## Traceability - Spec ID: `2026-07-25-monorepo-workspace-support` -- Status: Implemented and locally validated through AC15; external CI remains - to be rerun for the updated PR head. +- Status: Implemented and locally validated through AC15. - Implementation branch: `feat/monorepo-workspace-topology` - Story: unavailable; this is a maintainer-requested product correctness change. - AI involvement: Codex implemented the change and used independent delegated diff --git a/test/agent-lint-monorepo.test.mjs b/test/agent-lint-monorepo.test.mjs index d65483f..248f392 100644 --- a/test/agent-lint-monorepo.test.mjs +++ b/test/agent-lint-monorepo.test.mjs @@ -18,7 +18,7 @@ function git(cwd, args) { encoding: "utf8", env: { ...process.env, - GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : os.devNull, GIT_CONFIG_NOSYSTEM: "1", XDG_CONFIG_HOME: path.join(cwd, ".git-test-xdg"), }, diff --git a/test/workspace-topology.test.mjs b/test/workspace-topology.test.mjs index 0073533..611f5b7 100644 --- a/test/workspace-topology.test.mjs +++ b/test/workspace-topology.test.mjs @@ -21,7 +21,7 @@ function git(cwd, args) { encoding: "utf8", env: { ...process.env, - GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : os.devNull, GIT_CONFIG_NOSYSTEM: "1", XDG_CONFIG_HOME: path.join(cwd, ".git-test-xdg"), },