From 9ce7b509cfbd88d31d9f805053c452c2692f7771 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 1 Jun 2026 00:58:09 -0400 Subject: [PATCH 1/2] Enhance drift summaries and controls --- README.md | 12 +- codegraph-skill/codegraph/SKILL.md | 8 +- docs/agent-workflows.md | 6 +- docs/cli.md | 13 +- docs/library-api.md | 10 + .../plans/2026-06-01-drift-enhancements.md | 274 ++++++++++++++++++ src/cli/drift.ts | 53 +++- src/cli/help.ts | 5 +- src/cli/options.ts | 2 + src/drift/compare.ts | 97 ++++++- src/drift/git.ts | 61 +++- src/drift/index.ts | 4 + src/drift/report.ts | 9 +- src/drift/types.ts | 30 +- src/index.ts | 4 + tests/cli-command-modules.test.ts | 3 + tests/cli-regressions.test.ts | 93 ++++++ tests/drift-artifact.test.ts | 5 + tests/drift-git-provider.test.ts | 6 + tests/drift.test.ts | 96 ++++++ 20 files changed, 748 insertions(+), 43 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-01-drift-enhancements.md diff --git a/README.md b/README.md index 8a3e320d..34b65b35 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ node ./dist/cli.js apisurface node ./dist/cli.js duplicates ./src --min-confidence medium --limit 20 # compare architecture drift between refs -node ./dist/cli.js drift ./src --base origin/main --head HEAD --pretty +node ./dist/cli.js drift ./src --base origin/main --head HEAD --compact-json +node ./dist/cli.js drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals ``` If you install the published CLI instead of using a source checkout, replace `node ./dist/cli.js` with `codegraph`. @@ -219,8 +220,8 @@ References for buildProjectIndex Use drift, impact, and review for architecture regression and PR or worktree risk: ```bash -codegraph drift ./src --base origin/main --head HEAD --pretty -codegraph drift ./src --base origin/main --head HEAD --fail-on new-cycle,public-api-removal +codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals +codegraph drift ./src --base origin/main --head HEAD --compact-json codegraph impact --base origin/main --head HEAD --pretty codegraph review --base origin/main --head HEAD --summary ``` @@ -239,7 +240,7 @@ Review Summary Candidate tests: 4 (high: 1, medium: 2, low: 1) ``` -`drift` compares graph states over time. It reports structural signals such as new cycles, unresolved imports, public API removals, duplicate group count changes, hotspot jumps, and graph-edge changes; it is not runtime validation or compiler diagnostics. +`drift` compares graph states over time. It reports structural signals such as new cycles, unresolved imports, public API removals, duplicate group count changes, hotspot jumps, and graph-edge changes; it is not runtime validation or compiler diagnostics. Use `--graph-edges summary|off`, `--public-api removals|off`, and `--compact-json` to keep CI and review output bounded. Run duplicate detection directly when refactor risk is the question: @@ -318,6 +319,9 @@ const drift = await analyzeArchitectureDrift(root, { base: "origin/main", head: "HEAD", failOn: ["new-cycle", "public-api-removal"], + format: "compact", + graphEdges: "summary", + publicApi: "removals", }); const impact = await analyzeImpactFromDiff(root, index, { diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 66eb0529..47f4507c 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -48,7 +48,7 @@ Then choose the narrowest follow-up command: - Worktree impact: `codegraph impact --provider git --base HEAD --head WORKTREE --pretty` - Review handoff: `codegraph review --base HEAD --head WORKTREE --summary` - Full review JSON: `codegraph review --base origin/main --head HEAD` -- Architecture drift: `codegraph drift ./src --base origin/main --head HEAD --pretty` +- Architecture drift: `codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals` - Public API: `codegraph apisurface` - Duplicate cleanup: `codegraph duplicates --root . ./src --min-confidence medium` - Chunks: `codegraph chunk ` @@ -227,10 +227,12 @@ Prefer `refs` over plain text search when you want semantic usages rather than e - Agent-ready full current worktree bundle: `codegraph review --base HEAD --head WORKTREE` - Architecture drift: - `codegraph drift ./src --base origin/main --head HEAD --pretty` + `codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals` +- Compact CI drift output: + `codegraph drift ./src --base origin/main --head HEAD --compact-json` - CI-selected drift gates: `codegraph drift ./src --base origin/main --head HEAD --fail-on new-cycle,public-api-removal` - Drift compares structural architecture signals, not runtime behavior or compiler diagnostics. Duplicate increases are review or CI findings and only fail when selected by policy. + Drift compares structural architecture signals, not runtime behavior or compiler diagnostics. Use `--graph-edges` and `--public-api` to trim noisy findings without changing policy evaluation. Prefer impact `--pretty` first when the user asks what a change can break, what to test, or where a reviewer should focus. Use `review --summary` for compact model-readable handoffs, and use full review JSON when a script or tool step needs `projectFiles`, `graphDelta`, complete changed-symbol handles, or low-confidence fallback test candidates. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 52f1d93e..6e19d9f1 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -64,11 +64,11 @@ Use `artifact build` when the agent needs a durable handoff directory. The defau Use `drift` when the agent needs one architecture-regression report for a base/head range: ```bash -codegraph drift ./src --base origin/main --head HEAD --pretty -codegraph drift ./src --base origin/main --head HEAD --fail-on new-cycle,public-api-removal +codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals +codegraph drift ./src --base origin/main --head HEAD --compact-json ``` -Drift compares structural signals over time: dependency cycles, hotspots, unresolved imports, API surface changes, duplicate group counts, and graph edges. It is review and CI evidence, not runtime validation or compiler diagnostics. +Drift compares structural signals over time: dependency cycles, hotspots, unresolved imports, API surface changes, duplicate group counts, and graph edges. It is review and CI evidence, not runtime validation or compiler diagnostics. Use compact JSON for CI or agent handoff, and use graph-edge/API filters to keep human review output bounded. ## MCP server diff --git a/docs/cli.md b/docs/cli.md index a92f51e5..12452e6e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -159,8 +159,9 @@ codegraph duplicates ./src --raw-pairs codegraph duplicates --help # Compare architecture drift between git refs -codegraph drift ./src --base origin/main --head HEAD --pretty +codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals codegraph drift ./src --base origin/main --head HEAD --json +codegraph drift ./src --base origin/main --head HEAD --compact-json codegraph drift ./src --base origin/main --head HEAD --fail-on new-cycle,public-api-removal codegraph drift --base-artifact ./baseline/codegraph-out --head . --json @@ -327,12 +328,18 @@ codegraph graph-delta --git-base origin/main --git-head HEAD > graph-delta.json ```bash # Architecture drift with CI policy gates -codegraph drift ./src --base origin/main --head HEAD --pretty +codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals +codegraph drift ./src --base origin/main --head HEAD --compact-json codegraph drift ./src --base origin/main --head HEAD --fail-on new-cycle,unresolved-import,public-api-removal codegraph drift --base-artifact ./baseline/codegraph-out --head . --json ``` -`drift` compares architecture signals, not runtime behavior, compiler diagnostics, or style. Duplicate drift compares group counts and stable top group keys; duplicate increases are review or CI findings and only fail the process when selected by `--fail-on`. +`drift` compares architecture signals, not runtime behavior, compiler diagnostics, or style. + +- `--graph-edges full|summary|off` controls whether graph-edge churn is emitted per edge, summarized by source file, or suppressed. +- `--public-api all|removals|off` controls whether API additions are shown; removals stay the main review signal. +- `--compact-json` emits bounded machine-friendly JSON with summary counts and example findings. +- Duplicate drift compares group counts plus stable top-group deltas; duplicate increases are review or CI findings and only fail the process when selected by `--fail-on`. For git-provider impact, `--head` accepts normal revisions plus worktree sentinels. Use `WORKTREE` to compare the base revision against the current working tree, including staged and unstaged tracked-file changes. Use `STAGED` or `INDEX` to compare the base revision against the current index; with `--base HEAD`, that is staged changes only. Untracked files are not included until they are staged or otherwise tracked by Git. diff --git a/docs/library-api.md b/docs/library-api.md index c07e426f..3ee16ab7 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -588,9 +588,19 @@ const report = await analyzeArchitectureDrift(process.cwd(), { head: "HEAD", includeRoots: ["src"], failOn: ["new-cycle", "public-api-removal"], + graphEdges: "summary", + publicApi: "removals", + format: "compact", }); ``` +Drift callers can tune noise and payload size without changing the core comparison: + +- `graphEdges: "full" | "summary" | "off"` controls graph-edge churn detail. +- `publicApi: "all" | "removals" | "off"` controls whether API additions are emitted. +- `format: "compact"` emits bounded example findings plus `summary.byKind` and `summary.bySeverity`. +- Git-backed reports expose logical `base.ref` and `head.ref` values instead of temporary checkout paths. + The API returns `ArchitectureDriftReport` with `schemaVersion: 1`, base/head summaries, bounded findings, and policy state. Drift compares architecture signals only; it does not run code, typecheck, or lint. ### Programmatic review and impact output diff --git a/docs/superpowers/plans/2026-06-01-drift-enhancements.md b/docs/superpowers/plans/2026-06-01-drift-enhancements.md new file mode 100644 index 00000000..aae08472 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-drift-enhancements.md @@ -0,0 +1,274 @@ +# Drift Enhancements Plan + +**Goal:** Improve `codegraph drift` signal-to-noise and CI usefulness for real review workflows without widening the core snapshot model more than necessary. + +**Status:** Planning only. This document is the implementation checklist for the next drift enhancement batch. + +## Why this batch exists + +The first drift implementation works, but real branch usage exposed three practical problems: + +- `graph-edge-added` and `graph-edge-removed` findings are too verbose for normal review output. +- Duplicate drift is measurable but not very actionable yet. +- JSON and pretty output do not clearly separate human review, CI gating, and machine-consumption use cases. + +The improvements below focus on the highest-value workflow: compare a branch against `main`, surface only meaningful architectural changes, and make CI gating predictable. + +## Scope decisions after plan review + +Included in this batch: + +- Graph-edge noise controls. +- Richer duplicate drift summaries. +- Logical ref reporting instead of temp checkout roots. +- Public API severity controls. +- Compact drift output mode for CI and agents. + +Explicitly deferred from this batch: + +- Hotspot rank movement and fan-in/fan-out delta reporting. + - Useful, but not currently the biggest source of review noise. +- Full artifact parity for unresolved/API/duplicate signals. + - Higher cost than this batch and needs a separate artifact-schema decision. +- New persistence formats or storage changes. + - This batch should remain stateless and derivable from current snapshots. + +## Design constraints + +- Keep `ArchitectureDriftReport` deterministic for identical inputs. +- Preserve existing finding kinds; add fields and modes rather than replacing the contract. +- Do not make CI output depend on pretty rendering. +- Keep policy evaluation independent from display truncation. +- Prefer bounded summaries over dumping many raw findings. +- Avoid adding new expensive repo-wide passes when existing snapshot data is sufficient. + +## Proposed product changes + +### 1) Graph-edge noise controls + +Add a drift option to control graph-edge findings in reports: + +- `graphEdges: "full" | "summary" | "off"` +- Default: `"summary"` for pretty and compact output, `"full"` for full JSON + +Behavior: + +- `full`: keep per-edge findings as today. +- `summary`: omit per-edge findings and instead emit bounded summary entries with counts by changed file. +- `off`: suppress graph-edge findings entirely. + +Why: + +- Graph-edge churn is structurally correct but often overwhelms more important findings. +- Summary mode preserves architectural signal while making reviews readable. + +### 2) Richer duplicate drift reporting + +Extend duplicate drift beyond total group count: + +- Keep total group delta. +- Add bounded stable top-group diff details: + - `newTopGroupKeys` + - `resolvedTopGroupKeys` +- Pretty output should show a small number of representative new/resolved keys. + +Why: + +- Reviewers need to know whether duplicate growth is concentrated in meaningful new groups or just count churn. + +### 3) Logical ref reporting + +Change report metadata so git-mode reports logical refs instead of temp checkout paths. + +Proposed shape refinement: + +- Keep `root` as the real project root. +- Add logical source fields: + - `base.ref` + - `head.ref` +- Keep temp materialization roots internal, not public contract data. + +Why: + +- Current temp paths are implementation details and make machine output noisier than necessary. + +### 4) Public API severity controls + +Add public API filtering/severity policy controls: + +- `publicApi: "all" | "removals" | "off"` +- Default behavior: + - removals remain high-signal + - additions stay visible in full JSON, but may be suppressed from compact/pretty unless explicitly requested + +Why: + +- Feature branches often add many exports; surfacing all additions by default makes drift noisy. + +### 5) Compact output mode + +Add a bounded machine-friendly compact drift mode: + +- CLI: `--compact-json` +- Library: `format?: "full" | "compact"` + +Compact payload should include: + +- `schemaVersion` +- `root` +- logical base/head refs when available +- counts by kind and severity +- policy result +- bounded example findings for each reported kind +- omitted counts + +Why: + +- CI and agent workflows usually need counts, policy state, and a few examples, not the full finding list. + +## Report shape changes + +These changes are intentionally additive. + +### Full report additions + +```ts +interface ArchitectureDriftReport { + schemaVersion: 1; + format?: "full" | "compact"; + root: string; + base: ArchitectureSnapshotSummary & { ref?: string }; + head: ArchitectureSnapshotSummary & { ref?: string }; + findings: ArchitectureDriftFinding[]; + summary?: { + byKind: Record; + bySeverity: Record; + }; + policy: { + failed: boolean; + failOn: ArchitectureDriftFindingKind[]; + failedKinds: ArchitectureDriftFindingKind[]; + }; + omittedCounts: { + findings: number; + }; +} +``` + +### New drift options + +```ts +interface ArchitectureDriftCompareOptions { + failOn?: ArchitectureDriftFindingKind[]; + thresholds?: Partial; + graphEdges?: "full" | "summary" | "off"; + publicApi?: "all" | "removals" | "off"; + format?: "full" | "compact"; +} +``` + +## CLI changes + +Add: + +```text +--graph-edges +--public-api +--compact-json +``` + +Rules: + +- `--json` => full JSON unless `--compact-json` is passed. +- `--pretty` => human summary using summary graph-edge mode by default. +- `--compact-json` implies JSON output and compact format. +- Reject invalid enum values with usage error and valid choices. + +## Implementation checklist + +### Task 1: Finalize refined contract + +- [x] Update the plan if implementation reveals a smaller, cleaner contract. +- [x] Add option/type tests first for new compare options and output format metadata. +- [x] Keep all new fields additive and backwards-compatible. + +### Task 2: Add failing tests for graph-edge controls + +- [x] Add unit tests for `graphEdges: "full" | "summary" | "off"`. +- [x] Add CLI tests for `--graph-edges summary` and `--graph-edges off`. +- [x] Assert summary mode reduces per-edge output while preserving counts. + +### Task 3: Implement graph-edge controls + +- [x] Add compare/report support for graph-edge summary suppression. +- [x] Add compact summary entries grouped by source file. +- [x] Keep full JSON behavior unchanged unless caller requests summary/off. + +### Task 4: Add failing tests for duplicate drift details + +- [x] Add unit tests that verify top-group diff details are reported deterministically. +- [x] Add pretty/compact output tests for bounded duplicate examples. + +### Task 5: Implement richer duplicate drift reporting + +- [x] Extend duplicate findings/details with bounded stable top-group diffs. +- [x] Keep total duplicate delta as the primary policy signal. +- [x] Bound output carefully to avoid dumping large duplicate key lists. + +### Task 6: Add failing tests for logical ref reporting + +- [x] Add git-mode tests asserting `base.ref` and `head.ref` use logical refs, not temp paths. +- [x] Add artifact-mode tests asserting artifact baselines report artifact identity sensibly. + +### Task 7: Implement logical ref reporting + +- [x] Store logical base/head identifiers in reports. +- [x] Remove temp checkout paths from the public drift contract. +- [x] Keep actual project root stable across modes. + +### Task 8: Add failing tests for public API controls + +- [x] Add unit tests for `publicApi: "all" | "removals" | "off"`. +- [x] Add CLI tests for `--public-api removals` and `--public-api off`. +- [x] Verify removals still participate in fail-on policy when enabled. + +### Task 9: Implement public API controls + +- [x] Filter or suppress API-addition findings according to mode. +- [x] Preserve full data for callers that explicitly request `all`. + +### Task 10: Add failing tests for compact output + +- [x] Add unit tests for `format: "compact"`. +- [x] Add CLI regression tests for `--compact-json`. +- [x] Verify compact output includes counts, policy state, omitted counts, and bounded examples. + +### Task 11: Implement compact output mode + +- [x] Add compact formatter/builder without weakening the full report. +- [x] Reuse one comparison pipeline; derive compact output from full findings rather than re-running analysis. + +### Task 12: Update docs and skill + +- [x] Update `README.md` examples if default review recommendations change. +- [x] Update `docs/cli.md` for new drift flags and compact mode. +- [x] Update `docs/library-api.md` for new compare options and logical ref metadata. +- [x] Update `docs/agent-workflows.md` and `codegraph-skill/codegraph/SKILL.md` for CI/review usage. + +### Task 13: Final verification + +- [x] Run targeted drift tests first. +- [x] Run CLI drift regressions. +- [x] Run `npm run lint`. +- [x] Run `npm run build`. +- [x] Run `npm run test:ci`. +- [x] Run `git diff --check`. + +## Plan review notes + +This plan was pruned and refined before implementation: + +- Dropped hotspot rank movement from this batch because it adds complexity without solving the main noise problem. +- Dropped artifact-signal parity expansion because it likely needs new artifact data, which is a separate design track. +- Chose additive contract changes to avoid destabilizing the just-shipped drift API. +- Chose summary controls over new raw finding kinds where the use case is presentation, not deeper semantic analysis. diff --git a/src/cli/drift.ts b/src/cli/drift.ts index 72fb7486..ad2112d6 100644 --- a/src/cli/drift.ts +++ b/src/cli/drift.ts @@ -1,5 +1,13 @@ -import { analyzeArchitectureDrift, ARCHITECTURE_DRIFT_FINDING_KINDS, renderArchitectureDriftReport } from "../drift/index.js"; -import type { ArchitectureDriftFindingKind } from "../drift/types.js"; +import { + analyzeArchitectureDrift, + ARCHITECTURE_DRIFT_FINDING_KINDS, + renderArchitectureDriftReport, +} from "../drift/index.js"; +import type { + ArchitectureDriftFindingKind, + ArchitectureDriftGraphEdgesMode, + ArchitectureDriftPublicApiMode, +} from "../drift/types.js"; import type { GraphBuildOptions } from "../graphs/types.js"; import type { BuildOptions } from "../indexer/types.js"; import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; @@ -20,6 +28,8 @@ export interface DriftCommandContext { } const findingKindSet = new Set(ARCHITECTURE_DRIFT_FINDING_KINDS); +const graphEdgesModes = new Set(["full", "summary", "off"]); +const publicApiModes = new Set(["all", "removals", "off"]); function parseFailOn(rawValue: string | undefined): ArchitectureDriftFindingKind[] { if (!rawValue) return []; @@ -34,14 +44,34 @@ function parseFailOn(rawValue: string | undefined): ArchitectureDriftFindingKind return Array.from(new Set(values)) as ArchitectureDriftFindingKind[]; } +function parseGraphEdgesMode(rawValue: string | undefined): ArchitectureDriftGraphEdgesMode | undefined { + if (rawValue === undefined) return undefined; + if (graphEdgesModes.has(rawValue as ArchitectureDriftGraphEdgesMode)) { + return rawValue as ArchitectureDriftGraphEdgesMode; + } + throw new Error(`Invalid --graph-edges value "${rawValue}". Valid values: full, summary, off.`); +} + +function parsePublicApiMode(rawValue: string | undefined): ArchitectureDriftPublicApiMode | undefined { + if (rawValue === undefined) return undefined; + if (publicApiModes.has(rawValue as ArchitectureDriftPublicApiMode)) { + return rawValue as ArchitectureDriftPublicApiMode; + } + throw new Error(`Invalid --public-api value "${rawValue}". Valid values: all, removals, off.`); +} + export async function handleDriftCommand(context: DriftCommandContext): Promise { let failOn: ArchitectureDriftFindingKind[]; let hotspotJump: number | undefined; let maxFindings: number; + let graphEdges: ArchitectureDriftGraphEdgesMode | undefined; + let publicApi: ArchitectureDriftPublicApiMode | undefined; try { failOn = parseFailOn(context.getOpt("--fail-on")); hotspotJump = parseOptionalNonNegativeIntegerOption(context.getOpt("--hotspot-jump-threshold"), "--hotspot-jump-threshold"); maxFindings = parseNonNegativeIntegerOption(context.getOpt("--limit"), "--limit", 100); + graphEdges = parseGraphEdgesMode(context.getOpt("--graph-edges")); + publicApi = parsePublicApiMode(context.getOpt("--public-api")); } catch (error) { context.writeStderrLine(error instanceof Error ? error.message : String(error)); context.exit(2); @@ -54,13 +84,18 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< context.exit(2); } if (!base && !baseArtifact) { - context.writeStderrLine("Usage: codegraph drift [roots...] --base [--head ] [--json | --pretty]"); + context.writeStderrLine("Usage: codegraph drift [roots...] [--root ] (--base | --base-artifact ) [--head ] [--json | --pretty]"); context.writeStderrLine("Provide either --base or --base-artifact."); context.exit(2); } + const compactJson = context.hasFlag("--compact-json"); + const pretty = context.hasFlag("--pretty"); + const effectiveGraphEdges = graphEdges ?? (pretty ? "summary" : undefined); + const effectivePublicApi = publicApi ?? (pretty || compactJson ? "removals" : undefined); + const head = context.getOpt("--head"); - let report; + let report: Awaited>; try { report = await analyzeArchitectureDrift(context.projectRootFs, { ...(base ? { provider: "git" as const, base } : {}), @@ -72,6 +107,9 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< ...(hotspotJump !== undefined ? { hotspotJump } : {}), maxFindings, }, + ...(effectiveGraphEdges !== undefined ? { graphEdges: effectiveGraphEdges } : {}), + ...(effectivePublicApi !== undefined ? { publicApi: effectivePublicApi } : {}), + ...(compactJson ? { format: "compact" as const } : {}), ...(context.graphOptions ? { graph: context.graphOptions } : {}), ...(context.indexOptions ? { index: context.indexOptions } : {}), ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), @@ -80,13 +118,12 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< context.writeStderrLine(error instanceof Error ? error.message : String(error)); context.exit(1); } - - if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { - context.writeJSONLine(report); - } else { + if (pretty) { for (const line of renderArchitectureDriftReport(report, { limit: maxFindings }).trimEnd().split("\n")) { context.writeStdoutLine(line); } + } else { + context.writeJSONLine(report); } if (report.policy.failed) { diff --git a/src/cli/help.ts b/src/cli/help.ts index 87b0af94..0fbeca5b 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -251,7 +251,7 @@ Output: export const DRIFT_HELP_TEXT = `codegraph drift - Compare architecture drift between graph states -Usage: codegraph drift [roots...] [--root ] (--base | --base-artifact ) [--head ] [--json | --pretty] [--fail-on ] [--hotspot-jump-threshold ] [--limit ] +Usage: codegraph drift [roots...] [--root ] (--base | --base-artifact ) [--head ] [--json | --pretty | --compact-json] [--fail-on ] [--hotspot-jump-threshold ] [--limit ] [--graph-edges ] [--public-api ] Signals: Compares dependency cycles, hotspots, unresolved imports, public API symbols, duplicate group counts, and graph edges. @@ -262,6 +262,9 @@ Options: --fail-on Exit 1 only when one of the selected finding kinds is present. --hotspot-jump-threshold Minimum absolute hotspot score delta to report. --limit Maximum findings to emit in the report output. + --graph-edges Graph edge detail mode: full, summary, or off. + --public-api Public API finding mode: all, removals, or off. + --compact-json Emit compact JSON with summary counts and bounded examples. `; export function helpTextForCommand(command: string, positionals: readonly string[]): string | undefined { diff --git a/src/cli/options.ts b/src/cli/options.ts index f0289706..c6e79fa2 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -58,6 +58,8 @@ const CLI_VALUE_OPTIONS = new Set([ "--limit", "--fail-on", "--hotspot-jump-threshold", + "--graph-edges", + "--public-api", "--budget", "--mode", "--from", diff --git a/src/drift/compare.ts b/src/drift/compare.ts index 632529f7..ed3484f7 100644 --- a/src/drift/compare.ts +++ b/src/drift/compare.ts @@ -3,7 +3,10 @@ import type { ArchitectureDriftCompareOptions, ArchitectureDriftFinding, ArchitectureDriftFindingKind, + ArchitectureDriftGraphEdgesMode, + ArchitectureDriftPublicApiMode, ArchitectureDriftReport, + ArchitectureDriftSummary, ArchitectureDriftThresholds, ArchitectureGraphEdge, ArchitectureHotspot, @@ -162,11 +165,15 @@ function comparePublicApi( findings: ArchitectureDriftFinding[], base: readonly ArchitecturePublicApiSymbol[], head: readonly ArchitecturePublicApiSymbol[], + mode: ArchitectureDriftPublicApiMode, ): void { + if (mode === "off") return; const baseById = byId(base); const headById = byId(head); - for (const symbol of headById.values()) { - if (!baseById.has(symbol.id)) pushPublicApi(findings, "public-api-addition", symbol); + if (mode === "all") { + for (const symbol of headById.values()) { + if (!baseById.has(symbol.id)) pushPublicApi(findings, "public-api-addition", symbol); + } } for (const symbol of baseById.values()) { if (!headById.has(symbol.id)) pushPublicApi(findings, "public-api-removal", symbol); @@ -185,6 +192,12 @@ function compareDuplicates(findings: ArchitectureDriftFinding[], base: Architect const after = head.duplicates.groups.total; if (before === after) return; const kind: ArchitectureDriftFindingKind = after > before ? "duplicate-increase" : "duplicate-decrease"; + const baseTopGroupKeys = base.duplicates.topGroupKeys; + const headTopGroupKeys = head.duplicates.topGroupKeys; + const baseSet = new Set(baseTopGroupKeys); + const headSet = new Set(headTopGroupKeys); + const newTopGroupKeys = headTopGroupKeys.filter((key) => !baseSet.has(key)); + const resolvedTopGroupKeys = baseTopGroupKeys.filter((key) => !headSet.has(key)); findings.push({ kind, severity: after > before ? "warning" : "info", @@ -193,8 +206,10 @@ function compareDuplicates(findings: ArchitectureDriftFinding[], base: Architect before, after, details: { - baseTopGroupKeys: base.duplicates.topGroupKeys, - headTopGroupKeys: head.duplicates.topGroupKeys, + baseTopGroupKeys, + headTopGroupKeys, + newTopGroupKeys, + resolvedTopGroupKeys, }, }); } @@ -214,18 +229,56 @@ function pushGraphEdge( }); } +function pushGraphEdgeSummary( + findings: ArchitectureDriftFinding[], + kind: "graph-edge-added" | "graph-edge-removed", + file: string, + count: number, +): void { + findings.push({ + kind, + severity: "info", + key: `${kind}:${file}`, + title: `${kind === "graph-edge-added" ? "Graph edges added" : "Graph edges removed"}: ${file} (${count})`, + file, + details: { count }, + }); +} + function compareGraphEdges( findings: ArchitectureDriftFinding[], base: readonly ArchitectureGraphEdge[], head: readonly ArchitectureGraphEdge[], + mode: ArchitectureDriftGraphEdgesMode, ): void { + if (mode === "off") return; const baseByKey = byKey(base); const headByKey = byKey(head); + if (mode === "full") { + for (const edge of headByKey.values()) { + if (!baseByKey.has(edge.key)) pushGraphEdge(findings, "graph-edge-added", edge); + } + for (const edge of baseByKey.values()) { + if (!headByKey.has(edge.key)) pushGraphEdge(findings, "graph-edge-removed", edge); + } + return; + } + + const addedByFile = new Map(); + const removedByFile = new Map(); for (const edge of headByKey.values()) { - if (!baseByKey.has(edge.key)) pushGraphEdge(findings, "graph-edge-added", edge); + if (baseByKey.has(edge.key)) continue; + addedByFile.set(edge.from, (addedByFile.get(edge.from) ?? 0) + 1); } for (const edge of baseByKey.values()) { - if (!headByKey.has(edge.key)) pushGraphEdge(findings, "graph-edge-removed", edge); + if (headByKey.has(edge.key)) continue; + removedByFile.set(edge.from, (removedByFile.get(edge.from) ?? 0) + 1); + } + for (const file of Array.from(addedByFile.keys()).sort()) { + pushGraphEdgeSummary(findings, "graph-edge-added", file, addedByFile.get(file) ?? 0); + } + for (const file of Array.from(removedByFile.keys()).sort()) { + pushGraphEdgeSummary(findings, "graph-edge-removed", file, removedByFile.get(file) ?? 0); } } @@ -238,6 +291,32 @@ function compareFindings(left: ArchitectureDriftFinding, right: ArchitectureDrif return left.key.localeCompare(right.key); } +function summarizeFindings(findings: readonly ArchitectureDriftFinding[]): ArchitectureDriftSummary { + const byKind: Partial> = {}; + const bySeverity: Partial> = {}; + for (const finding of findings) { + byKind[finding.kind] = (byKind[finding.kind] ?? 0) + 1; + bySeverity[finding.severity] = (bySeverity[finding.severity] ?? 0) + 1; + } + return { byKind, bySeverity }; +} + +function effectivePublicApiMode(options: ArchitectureDriftCompareOptions): ArchitectureDriftPublicApiMode { + if (options.publicApi) return options.publicApi; + if (options.format === "compact") return "removals"; + return "all"; +} + +function effectiveGraphEdgesMode(options: ArchitectureDriftCompareOptions): ArchitectureDriftGraphEdgesMode { + if (options.graphEdges) return options.graphEdges; + if (options.format === "compact") return "summary"; + return "full"; +} + +function effectiveFormat(options: ArchitectureDriftCompareOptions): "full" | "compact" { + return options.format ?? "full"; +} + export function compareArchitectureSnapshots( base: ArchitectureSnapshot, head: ArchitectureSnapshot, @@ -251,12 +330,12 @@ export function compareArchitectureSnapshots( compareUnresolved(findings, base.unresolved.imports, head.unresolved.imports); } if (signalEnabled(base, "publicApi") && signalEnabled(head, "publicApi")) { - comparePublicApi(findings, base.publicApi, head.publicApi); + comparePublicApi(findings, base.publicApi, head.publicApi, effectivePublicApiMode(options)); } if (signalEnabled(base, "duplicates") && signalEnabled(head, "duplicates")) { compareDuplicates(findings, base, head); } - compareGraphEdges(findings, base.graphEdges, head.graphEdges); + compareGraphEdges(findings, base.graphEdges, head.graphEdges, effectiveGraphEdgesMode(options)); findings.sort(compareFindings); const limitedFindings = findings.slice(0, thresholds.maxFindings); @@ -267,10 +346,12 @@ export function compareArchitectureSnapshots( return { schemaVersion: 1, + format: effectiveFormat(options), root: head.root, base: summarize(base), head: summarize(head), findings: limitedFindings, + summary: summarizeFindings(findings), policy: { failed: !!failedKinds.length, failOn, diff --git a/src/drift/git.ts b/src/drift/git.ts index 125cb327..a3470f3f 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { isGitIndexSentinel, isGitWorktreeSentinel } from "../util/git.js"; +import { normalizePath } from "../util/paths.js"; import { compareArchitectureSnapshots } from "./compare.js"; import { buildArchitectureSnapshot } from "./snapshot.js"; import { loadArchitectureSnapshotFromArtifact } from "./artifact.js"; @@ -63,6 +64,29 @@ async function materializeGitRef(root: string, ref: string | undefined, prefix: } } +function withReportRefs( + report: ArchitectureDriftReport, + root: string, + refs: { baseRef?: string; headRef?: string; baseRoot?: string; headRoot?: string }, +): ArchitectureDriftReport { + const normalizedRoot = normalizePath(path.resolve(root)); + return { + ...report, + root: normalizedRoot, + base: { + ...report.base, + root: refs.baseRoot ?? normalizedRoot, + ...(refs.baseRef !== undefined ? { ref: refs.baseRef } : {}), + }, + head: { + ...report.head, + root: refs.headRoot ?? normalizedRoot, + ...(refs.headRef !== undefined ? { ref: refs.headRef } : {}), + }, + }; +} + + export async function analyzeArchitectureDrift( root: string, options: ArchitectureDriftOptions, @@ -77,10 +101,21 @@ export async function analyzeArchitectureDrift( } const baseSnapshot = await loadArchitectureSnapshotFromArtifact(options.baseArtifact); const headSnapshot = await buildArchitectureSnapshot(path.resolve(root), snapshotOptions(options)); - return compareArchitectureSnapshots(baseSnapshot, headSnapshot, { - ...(options.failOn ? { failOn: options.failOn } : {}), - ...(options.thresholds ? { thresholds: options.thresholds } : {}), - }); + return withReportRefs( + compareArchitectureSnapshots(baseSnapshot, headSnapshot, { + ...(options.failOn ? { failOn: options.failOn } : {}), + ...(options.graphEdges ? { graphEdges: options.graphEdges } : {}), + ...(options.publicApi ? { publicApi: options.publicApi } : {}), + ...(options.format ? { format: options.format } : {}), + ...(options.thresholds ? { thresholds: options.thresholds } : {}), + }), + root, + { + baseRef: `artifact:${normalizePath(path.resolve(options.baseArtifact))}`, + baseRoot: normalizePath(path.resolve(options.baseArtifact)), + headRef: options.head ?? ".", + }, + ); } if (!options.base) { throw new Error("Architecture drift requires --base or --base-artifact."); @@ -93,10 +128,20 @@ export async function analyzeArchitectureDrift( head = await materializeGitRef(resolvedRoot, options.head, "cg-drift-head-"); const baseSnapshot = await buildArchitectureSnapshot(base.root, snapshotOptions(options)); const headSnapshot = await buildArchitectureSnapshot(head.root, snapshotOptions(options)); - return compareArchitectureSnapshots(baseSnapshot, headSnapshot, { - ...(options.failOn ? { failOn: options.failOn } : {}), - ...(options.thresholds ? { thresholds: options.thresholds } : {}), - }); + return withReportRefs( + compareArchitectureSnapshots(baseSnapshot, headSnapshot, { + ...(options.failOn ? { failOn: options.failOn } : {}), + ...(options.graphEdges ? { graphEdges: options.graphEdges } : {}), + ...(options.publicApi ? { publicApi: options.publicApi } : {}), + ...(options.format ? { format: options.format } : {}), + ...(options.thresholds ? { thresholds: options.thresholds } : {}), + }), + resolvedRoot, + { + baseRef: options.base, + headRef: options.head ?? ".", + }, + ); } finally { await cleanupTempDir(head?.cleanup); await cleanupTempDir(base?.cleanup); diff --git a/src/drift/index.ts b/src/drift/index.ts index 2cdcb084..aac5bbbf 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -12,10 +12,14 @@ export type { ArchitectureDriftCompareOptions, ArchitectureDriftFinding, ArchitectureDriftFindingKind, + ArchitectureDriftFormat, + ArchitectureDriftGraphEdgesMode, ArchitectureDriftOptions, ArchitectureDriftProvider, + ArchitectureDriftPublicApiMode, ArchitectureDriftReport, ArchitectureDriftSeverity, + ArchitectureDriftSummary, ArchitectureDriftThresholds, ArchitectureDuplicateSummary, ArchitectureGraphEdge, diff --git a/src/drift/report.ts b/src/drift/report.ts index 7c1b97ca..a5395b63 100644 --- a/src/drift/report.ts +++ b/src/drift/report.ts @@ -25,7 +25,14 @@ function findingSubject(finding: ArchitectureDriftFinding): string { return `${finding.file ?? finding.key} imports ${finding.specifier ?? ""}`.trimEnd(); } if (finding.kind === "duplicate-increase" || finding.kind === "duplicate-decrease") { - return `groups ${finding.before ?? 0} -> ${finding.after ?? 0}`; + const newKeys = Array.isArray(finding.details?.newTopGroupKeys) ? finding.details.newTopGroupKeys.length : 0; + const resolvedKeys = Array.isArray(finding.details?.resolvedTopGroupKeys) ? finding.details.resolvedTopGroupKeys.length : 0; + return `groups ${finding.before ?? 0} -> ${finding.after ?? 0} (top +${newKeys}/-${resolvedKeys})`; + } + if (finding.kind === "graph-edge-added" || finding.kind === "graph-edge-removed") { + if (typeof finding.details?.count === "number") { + return `${finding.file ?? finding.key} (${finding.details.count} edges)`; + } } if (finding.edge) { return `${finding.edge.from} -> ${finding.edge.to}`; diff --git a/src/drift/types.ts b/src/drift/types.ts index d1040200..db9b979d 100644 --- a/src/drift/types.ts +++ b/src/drift/types.ts @@ -85,10 +85,27 @@ export interface ArchitectureSnapshot { signalAvailability?: ArchitectureSignalAvailability; } -export type ArchitectureSnapshotSummary = Pick< - ArchitectureSnapshot, - "root" | "files" | "hotspots" | "cycles" | "unresolved" | "publicApi" | "duplicates" ->; +export interface ArchitectureSnapshotSummary { + root: string; + ref?: string; + files: ArchitectureSnapshot["files"]; + hotspots: ArchitectureHotspot[]; + cycles: ArchitectureCycle[]; + unresolved: ArchitectureUnresolvedImportSummary; + publicApi: ArchitecturePublicApiSymbol[]; + duplicates: ArchitectureDuplicateSummary; +} + +export interface ArchitectureDriftSummary { + byKind: Partial>; + bySeverity: Partial>; +} + +export type ArchitectureDriftFormat = "full" | "compact"; + +export type ArchitectureDriftGraphEdgesMode = "full" | "summary" | "off"; + +export type ArchitectureDriftPublicApiMode = "all" | "removals" | "off"; export interface ArchitectureDriftFinding { kind: ArchitectureDriftFindingKind; @@ -107,10 +124,12 @@ export interface ArchitectureDriftFinding { export interface ArchitectureDriftReport { schemaVersion: 1; + format: ArchitectureDriftFormat; root: string; base: ArchitectureSnapshotSummary; head: ArchitectureSnapshotSummary; findings: ArchitectureDriftFinding[]; + summary: ArchitectureDriftSummary; policy: { failed: boolean; failOn: ArchitectureDriftFindingKind[]; @@ -129,6 +148,9 @@ export interface ArchitectureDriftThresholds { export interface ArchitectureDriftCompareOptions { failOn?: ArchitectureDriftFindingKind[]; thresholds?: Partial; + format?: ArchitectureDriftFormat; + graphEdges?: ArchitectureDriftGraphEdgesMode; + publicApi?: ArchitectureDriftPublicApiMode; } export interface ArchitectureSnapshotOptions { diff --git a/src/index.ts b/src/index.ts index f7f33c65..9a829bd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -367,10 +367,14 @@ export { type ArchitectureDriftCompareOptions, type ArchitectureDriftFinding, type ArchitectureDriftFindingKind, + type ArchitectureDriftFormat, + type ArchitectureDriftGraphEdgesMode, type ArchitectureDriftOptions, type ArchitectureDriftProvider, + type ArchitectureDriftPublicApiMode, type ArchitectureDriftReport, type ArchitectureDriftSeverity, + type ArchitectureDriftSummary, type ArchitectureDriftThresholds, type ArchitectureDuplicateSummary, type ArchitectureGraphEdge, diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 6f74f7be..d5937004 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -254,6 +254,9 @@ describe("CLI command modules", () => { expect(result.stdout).toContain("--limit"); expect(result.stdout).toContain("--hotspot-jump-threshold"); expect(result.stdout).toContain("--head "); + expect(result.stdout).toContain("--graph-edges "); + expect(result.stdout).toContain("--public-api "); + expect(result.stdout).toContain("--compact-json"); expect(result.stdout).toContain("with --base-artifact, only the current checkout is supported"); }); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index 750f9311..fb75119a 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1538,6 +1538,99 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) } }); + it("drift compact json summarizes graph edges and suppresses API additions by default", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-compact-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "export function a() { return 1; }\n", "utf8"); + git(root, ["init"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + await fsp.writeFile(path.join(root, "src", "b.ts"), "import { a } from './a';\nexport function b() { return a(); }\n", "utf8"); + await fsp.writeFile(path.join(root, "src", "a.ts"), "import { b } from './b';\nexport function a() { return b(); }\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "head"]); + + const stdout = await runCliCommand([ + "drift", + "src", + "--root", + root, + "--base", + "HEAD~1", + "--head", + "HEAD", + "--compact-json", + ]); + const report = JSON.parse(stdout) as { + format: string; + summary: { byKind: Record }; + findings: Array<{ kind: string }>; + }; + + expect(report.format).toBe("compact"); + expect(report.summary.byKind["graph-edge-added"]).toBeGreaterThan(0); + expect(report.findings.some((finding) => finding.kind === "graph-edge-added")).toBe(true); + expect(report.findings.some((finding) => finding.kind === "public-api-addition")).toBe(false); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("drift CLI supports graph edge filtering", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-graph-edges-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "export function a() { return 1; }\n", "utf8"); + git(root, ["init"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + await fsp.writeFile(path.join(root, "src", "b.ts"), "import { a } from './a';\nexport function b() { return a(); }\n", "utf8"); + await fsp.writeFile(path.join(root, "src", "a.ts"), "import { b } from './b';\nexport function a() { return b(); }\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "head"]); + + const summary = JSON.parse( + await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD~1", "--head", "HEAD", "--graph-edges", "summary"]), + ) as { findings: Array<{ kind: string; details?: { count?: number } }> }; + const off = JSON.parse( + await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD~1", "--head", "HEAD", "--graph-edges", "off"]), + ) as { findings: Array<{ kind: string }> }; + + expect(summary.findings.some((finding) => finding.kind === "graph-edge-added" && finding.details?.count === 1)).toBe(true); + expect(off.findings.some((finding) => finding.kind === "graph-edge-added")).toBe(false); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("drift CLI supports public API filtering", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-public-api-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "api.ts"), "export function oldName() { return 1; }\n", "utf8"); + git(root, ["init"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + await fsp.writeFile(path.join(root, "src", "api.ts"), "export function newName() { return 2; }\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "head"]); + + const removals = JSON.parse( + await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD~1", "--head", "HEAD", "--public-api", "removals"]), + ) as { findings: Array<{ kind: string }> }; + const off = JSON.parse( + await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD~1", "--head", "HEAD", "--public-api", "off"]), + ) as { findings: Array<{ kind: string }> }; + + expect(removals.findings.some((finding) => finding.kind === "public-api-removal")).toBe(true); + expect(removals.findings.some((finding) => finding.kind === "public-api-addition")).toBe(false); + expect(off.findings.some((finding) => finding.kind.startsWith("public-api"))).toBe(false); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + it("rejects using both --base and --base-artifact", async () => { const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-conflict-")); try { diff --git a/tests/drift-artifact.test.ts b/tests/drift-artifact.test.ts index 4ecd5fad..56a4aac3 100644 --- a/tests/drift-artifact.test.ts +++ b/tests/drift-artifact.test.ts @@ -152,6 +152,11 @@ describe("architecture drift artifact baselines", () => { const report = await analyzeArchitectureDrift(root, { baseArtifact: outDir, head: ".", includeRoots: ["src"] }); expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle" })); + + expect(report.root.replace(/\\/g, "/")).toBe(root.replace(/\\/g, "/")); + expect(report.base.ref).toBe(`artifact:${outDir.replace(/\\/g, "/")}`); + expect(report.base.root.replace(/\\/g, "/")).toBe(outDir.replace(/\\/g, "/")); + expect(report.head.root.replace(/\\/g, "/")).toBe(root.replace(/\\/g, "/")); }); it("rejects non-current heads when comparing against an artifact baseline", async () => { diff --git a/tests/drift-git-provider.test.ts b/tests/drift-git-provider.test.ts index 48a74d34..3cd9724a 100644 --- a/tests/drift-git-provider.test.ts +++ b/tests/drift-git-provider.test.ts @@ -41,6 +41,12 @@ describe("architecture drift git provider", () => { expect(beforeStatus).toBe(""); expect(afterStatus).toBe(""); expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle", severity: "error" })); + + expect(report.root.replace(/\\/g, "/")).toBe(root.replace(/\\/g, "/")); + expect(report.base.ref).toBe("HEAD~1"); + expect(report.head.ref).toBe("HEAD"); + expect(report.base.root.replace(/\\/g, "/")).toBe(root.replace(/\\/g, "/")); + expect(report.head.root.replace(/\\/g, "/")).toBe(root.replace(/\\/g, "/")); }); it("accepts WORKTREE as the head sentinel", async () => { diff --git a/tests/drift.test.ts b/tests/drift.test.ts index 698feab8..f53a2b27 100644 --- a/tests/drift.test.ts +++ b/tests/drift.test.ts @@ -107,6 +107,56 @@ describe("architecture drift", () => { ); }); + it("summarizes graph edge drift by source file when requested", () => { + const base = makeSnapshot({ + graphEdges: [{ key: "src/a.ts\u0000./b\u0000src/b.ts", from: "src/a.ts", to: "src/b.ts", raw: "./b" }], + }); + const head = makeSnapshot({ + graphEdges: [ + { key: "src/a.ts\u0000./b\u0000src/b.ts", from: "src/a.ts", to: "src/b.ts", raw: "./b" }, + { key: "src/a.ts\u0000./c\u0000src/c.ts", from: "src/a.ts", to: "src/c.ts", raw: "./c" }, + { key: "src/a.ts\u0000./d\u0000src/d.ts", from: "src/a.ts", to: "src/d.ts", raw: "./d" }, + ], + }); + + const report = compareArchitectureSnapshots(base, head, { graphEdges: "summary" }); + + expect(report.findings).toContainEqual( + expect.objectContaining({ + kind: "graph-edge-added", + file: "src/a.ts", + details: expect.objectContaining({ count: 2 }), + }), + ); + expect(report.findings.some((finding) => finding.edge?.to === "src/c.ts")).toBe(false); + }); + + it("suppresses graph edge drift when graph edges are disabled", () => { + const base = makeSnapshot(); + const head = makeSnapshot({ + graphEdges: [{ key: "src/a.ts\u0000./b\u0000src/b.ts", from: "src/a.ts", to: "src/b.ts", raw: "./b" }], + }); + + const report = compareArchitectureSnapshots(base, head, { graphEdges: "off" }); + + expect(report.findings.some((finding) => finding.kind === "graph-edge-added")).toBe(false); + }); + + it("reports duplicate top group additions and removals", () => { + const base = makeSnapshot({ duplicates: { groups: { total: 2 }, topGroupKeys: ["a<->b", "c<->d"] } }); + const head = makeSnapshot({ duplicates: { groups: { total: 3 }, topGroupKeys: ["a<->b", "e<->f", "g<->h"] } }); + + const report = compareArchitectureSnapshots(base, head, { failOn: [] }); + const finding = report.findings.find((entry) => entry.kind === "duplicate-increase"); + + expect(finding?.details).toEqual( + expect.objectContaining({ + newTopGroupKeys: ["e<->f", "g<->h"], + resolvedTopGroupKeys: ["c<->d"], + }), + ); + }); + it("applies fail-on policy only to selected finding kinds", () => { const base = makeSnapshot({ publicApi: [{ id: "src/api.ts#old:function", file: "src/api.ts", name: "old", kind: "function" }] }); const head = makeSnapshot({ publicApi: [] }); @@ -119,6 +169,33 @@ describe("architecture drift", () => { expect(selected.policy.failedKinds).toEqual(["public-api-removal"]); }); + it("suppresses public API additions by default in compact mode", () => { + const base = makeSnapshot(); + const head = makeSnapshot({ + publicApi: [{ id: "src/api.ts#new:function", file: "src/api.ts", name: "new", kind: "function" }], + }); + + const report = compareArchitectureSnapshots(base, head, { format: "compact" }); + + expect(report.findings.some((finding) => finding.kind === "public-api-addition")).toBe(false); + }); + + it("supports explicit public API filtering", () => { + const base = makeSnapshot({ + publicApi: [{ id: "src/api.ts#old:function", file: "src/api.ts", name: "old", kind: "function" }], + }); + const head = makeSnapshot({ + publicApi: [{ id: "src/api.ts#new:function", file: "src/api.ts", name: "new", kind: "function" }], + }); + + const removalsOnly = compareArchitectureSnapshots(base, head, { publicApi: "removals" }); + const disabled = compareArchitectureSnapshots(base, head, { publicApi: "off" }); + + expect(removalsOnly.findings.some((finding) => finding.kind === "public-api-addition")).toBe(false); + expect(removalsOnly.findings.some((finding) => finding.kind === "public-api-removal")).toBe(true); + expect(disabled.findings.some((finding) => finding.kind.startsWith("public-api"))).toBe(false); + }); + it("does not report hotspot drift when scores are unchanged at threshold zero", () => { const base = makeSnapshot({ hotspots: [{ file: "src/core.ts", fanIn: 2, fanOut: 3, score: 7 }] }); const head = makeSnapshot({ hotspots: [{ file: "src/core.ts", fanIn: 2, fanOut: 3, score: 7 }] }); @@ -164,6 +241,25 @@ describe("architecture drift", () => { expect(text).toContain("Omitted 1 finding(s)."); }); + it("emits compact drift reports with summary counts", () => { + const report = compareArchitectureSnapshots( + makeSnapshot(), + makeSnapshot({ + cycles: [{ key: "src/a.ts\u0000src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + graphEdges: [{ key: "src/a.ts\u0000./b\u0000src/b.ts", from: "src/a.ts", to: "src/b.ts", raw: "./b" }], + }), + { format: "compact", graphEdges: "summary" }, + ); + + expect(report.format).toBe("compact"); + expect(report.summary).toEqual( + expect.objectContaining({ + byKind: expect.objectContaining({ "new-cycle": 1, "graph-edge-added": 1 }), + bySeverity: expect.objectContaining({ error: 1, info: 1 }), + }), + ); + }); + it("renders a short grouped pretty report", () => { const report = compareArchitectureSnapshots( makeSnapshot({ publicApi: [{ id: "src/api.ts#oldName:function", file: "src/api.ts", name: "oldName", kind: "function" }] }), From 5b90c5071ff9ed8427f5bb9e88a675a3d8ce62d0 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 1 Jun 2026 07:40:12 -0400 Subject: [PATCH 2/2] Address drift PR feedback --- src/cli/drift.ts | 10 +++++---- src/drift/types.ts | 4 ++-- tests/cli-regressions.test.ts | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/cli/drift.ts b/src/cli/drift.ts index ad2112d6..05c4bed3 100644 --- a/src/cli/drift.ts +++ b/src/cli/drift.ts @@ -84,15 +84,17 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< context.exit(2); } if (!base && !baseArtifact) { - context.writeStderrLine("Usage: codegraph drift [roots...] [--root ] (--base | --base-artifact ) [--head ] [--json | --pretty]"); + context.writeStderrLine("Usage: codegraph drift [roots...] [--root ] (--base | --base-artifact ) [--head ] [--json | --pretty | --compact-json]"); context.writeStderrLine("Provide either --base or --base-artifact."); context.exit(2); } + const json = context.hasFlag("--json"); const compactJson = context.hasFlag("--compact-json"); const pretty = context.hasFlag("--pretty"); - const effectiveGraphEdges = graphEdges ?? (pretty ? "summary" : undefined); - const effectivePublicApi = publicApi ?? (pretty || compactJson ? "removals" : undefined); + const prettyOutput = pretty && !json && !compactJson; + const effectiveGraphEdges = graphEdges ?? (prettyOutput ? "summary" : undefined); + const effectivePublicApi = publicApi ?? (prettyOutput || compactJson ? "removals" : undefined); const head = context.getOpt("--head"); let report: Awaited>; @@ -118,7 +120,7 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< context.writeStderrLine(error instanceof Error ? error.message : String(error)); context.exit(1); } - if (pretty) { + if (prettyOutput) { for (const line of renderArchitectureDriftReport(report, { limit: maxFindings }).trimEnd().split("\n")) { context.writeStdoutLine(line); } diff --git a/src/drift/types.ts b/src/drift/types.ts index db9b979d..9f7fd05d 100644 --- a/src/drift/types.ts +++ b/src/drift/types.ts @@ -124,12 +124,12 @@ export interface ArchitectureDriftFinding { export interface ArchitectureDriftReport { schemaVersion: 1; - format: ArchitectureDriftFormat; + format?: ArchitectureDriftFormat; root: string; base: ArchitectureSnapshotSummary; head: ArchitectureSnapshotSummary; findings: ArchitectureDriftFinding[]; - summary: ArchitectureDriftSummary; + summary?: ArchitectureDriftSummary; policy: { failed: boolean; failOn: ArchitectureDriftFindingKind[]; diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index fb75119a..0a826fec 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1538,6 +1538,44 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) } }); + it("drift JSON output flags take precedence over pretty output", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-json-precedence-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "export function a() { return 1; }\n", "utf8"); + git(root, ["init"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + + const explicitJson = await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD", "--head", "WORKTREE", "--json", "--pretty"]); + const compactJson = await runCliCommand([ + "drift", + "src", + "--root", + root, + "--base", + "HEAD", + "--head", + "WORKTREE", + "--compact-json", + "--pretty", + ]); + + expect(JSON.parse(explicitJson)).toMatchObject({ schemaVersion: 1, format: "full" }); + expect(JSON.parse(compactJson)).toMatchObject({ schemaVersion: 1, format: "compact" }); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("drift usage includes compact JSON output mode", async () => { + const result = await runCliWithExit(["drift"], process.cwd()); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--json | --pretty | --compact-json"); + }); + + it("drift compact json summarizes graph edges and suppresses API additions by default", async () => { const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-compact-")); try {