diff --git a/README.md b/README.md index 2ed13435..8a3e320d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # codegraph -Codegraph is a small multi-language code analysis library and CLI for understanding repos quickly. It builds dependency graphs, symbol indexes, go-to-definition results, find-references results, semantic chunks, and PR review and impact artifacts across source languages plus graph-first document, stylesheet, and template formats. +Codegraph is a small multi-language code analysis library and CLI for understanding repos quickly. It builds dependency graphs, symbol indexes, go-to-definition results, find-references results, semantic chunks, architecture drift reports, and PR review and impact artifacts across source languages plus graph-first document, stylesheet, and template formats. It is built for agent and human workflows that need repo structure fast without standing up a full editor or LSP stack. @@ -141,6 +141,9 @@ node ./dist/cli.js apisurface # find duplicate and near-duplicate code 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 ``` If you install the published CLI instead of using a source checkout, replace `node ./dist/cli.js` with `codegraph`. @@ -213,9 +216,11 @@ References for buildProjectIndex - tests/indexer.test.ts:22 call ``` -Use impact and review for PR or worktree risk: +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 impact --base origin/main --head HEAD --pretty codegraph review --base origin/main --head HEAD --summary ``` @@ -234,6 +239,8 @@ 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. + Run duplicate detection directly when refactor risk is the question: ```bash @@ -256,7 +263,7 @@ codegraph duplicates ./src --min-confidence medium --limit 20 } ``` -See [docs/cli.md](./docs/cli.md) for full flags, JSON shapes, duplicate scopes, and review output details. +See [docs/cli.md](./docs/cli.md) for full flags, JSON shapes, drift policy gates, duplicate scopes, and review output details. ## Agent setup @@ -292,11 +299,11 @@ Use the TypeScript API when another program needs deterministic file packs, revi import { buildProjectIndex, buildReviewReport, + analyzeArchitectureDrift, analyzeImpactFromDiff, analyzeImpactStreaming, tool_impactJSON, } from "@lzehrung/codegraph"; - const root = process.cwd(); const index = await buildProjectIndex(root, { native: "auto" }); @@ -306,6 +313,13 @@ const review = await buildReviewReport(root, { reviewDepth: "standard", }); +const drift = await analyzeArchitectureDrift(root, { + provider: "git", + base: "origin/main", + head: "HEAD", + failOn: ["new-cycle", "public-api-removal"], +}); + const impact = await analyzeImpactFromDiff(root, index, { provider: "git", base: "origin/main", diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 92870be5..66eb0529 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -48,6 +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` - Public API: `codegraph apisurface` - Duplicate cleanup: `codegraph duplicates --root . ./src --min-confidence medium` - Chunks: `codegraph chunk ` @@ -64,6 +65,7 @@ Use Codegraph MCP tools when they are already available in the agent runtime. MC - Use `search` for anchors and `packet_get` for bounded evidence packets. - Use `refs`, `goto`, `deps`, `rdeps`, and `path` for semantic navigation. - Use `impact` and `review` for git-range risk analysis. +- Use `drift` for base/head architecture-regression checks. - Use `query_sqlite` only for read-only artifact inspection. - Use `artifact_build` only when the tool is exposed and write access is intentionally enabled. @@ -224,6 +226,11 @@ Prefer `refs` over plain text search when you want semantic usages rather than e `codegraph review --base origin/main --head HEAD` - Agent-ready full current worktree bundle: `codegraph review --base HEAD --head WORKTREE` +- Architecture drift: + `codegraph drift ./src --base origin/main --head HEAD --pretty` +- 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. 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 a3700e98..52f1d93e 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -61,6 +61,15 @@ Search results include project-relative `handle`, `rankReasons`, `evidence`, `ne Use `artifact build` when the agent needs a durable handoff directory. The default bundle writes SQLite, self-describing project-relative graph JSON with symbols, a concise Markdown report, suggested questions, and a manifest. Suggested questions command stable handles, not ambiguous bare names, and use unique IDs even when display labels collide. In-repo artifact output directories and linked outside-root files are excluded from the emitted artifacts so stale handoff files do not feed back into the graph. With `--force`, Codegraph removes recognizable stale artifact files while preserving unrelated operator files and refusing unrecognized reserved-name collisions. `codegraph doctor ` recognizes manifest-backed bundle directories and reports which expected artifacts are present. +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 +``` + +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. + ## MCP server Use `codegraph mcp serve --root . --stdio` when an agent can spawn a stdio MCP server, or `codegraph mcp serve --root . --port 7331` for Streamable HTTP at `/mcp`. HTTP binds to `127.0.0.1` by default; pass `--host ` only when the server must be reachable elsewhere. MCP reuses one in-process Codegraph session and exposes the same deterministic primitives as compact tools: `orient`, `packet_get`, `search`, `get_file`, `get_symbol`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, `query_sqlite`, and `artifact_build`. diff --git a/docs/cli.md b/docs/cli.md index 84ea32a4..a92f51e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -158,6 +158,12 @@ codegraph duplicates --root . ./src ./packages/app --include-same-file 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 --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 + # Go to definition codegraph goto @@ -185,7 +191,7 @@ codegraph grep --pattern 'eval\(' --ignore-case - Use `--include-same-file` for non-overlapping clones inside one file. - Use `--raw-pairs` when debugging the low-level pair evidence behind each group. -`orient`, `packet`, `search`, `explain`, `artifact`, and `mcp` each support command-specific `--help` output. +`orient`, `packet`, `search`, `explain`, `artifact`, `drift`, and `mcp` each support command-specific `--help` output. #### Agent orientation and packets @@ -319,6 +325,15 @@ codegraph review --base origin/main --head HEAD --summary --duplicates impacted 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 --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`. + 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. Impact JSON responses include `schemaVersion` plus `format: "full" | "compact"` so downstream tools can branch on payload shape without inferring it from missing fields. Use `--compact` or `--compact-json` for compact impact JSON. Impact JSON can also include `exportSummary`, `reexportChains`, `topImpacts`, `surfaceArea`, `clusters`, and `changedSymbols[].callCompatibility` when applicable. File paths in impact reports are project-relative, and raw diffs that point outside the project root are rejected. diff --git a/docs/library-api.md b/docs/library-api.md index 633190f0..c07e426f 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -575,6 +575,24 @@ const references = await tool_findReferences(root, "src/main.ts", 10, 5, index); const impact = await tool_impactJSON(root, { provider: "git", base: "HEAD", head: "WORKTREE" }, { index }); ``` +### Architecture drift + +Use `analyzeArchitectureDrift()` when a caller needs one deterministic architecture-regression report instead of separately comparing cycles, unresolved imports, API surface, duplicates, hotspots, and graph edges. + +```ts +import { analyzeArchitectureDrift } from "@lzehrung/codegraph"; + +const report = await analyzeArchitectureDrift(process.cwd(), { + provider: "git", + base: "origin/main", + head: "HEAD", + includeRoots: ["src"], + failOn: ["new-cycle", "public-api-removal"], +}); +``` + +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 Use the exported TypeScript APIs when another program is composing deterministic review packets, file packs, or model prompts. CLI `--pretty` and `--summary` output is optimized for compact reading by people or models; it is not the stable integration contract. diff --git a/docs/superpowers/plans/2026-05-26-architecture-drift-check.md b/docs/superpowers/plans/2026-05-26-architecture-drift-check.md index 5f6c43f7..ace75da7 100644 --- a/docs/superpowers/plans/2026-05-26-architecture-drift-check.md +++ b/docs/superpowers/plans/2026-05-26-architecture-drift-check.md @@ -134,7 +134,7 @@ export type ArchitectureDriftFindingKind = - Modify: `src/index.ts` - Test: `tests/drift.test.ts` -- [ ] **Step 1: Write failing snapshot tests** +- [x] **Step 1: Write failing snapshot tests** ```ts import { buildArchitectureSnapshot } from "../src/drift/index.js"; @@ -154,7 +154,7 @@ it("builds a deterministic architecture snapshot", async () => { }); ``` -- [ ] **Step 2: Run test to verify failure** +- [x] **Step 2: Run test to verify failure** Run: @@ -164,7 +164,7 @@ npx vitest run tests/drift.test.ts Expected: FAIL because drift files do not exist. -- [ ] **Step 3: Implement snapshot types** +- [x] **Step 3: Implement snapshot types** Define snapshot data that is intentionally smaller than full graph JSON: @@ -182,11 +182,11 @@ export interface ArchitectureSnapshot { Use existing project index, graph, cycles, unresolved, API surface, and duplicate helpers. If a helper is CLI-only, extract a library helper first instead of parsing CLI text. -- [ ] **Step 4: Export the API** +- [x] **Step 4: Export the API** In `src/drift/index.ts`, export snapshot and later analyzer functions. In `src/index.ts`, export from `./drift/index.js`. -- [ ] **Step 5: Run focused test** +- [x] **Step 5: Run focused test** Run: @@ -196,7 +196,7 @@ npx vitest run tests/drift.test.ts Expected: PASS. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src/drift/types.ts src/drift/snapshot.ts src/drift/index.ts src/index.ts tests/drift.test.ts @@ -211,7 +211,7 @@ git commit -m "Add architecture drift snapshots" - Modify: `src/drift/index.ts` - Test: `tests/drift.test.ts` -- [ ] **Step 1: Add failing comparison tests** +- [x] **Step 1: Add failing comparison tests** ```ts import { compareArchitectureSnapshots } from "../src/drift/index.js"; @@ -239,7 +239,7 @@ it("reports public API removals", () => { Define a local `makeSnapshot()` helper in the test with complete default fields so the test remains readable. -- [ ] **Step 2: Run test to verify failure** +- [x] **Step 2: Run test to verify failure** Run: @@ -249,7 +249,7 @@ npx vitest run tests/drift.test.ts Expected: FAIL because comparison is missing. -- [ ] **Step 3: Implement comparison** +- [x] **Step 3: Implement comparison** Comparison keys: @@ -269,7 +269,7 @@ export const DEFAULT_DRIFT_THRESHOLDS = { } as const; ``` -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** Run: @@ -279,7 +279,7 @@ npx vitest run tests/drift.test.ts Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/drift/compare.ts src/drift/index.ts tests/drift.test.ts @@ -294,7 +294,7 @@ git commit -m "Compare architecture drift snapshots" - Modify: `src/drift/index.ts` - Test: `tests/drift-git-provider.test.ts` -- [ ] **Step 1: Add failing git fixture test** +- [x] **Step 1: Add failing git fixture test** Use the existing git fixture style from `tests/impact-git-provider.test.ts`. @@ -305,7 +305,7 @@ Scenario: - Run `analyzeArchitectureDrift(root, { provider: "git", base: "HEAD~1", head: "HEAD" })`. - Assert a `new-cycle` finding exists. -- [ ] **Step 2: Run test to verify failure** +- [x] **Step 2: Run test to verify failure** Run: @@ -315,7 +315,7 @@ npx vitest run tests/drift-git-provider.test.ts Expected: FAIL because git drift is missing. -- [ ] **Step 3: Implement git comparison** +- [x] **Step 3: Implement git comparison** Implementation constraints: @@ -325,7 +325,7 @@ Implementation constraints: - Respect `--root` and include roots. - Handle `WORKTREE` and `STAGED` only if existing repo helpers already provide that sentinel safely. If not, document v1 as real git refs only plus current checkout. -- [ ] **Step 4: Run focused tests** +- [x] **Step 4: Run focused tests** Run: @@ -335,7 +335,7 @@ npx vitest run tests/drift.test.ts tests/drift-git-provider.test.ts Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/drift/git.ts src/drift/index.ts tests/drift-git-provider.test.ts @@ -350,7 +350,7 @@ git commit -m "Compare architecture drift across git refs" - Modify: `src/drift/index.ts` - Test: `tests/drift-artifact.test.ts` -- [ ] **Step 1: Add failing artifact test** +- [x] **Step 1: Add failing artifact test** Build an artifact with `buildCodegraphArtifact()` or the existing artifact test helper. Compare that artifact to a modified current checkout. @@ -360,7 +360,7 @@ Assert: - missing required artifact files produce a clear error. - unrelated files in the artifact directory are ignored. -- [ ] **Step 2: Run test to verify failure** +- [x] **Step 2: Run test to verify failure** Run: @@ -370,7 +370,7 @@ npx vitest run tests/drift-artifact.test.ts Expected: FAIL because artifact baseline loading is missing. -- [ ] **Step 3: Implement artifact loader** +- [x] **Step 3: Implement artifact loader** Rules: @@ -379,7 +379,7 @@ Rules: - If a full drift snapshot is not present in old artifacts, derive the v1 snapshot from graph JSON and available files. - Do not read arbitrary paths from the artifact manifest without root confinement checks already used by artifact/MCP code. -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** Run: @@ -389,7 +389,7 @@ npx vitest run tests/drift-artifact.test.ts tests/artifact-build.test.ts Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/drift/artifact.ts src/drift/index.ts tests/drift-artifact.test.ts @@ -406,7 +406,7 @@ git commit -m "Load drift baselines from artifacts" - Test: `tests/cli-command-modules.test.ts` - Test: `tests/cli-regressions.test.ts` -- [ ] **Step 1: Add failing CLI tests** +- [x] **Step 1: Add failing CLI tests** Assert: @@ -415,7 +415,7 @@ Assert: - `--fail-on new-cycle` exits `1` when a new cycle exists. - `--fail-on public-api-removal` exits `0` when no API removal exists. -- [ ] **Step 2: Run CLI tests to verify failure** +- [x] **Step 2: Run CLI tests to verify failure** Run: @@ -425,7 +425,7 @@ npx vitest run tests/cli-command-modules.test.ts tests/cli-regressions.test.ts Expected: FAIL because the command is not wired. -- [ ] **Step 3: Implement command** +- [x] **Step 3: Implement command** Support flags: @@ -448,7 +448,7 @@ Validation: - Default `--head` to current checkout when `--base-artifact` is used. - Reject unknown `--fail-on` values with a non-zero exit and a list of valid kinds. -- [ ] **Step 4: Run focused CLI tests** +- [x] **Step 4: Run focused CLI tests** Run: @@ -458,7 +458,7 @@ npx vitest run tests/cli-command-modules.test.ts tests/cli-regressions.test.ts t Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/cli/drift.ts src/cli.ts src/cli/help.ts tests/cli-command-modules.test.ts tests/cli-regressions.test.ts @@ -474,7 +474,7 @@ git commit -m "Add architecture drift CLI" - Test: `tests/drift.test.ts` - Test: `tests/cli-regressions.test.ts` -- [ ] **Step 1: Add failing renderer test** +- [x] **Step 1: Add failing renderer test** Expected pretty output: @@ -489,7 +489,7 @@ Warnings - hotspot-jump: src/core.ts score 35 -> 72 ``` -- [ ] **Step 2: Run test to verify failure** +- [x] **Step 2: Run test to verify failure** Run: @@ -499,7 +499,7 @@ npx vitest run tests/drift.test.ts tests/cli-regressions.test.ts Expected: FAIL because pretty rendering is missing. -- [ ] **Step 3: Implement renderer** +- [x] **Step 3: Implement renderer** Rules: @@ -508,7 +508,7 @@ Rules: - Include omitted count when findings exceed the limit. - Keep lines path-first and suitable for CI logs. -- [ ] **Step 4: Run focused tests** +- [x] **Step 4: Run focused tests** Run: @@ -518,7 +518,7 @@ npx vitest run tests/drift.test.ts tests/cli-regressions.test.ts Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/drift/report.ts src/cli/drift.ts tests/drift.test.ts tests/cli-regressions.test.ts @@ -536,7 +536,7 @@ git commit -m "Render architecture drift summaries" - Modify: `codegraph-skill/codegraph/SKILL.md` - Test: `tests/package-metadata.test.ts` -- [ ] **Step 1: Document command and API** +- [x] **Step 1: Document command and API** Add concise examples: @@ -548,11 +548,11 @@ codegraph drift --base origin/main --head HEAD --fail-on new-cycle,public-api-re Docs must explain that drift compares architecture signals, not runtime behavior or compiler diagnostics. -- [ ] **Step 2: Update skill command list** +- [x] **Step 2: Update skill command list** Add `drift` to the PR/repo health command area in `codegraph-skill/codegraph/SKILL.md`. -- [ ] **Step 3: Run docs checks** +- [x] **Step 3: Run docs checks** Run: @@ -563,7 +563,7 @@ git diff --check Expected: PASS. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add README.md docs/cli.md docs/library-api.md docs/agent-workflows.md codegraph-skill/codegraph/SKILL.md tests/package-metadata.test.ts @@ -572,7 +572,7 @@ git commit -m "Document architecture drift checks" ## Final Verification -- [ ] Run: +- [x] Run: ```bash npm run lint @@ -581,7 +581,7 @@ npm run test:ci git diff --check ``` -- [ ] Expected: +- [x] Expected: ```text lint passes diff --git a/src/cli.ts b/src/cli.ts index d5ddbbc3..4b8a3de9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,7 @@ import { } from "./cli/context.js"; import { handleArtifactCommand } from "./cli/artifact.js"; import { buildDoctorReport } from "./cli/doctor.js"; +import { handleDriftCommand } from "./cli/drift.js"; import { handleDuplicatesCommand } from "./cli/duplicates.js"; import { handleExplainCommand } from "./cli/explain.js"; import { handleGraphCommand } from "./cli/graph.js"; @@ -249,14 +250,15 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "hotspots" || cmd === "inspect" || cmd === "duplicates" || + cmd === "drift" || cmd === "orient"; let includeRoots: string[] = []; if (supportsIncludeRoots) { if (rootOpt) { // If the user explicitly sets --root, treat all remaining positionals as include roots. includeRoots = parsed.positionals; - } else if (cmd === "orient") { - // Orient uses positionals only as include roots; it does not use the legacy root positional. + } else if (cmd === "orient" || cmd === "drift") { + // Orient and drift use positionals only as include roots; they do not use the legacy root positional. includeRoots = parsed.positionals; } else if (parsed.positionals.length > 1) { // Otherwise, a single positional arg is treated as the project root (back-compat). @@ -502,6 +504,29 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return; } + if (cmd === "drift") { + const driftGraphOptions = hasGraphOverrides || nativeMode !== "auto" ? buildGraphOptions() : undefined; + await handleDriftCommand({ + projectRootFs, + positionals: includeRoots, + getOpt, + hasFlag, + nativeMode, + ...(driftGraphOptions ? { graphOptions: driftGraphOptions } : {}), + indexOptions: { + onProgress: progressHandler, + discovery: discoveryOptions, + ...(nativeMode !== "auto" ? { native: nativeMode } : {}), + ...workerOpts, + }, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } + if (cmd === "duplicates") { const files = await resolveFiles(); await handleDuplicatesCommand({ diff --git a/src/cli/drift.ts b/src/cli/drift.ts new file mode 100644 index 00000000..72fb7486 --- /dev/null +++ b/src/cli/drift.ts @@ -0,0 +1,95 @@ +import { analyzeArchitectureDrift, ARCHITECTURE_DRIFT_FINDING_KINDS, renderArchitectureDriftReport } from "../drift/index.js"; +import type { ArchitectureDriftFindingKind } 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"; +import { parseNonNegativeIntegerOption, parseOptionalNonNegativeIntegerOption } from "./options.js"; + +export interface DriftCommandContext { + projectRootFs: string; + positionals: string[]; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + nativeMode: NativeRuntimeMode; + graphOptions?: GraphBuildOptions; + indexOptions?: BuildOptions; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; + writeStderrLine: (message: string) => void; + exit: (code: number) => never; +} + +const findingKindSet = new Set(ARCHITECTURE_DRIFT_FINDING_KINDS); + +function parseFailOn(rawValue: string | undefined): ArchitectureDriftFindingKind[] { + if (!rawValue) return []; + const values = rawValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const invalid = values.filter((value) => !findingKindSet.has(value)); + if (invalid.length) { + throw new Error(`Invalid --fail-on value(s): ${invalid.join(", ")}. Valid kinds: ${ARCHITECTURE_DRIFT_FINDING_KINDS.join(", ")}.`); + } + return Array.from(new Set(values)) as ArchitectureDriftFindingKind[]; +} + +export async function handleDriftCommand(context: DriftCommandContext): Promise { + let failOn: ArchitectureDriftFindingKind[]; + let hotspotJump: number | undefined; + let maxFindings: number; + try { + failOn = parseFailOn(context.getOpt("--fail-on")); + hotspotJump = parseOptionalNonNegativeIntegerOption(context.getOpt("--hotspot-jump-threshold"), "--hotspot-jump-threshold"); + maxFindings = parseNonNegativeIntegerOption(context.getOpt("--limit"), "--limit", 100); + } catch (error) { + context.writeStderrLine(error instanceof Error ? error.message : String(error)); + context.exit(2); + } + + const base = context.getOpt("--base"); + const baseArtifact = context.getOpt("--base-artifact"); + if (base && baseArtifact) { + context.writeStderrLine("Provide either --base or --base-artifact, but not both."); + context.exit(2); + } + if (!base && !baseArtifact) { + context.writeStderrLine("Usage: codegraph drift [roots...] --base [--head ] [--json | --pretty]"); + context.writeStderrLine("Provide either --base or --base-artifact."); + context.exit(2); + } + + const head = context.getOpt("--head"); + let report; + try { + report = await analyzeArchitectureDrift(context.projectRootFs, { + ...(base ? { provider: "git" as const, base } : {}), + ...(head ? { head } : {}), + ...(baseArtifact ? { baseArtifact } : {}), + includeRoots: context.positionals, + failOn, + thresholds: { + ...(hotspotJump !== undefined ? { hotspotJump } : {}), + maxFindings, + }, + ...(context.graphOptions ? { graph: context.graphOptions } : {}), + ...(context.indexOptions ? { index: context.indexOptions } : {}), + ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), + }); + } catch (error) { + context.writeStderrLine(error instanceof Error ? error.message : String(error)); + context.exit(1); + } + + if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { + context.writeJSONLine(report); + } else { + for (const line of renderArchitectureDriftReport(report, { limit: maxFindings }).trimEnd().split("\n")) { + context.writeStdoutLine(line); + } + } + + if (report.policy.failed) { + context.exit(1); + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 7048b86d..87b0af94 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -10,6 +10,7 @@ Commands: search Ranked agent search across files, symbols, chunks, SQL, and graph context explain Explain a file, symbol, SQL object, or search handle artifact Build an agent-ready SQLite/graph/report/question bundle + drift Compare architecture health between refs or artifacts mcp Serve MCP tools for agent graph navigation index Build the project symbol index impact Analyze PR impact @@ -94,6 +95,7 @@ const knownCliCommands = new Set([ "apisurface", "artifact", "chunk", + "drift", "cycles", "deps", "doctor", @@ -247,11 +249,27 @@ Output: Use --raw-pairs to include the underlying scored unit-pair suggestions. `; +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 ] + +Signals: + Compares dependency cycles, hotspots, unresolved imports, public API symbols, duplicate group counts, and graph edges. + Drift is structural architecture comparison, not runtime validation, compiler diagnostics, or a style linter. + +Options: + --head Git ref for the head snapshot. Defaults to the current checkout; with --base-artifact, only the current checkout is supported (., WORKTREE). + --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. +`; + export function helpTextForCommand(command: string, positionals: readonly string[]): string | undefined { if (command === "search") return SEARCH_HELP_TEXT; if (command === "orient") return ORIENT_HELP_TEXT; if (command === "packet") return PACKET_HELP_TEXT; if (command === "explain") return EXPLAIN_HELP_TEXT; + if (command === "drift") return DRIFT_HELP_TEXT; if (command === "duplicates") return DUPLICATES_HELP_TEXT; if (command === "artifact") return ARTIFACT_HELP_TEXT; if (command === "mcp") { diff --git a/src/cli/options.ts b/src/cli/options.ts index 7e006691..f0289706 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -26,6 +26,7 @@ const CLI_VALUE_OPTIONS = new Set([ "--provider", "--base", "--head", + "--base-artifact", "--pr", "--repo", "--max-refs", @@ -55,6 +56,8 @@ const CLI_VALUE_OPTIONS = new Set([ "--agent", "--target", "--limit", + "--fail-on", + "--hotspot-jump-threshold", "--budget", "--mode", "--from", diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts new file mode 100644 index 00000000..03ed252f --- /dev/null +++ b/src/drift/artifact.ts @@ -0,0 +1,171 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { getHotspots } from "../graphs/hotspots.js"; +import { findDetailedCycles, getUnresolvedImports, sortDetailedCycles } from "../graphs/queries.js"; +import { supportForFile } from "../languages.js"; +import type { Edge, Graph } from "../types.js"; +import { isPlainRecord } from "../util/guards.js"; +import { normalizePath } from "../util/paths.js"; +import type { ArchitectureGraphEdge, ArchitectureSnapshot, ArchitectureUnresolvedImport } from "./types.js"; + +interface ArtifactManifest { + artifacts: { graphJson: string }; +} + +interface PortableGraphJson { + schemaVersion: 1; + format: "codegraph.graph-json"; + graph: { + files: string[]; + fileEdges: Edge[]; + symbols?: Array<{ file: string; name: string; kind: string }>; + }; +} + +function readStringRecord(value: unknown): Record { + if (!isPlainRecord(value)) return {}; + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function parseManifest(value: unknown): ArtifactManifest | null { + if (!isPlainRecord(value)) return null; + if (value.schemaVersion !== 1 || value.graphJsonSchema !== "codegraph.graph-json") return null; + const artifacts = readStringRecord(value.artifacts); + if (!artifacts.graphJson) return null; + return { artifacts: { graphJson: artifacts.graphJson } }; +} + +async function readJson(filePath: string, label: string): Promise { + try { + return JSON.parse(await fsp.readFile(filePath, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error(`Codegraph artifact ${label} is missing.`); + } + if (error instanceof SyntaxError) { + throw new Error(`Codegraph artifact ${label} is invalid JSON.`); + } + throw error; + } +} + +function assertArtifactChild(outDir: string, artifactPath: string): string { + const resolved = path.resolve(outDir, artifactPath); + const normalizedRoot = normalizePath(path.resolve(outDir)); + const normalizedFile = normalizePath(resolved); + if (normalizedFile !== normalizedRoot && !normalizedFile.startsWith(`${normalizedRoot}/`)) { + throw new Error(`Codegraph artifact file is outside artifact directory: ${artifactPath}`); + } + return resolved; +} + +function parseGraphJson(value: unknown): PortableGraphJson { + if (!isPlainRecord(value) || value.schemaVersion !== 1 || value.format !== "codegraph.graph-json") { + throw new Error("Codegraph artifact graph.json is missing or invalid."); + } + if (!isPlainRecord(value.graph) || !Array.isArray(value.graph.files) || !Array.isArray(value.graph.fileEdges)) { + throw new Error("Codegraph artifact graph.json does not contain a portable graph."); + } + const files = value.graph.files.filter((entry): entry is string => typeof entry === "string").map(normalizePath).sort(); + const fileEdges: Edge[] = []; + for (const edge of value.graph.fileEdges) { + if (!isPlainRecord(edge) || typeof edge.from !== "string" || typeof edge.raw !== "string" || !isPlainRecord(edge.to)) { + continue; + } + if (edge.to.type === "file" && typeof edge.to.path === "string") { + fileEdges.push({ from: normalizePath(edge.from), raw: edge.raw, to: { type: "file", path: normalizePath(edge.to.path) } }); + } else if (edge.to.type === "external" && typeof edge.to.name === "string") { + fileEdges.push({ from: normalizePath(edge.from), raw: edge.raw, to: { type: "external", name: edge.to.name } }); + } + } + const symbols = Array.isArray(value.graph.symbols) + ? value.graph.symbols.filter((entry): entry is { file: string; name: string; kind: string } => { + return isPlainRecord(entry) && typeof entry.file === "string" && typeof entry.name === "string" && typeof entry.kind === "string"; + }) + : []; + return { + schemaVersion: 1, + format: "codegraph.graph-json", + graph: { files, fileEdges, symbols }, + }; +} + +function languageId(id: string): string { + if (id === "ts") return "typescript"; + return id; +} + +function languageCounts(files: readonly string[]): Record { + const counts: Record = {}; + for (const file of files) { + const support = supportForFile(file); + if (!support) continue; + const id = languageId(support.id); + counts[id] = (counts[id] ?? 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); +} + +function edgeTarget(edge: Edge): string { + if (edge.to.type === "file") return edge.to.path; + return `external:${edge.to.name}`; +} + +function edgeKey(edge: Edge): string { + return `${edge.from}\0${edge.raw}\0${edgeTarget(edge)}`; +} + +function graphEdges(edges: readonly Edge[]): ArchitectureGraphEdge[] { + return edges + .map((edge) => ({ key: edgeKey(edge), from: edge.from, to: edgeTarget(edge), raw: edge.raw })) + .sort((left, right) => left.key.localeCompare(right.key)); +} + +function unresolvedImports(graph: Graph): { total: number; imports: ArchitectureUnresolvedImport[] } { + const imports: ArchitectureUnresolvedImport[] = []; + for (const item of getUnresolvedImports(graph)) { + for (const importer of item.importers) { + imports.push({ key: `${importer.file}\0${importer.raw}`, file: importer.file, specifier: importer.raw }); + } + } + imports.sort((left, right) => left.key.localeCompare(right.key)); + return { total: imports.length, imports }; +} + +export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): Promise { + const outDir = path.resolve(outDirInput); + const manifest = parseManifest(await readJson(path.join(outDir, "manifest.json"), "manifest")); + if (!manifest) { + throw new Error("Codegraph artifact manifest is missing graph.json metadata."); + } + const graphPath = assertArtifactChild(outDir, manifest.artifacts.graphJson); + const graphJson = parseGraphJson(await readJson(graphPath, "graph.json")); + const graph: Graph = { nodes: new Set(graphJson.graph.files), edges: graphJson.graph.fileEdges }; + return { + schemaVersion: 1, + root: outDir, + files: { total: graphJson.graph.files.length, byLanguage: languageCounts(graphJson.graph.files) }, + hotspots: getHotspots(graph) + .map((entry) => ({ file: entry.file, fanIn: entry.fanIn, fanOut: entry.fanOut, score: entry.score })) + .sort((left, right) => left.file.localeCompare(right.file)), + cycles: sortDetailedCycles(findDetailedCycles(graph), "priority") + .map((cycle) => { + const files = cycle.files.map(normalizePath).sort(); + return { key: files.join("\0"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; + }) + .sort((left, right) => left.key.localeCompare(right.key)), + unresolved: unresolvedImports(graph), + publicApi: [], + duplicates: { groups: { total: 0 }, topGroupKeys: [] }, + graphEdges: graphEdges(graph.edges), + signalAvailability: { + unresolved: false, + publicApi: false, + duplicates: false, + }, + }; +} diff --git a/src/drift/compare.ts b/src/drift/compare.ts new file mode 100644 index 00000000..632529f7 --- /dev/null +++ b/src/drift/compare.ts @@ -0,0 +1,283 @@ +import type { + ArchitectureCycle, + ArchitectureDriftCompareOptions, + ArchitectureDriftFinding, + ArchitectureDriftFindingKind, + ArchitectureDriftReport, + ArchitectureDriftThresholds, + ArchitectureGraphEdge, + ArchitectureHotspot, + ArchitecturePublicApiSymbol, + ArchitectureSnapshot, + ArchitectureSnapshotSummary, + ArchitectureUnresolvedImport, +} from "./types.js"; + +export const DEFAULT_DRIFT_THRESHOLDS: ArchitectureDriftThresholds = { + hotspotJump: 20, + maxFindings: 100, +} as const; + +export const ARCHITECTURE_DRIFT_FINDING_KINDS: readonly ArchitectureDriftFindingKind[] = [ + "new-cycle", + "resolved-cycle", + "hotspot-jump", + "hotspot-drop", + "unresolved-import", + "resolved-unresolved-import", + "public-api-addition", + "public-api-removal", + "duplicate-increase", + "duplicate-decrease", + "graph-edge-added", + "graph-edge-removed", +] as const; + +function summarize(snapshot: ArchitectureSnapshot): ArchitectureSnapshotSummary { + return { + root: snapshot.root, + files: snapshot.files, + hotspots: snapshot.hotspots, + cycles: snapshot.cycles, + unresolved: snapshot.unresolved, + publicApi: snapshot.publicApi, + duplicates: snapshot.duplicates, + }; +} + +function byKey(items: readonly T[]): Map { + return new Map(items.map((item) => [item.key, item])); +} + +function byId(items: readonly T[]): Map { + return new Map(items.map((item) => [item.id, item])); +} + +function pushNewCycle(findings: ArchitectureDriftFinding[], cycle: ArchitectureCycle): void { + findings.push({ + kind: "new-cycle", + severity: "error", + key: cycle.key, + title: `New dependency cycle: ${cycle.files.join(" -> ")}`, + files: cycle.files, + after: cycle.priorityScore, + }); +} + +function pushResolvedCycle(findings: ArchitectureDriftFinding[], cycle: ArchitectureCycle): void { + findings.push({ + kind: "resolved-cycle", + severity: "info", + key: cycle.key, + title: `Resolved dependency cycle: ${cycle.files.join(" -> ")}`, + files: cycle.files, + before: cycle.priorityScore, + }); +} + +function compareCycles(findings: ArchitectureDriftFinding[], base: readonly ArchitectureCycle[], head: readonly ArchitectureCycle[]): void { + const baseByKey = byKey(base); + const headByKey = byKey(head); + for (const cycle of headByKey.values()) { + if (!baseByKey.has(cycle.key)) pushNewCycle(findings, cycle); + } + for (const cycle of baseByKey.values()) { + if (!headByKey.has(cycle.key)) pushResolvedCycle(findings, cycle); + } +} + +function compareHotspots( + findings: ArchitectureDriftFinding[], + base: readonly ArchitectureHotspot[], + head: readonly ArchitectureHotspot[], + threshold: number, +): void { + const baseByFile = new Map(base.map((entry) => [entry.file, entry])); + const headByFile = new Map(head.map((entry) => [entry.file, entry])); + const files = Array.from(new Set([...baseByFile.keys(), ...headByFile.keys()])).sort(); + for (const file of files) { + const before = baseByFile.get(file)?.score ?? 0; + const after = headByFile.get(file)?.score ?? 0; + const delta = after - before; + if (!delta || Math.abs(delta) < threshold) continue; + const kind: ArchitectureDriftFindingKind = delta > 0 ? "hotspot-jump" : "hotspot-drop"; + findings.push({ + kind, + severity: delta > 0 ? "warning" : "info", + key: file, + title: `${kind === "hotspot-jump" ? "Hotspot increased" : "Hotspot decreased"}: ${file} score ${before} -> ${after}`, + file, + before, + after, + }); + } +} + +function pushUnresolved( + findings: ArchitectureDriftFinding[], + kind: "unresolved-import" | "resolved-unresolved-import", + item: ArchitectureUnresolvedImport, +): void { + findings.push({ + kind, + severity: kind === "unresolved-import" ? "error" : "info", + key: item.key, + title: `${kind === "unresolved-import" ? "New unresolved import" : "Resolved unresolved import"}: ${item.file} imports ${item.specifier}`, + file: item.file, + specifier: item.specifier, + }); +} + +function compareUnresolved( + findings: ArchitectureDriftFinding[], + base: readonly ArchitectureUnresolvedImport[], + head: readonly ArchitectureUnresolvedImport[], +): void { + const baseByKey = byKey(base); + const headByKey = byKey(head); + for (const item of headByKey.values()) { + if (!baseByKey.has(item.key)) pushUnresolved(findings, "unresolved-import", item); + } + for (const item of baseByKey.values()) { + if (!headByKey.has(item.key)) pushUnresolved(findings, "resolved-unresolved-import", item); + } +} + +function pushPublicApi( + findings: ArchitectureDriftFinding[], + kind: "public-api-addition" | "public-api-removal", + symbol: ArchitecturePublicApiSymbol, +): void { + findings.push({ + kind, + severity: kind === "public-api-removal" ? "error" : "info", + key: symbol.id, + title: `${kind === "public-api-removal" ? "Public API removed" : "Public API added"}: ${symbol.file}#${symbol.name}`, + file: symbol.file, + symbol, + }); +} + +function comparePublicApi( + findings: ArchitectureDriftFinding[], + base: readonly ArchitecturePublicApiSymbol[], + head: readonly ArchitecturePublicApiSymbol[], +): void { + 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); + } + for (const symbol of baseById.values()) { + if (!headById.has(symbol.id)) pushPublicApi(findings, "public-api-removal", symbol); + } +} + +function signalEnabled( + snapshot: ArchitectureSnapshot, + signal: "unresolved" | "publicApi" | "duplicates", +): boolean { + return snapshot.signalAvailability?.[signal] !== false; +} + +function compareDuplicates(findings: ArchitectureDriftFinding[], base: ArchitectureSnapshot, head: ArchitectureSnapshot): void { + const before = base.duplicates.groups.total; + const after = head.duplicates.groups.total; + if (before === after) return; + const kind: ArchitectureDriftFindingKind = after > before ? "duplicate-increase" : "duplicate-decrease"; + findings.push({ + kind, + severity: after > before ? "warning" : "info", + key: "duplicates:groups", + title: `Duplicate groups ${after > before ? "increased" : "decreased"}: ${before} -> ${after}`, + before, + after, + details: { + baseTopGroupKeys: base.duplicates.topGroupKeys, + headTopGroupKeys: head.duplicates.topGroupKeys, + }, + }); +} + +function pushGraphEdge( + findings: ArchitectureDriftFinding[], + kind: "graph-edge-added" | "graph-edge-removed", + edge: ArchitectureGraphEdge, +): void { + findings.push({ + kind, + severity: "info", + key: edge.key, + title: `${kind === "graph-edge-added" ? "Graph edge added" : "Graph edge removed"}: ${edge.from} -> ${edge.to}`, + file: edge.from, + edge, + }); +} + +function compareGraphEdges( + findings: ArchitectureDriftFinding[], + base: readonly ArchitectureGraphEdge[], + head: readonly ArchitectureGraphEdge[], +): void { + const baseByKey = byKey(base); + const headByKey = byKey(head); + 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); + } +} + +function compareFindings(left: ArchitectureDriftFinding, right: ArchitectureDriftFinding): number { + const severityRank = { error: 0, warning: 1, info: 2 } as const; + const severityDelta = severityRank[left.severity] - severityRank[right.severity]; + if (severityDelta) return severityDelta; + const kindDelta = left.kind.localeCompare(right.kind); + if (kindDelta) return kindDelta; + return left.key.localeCompare(right.key); +} + +export function compareArchitectureSnapshots( + base: ArchitectureSnapshot, + head: ArchitectureSnapshot, + options: ArchitectureDriftCompareOptions = {}, +): ArchitectureDriftReport { + const thresholds = { ...DEFAULT_DRIFT_THRESHOLDS, ...options.thresholds }; + const findings: ArchitectureDriftFinding[] = []; + compareCycles(findings, base.cycles, head.cycles); + compareHotspots(findings, base.hotspots, head.hotspots, thresholds.hotspotJump); + if (signalEnabled(base, "unresolved") && signalEnabled(head, "unresolved")) { + compareUnresolved(findings, base.unresolved.imports, head.unresolved.imports); + } + if (signalEnabled(base, "publicApi") && signalEnabled(head, "publicApi")) { + comparePublicApi(findings, base.publicApi, head.publicApi); + } + if (signalEnabled(base, "duplicates") && signalEnabled(head, "duplicates")) { + compareDuplicates(findings, base, head); + } + compareGraphEdges(findings, base.graphEdges, head.graphEdges); + findings.sort(compareFindings); + + const limitedFindings = findings.slice(0, thresholds.maxFindings); + const failOn = [...(options.failOn ?? [])].sort(); + const failOnSet = new Set(failOn); + const matchedFailKinds = findings.filter((finding) => failOnSet.has(finding.kind)).map((finding) => finding.kind); + const failedKinds = Array.from(new Set(matchedFailKinds)).sort(); + + return { + schemaVersion: 1, + root: head.root, + base: summarize(base), + head: summarize(head), + findings: limitedFindings, + policy: { + failed: !!failedKinds.length, + failOn, + failedKinds, + }, + omittedCounts: { + findings: Math.max(0, findings.length - limitedFindings.length), + }, + }; +} diff --git a/src/drift/git.ts b/src/drift/git.ts new file mode 100644 index 00000000..125cb327 --- /dev/null +++ b/src/drift/git.ts @@ -0,0 +1,104 @@ +import { execFile } from "node:child_process"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { isGitIndexSentinel, isGitWorktreeSentinel } from "../util/git.js"; +import { compareArchitectureSnapshots } from "./compare.js"; +import { buildArchitectureSnapshot } from "./snapshot.js"; +import { loadArchitectureSnapshotFromArtifact } from "./artifact.js"; +import type { ArchitectureDriftOptions, ArchitectureDriftReport, ArchitectureSnapshotOptions } from "./types.js"; + +const execFileAsync = promisify(execFile); + +function snapshotOptions(options: ArchitectureDriftOptions): ArchitectureSnapshotOptions { + return { + ...(options.includeRoots ? { includeRoots: options.includeRoots } : {}), + ...(options.discovery ? { discovery: options.discovery } : {}), + ...(options.graph ? { graph: options.graph } : {}), + ...(options.index ? { index: options.index } : {}), + ...(options.native !== undefined ? { native: options.native } : {}), + ...(options.duplicateLimit !== undefined ? { duplicateLimit: options.duplicateLimit } : {}), + }; +} + +function isCurrentCheckoutRef(ref: string | undefined): boolean { + return ref === undefined || ref === "." || (typeof ref === "string" && isGitWorktreeSentinel(ref)); +} + +async function cleanupTempDir(dir: string | undefined): Promise { + if (!dir) return; + try { + await fsp.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures so they do not mask the primary drift error. + } +} + +async function resolveGitCommit(root: string, ref: string): Promise { + const rev = `${ref}^{commit}`; + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--verify", "--quiet", "--end-of-options", rev], + { cwd: root, env: process.env }, + ); + return stdout.toString().trim(); +} + +async function materializeGitRef(root: string, ref: string | undefined, prefix: string): Promise<{ root: string; cleanup?: string }> { + const checkoutRef = ref; + if (checkoutRef !== undefined && isGitIndexSentinel(checkoutRef)) { + throw new Error("Architecture drift does not support STAGED/INDEX snapshots yet."); + } + if (checkoutRef === undefined || checkoutRef === "." || isGitWorktreeSentinel(checkoutRef)) return { root }; + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + await execFileAsync("git", ["clone", "--quiet", "--no-checkout", root, tempRoot], { env: process.env }); + const checkoutCommit = await resolveGitCommit(root, checkoutRef); + await execFileAsync("git", ["checkout", "--quiet", checkoutCommit], { cwd: tempRoot, env: process.env }); + return { root: tempRoot, cleanup: tempRoot }; + } catch (error) { + await cleanupTempDir(tempRoot); + throw error; + } +} + +export async function analyzeArchitectureDrift( + root: string, + options: ArchitectureDriftOptions, +): Promise { + if (options.baseArtifact && options.base) { + throw new Error("Architecture drift cannot combine --base with --base-artifact."); + } + + if (options.baseArtifact) { + if (options.head && !isCurrentCheckoutRef(options.head)) { + throw new Error("Architecture drift with --base-artifact only supports the current checkout as --head."); + } + 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 } : {}), + }); + } + if (!options.base) { + throw new Error("Architecture drift requires --base or --base-artifact."); + } + const resolvedRoot = path.resolve(root); + let base: { root: string; cleanup?: string } | undefined; + let head: { root: string; cleanup?: string } | undefined; + try { + base = await materializeGitRef(resolvedRoot, options.base, "cg-drift-base-"); + 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 } : {}), + }); + } finally { + await cleanupTempDir(head?.cleanup); + await cleanupTempDir(base?.cleanup); + } +} diff --git a/src/drift/index.ts b/src/drift/index.ts new file mode 100644 index 00000000..2cdcb084 --- /dev/null +++ b/src/drift/index.ts @@ -0,0 +1,28 @@ +export { buildArchitectureSnapshot } from "./snapshot.js"; +export { + ARCHITECTURE_DRIFT_FINDING_KINDS, + DEFAULT_DRIFT_THRESHOLDS, + compareArchitectureSnapshots, +} from "./compare.js"; +export { renderArchitectureDriftReport, type ArchitectureDriftRenderOptions } from "./report.js"; +export { analyzeArchitectureDrift } from "./git.js"; +export { loadArchitectureSnapshotFromArtifact } from "./artifact.js"; +export type { + ArchitectureCycle, + ArchitectureDriftCompareOptions, + ArchitectureDriftFinding, + ArchitectureDriftFindingKind, + ArchitectureDriftOptions, + ArchitectureDriftProvider, + ArchitectureDriftReport, + ArchitectureDriftSeverity, + ArchitectureDriftThresholds, + ArchitectureDuplicateSummary, + ArchitectureGraphEdge, + ArchitectureHotspot, + ArchitecturePublicApiSymbol, + ArchitectureSnapshot, + ArchitectureSnapshotOptions, + ArchitectureSnapshotSummary, + ArchitectureUnresolvedImport, +} from "./types.js"; diff --git a/src/drift/report.ts b/src/drift/report.ts new file mode 100644 index 00000000..7c1b97ca --- /dev/null +++ b/src/drift/report.ts @@ -0,0 +1,75 @@ +import type { ArchitectureDriftFinding, ArchitectureDriftReport, ArchitectureDriftSeverity } from "./types.js"; + +export interface ArchitectureDriftRenderOptions { + limit?: number; +} + +function severityHeading(severity: ArchitectureDriftSeverity): string { + if (severity === "error") return "Errors"; + if (severity === "warning") return "Warnings"; + return "Info"; +} + +function findingSubject(finding: ArchitectureDriftFinding): string { + if (finding.kind === "new-cycle" || finding.kind === "resolved-cycle") { + return (finding.files ?? []).join(" -> "); + } + if (finding.kind === "hotspot-jump" || finding.kind === "hotspot-drop") { + return `${finding.file ?? finding.key} score ${finding.before ?? 0} -> ${finding.after ?? 0}`; + } + if (finding.kind === "public-api-addition" || finding.kind === "public-api-removal") { + const symbol = finding.symbol; + return symbol ? `${symbol.file}#${symbol.name}` : finding.key; + } + if (finding.kind === "unresolved-import" || finding.kind === "resolved-unresolved-import") { + 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}`; + } + if (finding.edge) { + return `${finding.edge.from} -> ${finding.edge.to}`; + } + return finding.key; +} + +function pushSeveritySection(lines: string[], heading: string, findings: readonly ArchitectureDriftFinding[]): void { + if (!findings.length) return; + if (lines.length > 2) lines.push(""); + lines.push(heading); + for (const finding of findings) { + lines.push(`- ${finding.kind}: ${findingSubject(finding)}`); + } +} + +export function renderArchitectureDriftReport( + report: ArchitectureDriftReport, + options: ArchitectureDriftRenderOptions = {}, +): string { + const limit = options.limit ?? report.findings.length; + const findings = report.findings.slice(0, limit); + const lines = ["Architecture drift", ""]; + if (!findings.length) { + if (report.omittedCounts.findings || report.findings.length) { + lines.push("All architecture drift findings were omitted by the current limit."); + } else { + lines.push("No architecture drift findings."); + } + } else { + for (const severity of ["error", "warning", "info"] as const) { + pushSeveritySection( + lines, + severityHeading(severity), + findings.filter((finding) => finding.severity === severity), + ); + } + } + const omitted = report.omittedCounts.findings + Math.max(0, report.findings.length - findings.length); + if (omitted) { + lines.push("", `Omitted ${omitted} finding(s).`); + } + if (report.policy.failed) { + lines.push("", `Policy failed: ${report.policy.failedKinds.join(", ")}`); + } + return `${lines.join("\n")}\n`; +} diff --git a/src/drift/snapshot.ts b/src/drift/snapshot.ts new file mode 100644 index 00000000..7ae2f876 --- /dev/null +++ b/src/drift/snapshot.ts @@ -0,0 +1,201 @@ +import path from "node:path"; +import { findDuplicates } from "../duplicates.js"; +import { getHotspots } from "../graphs/hotspots.js"; +import { findDetailedCycles, getUnresolvedImports, sortDetailedCycles } from "../graphs/queries.js"; +import { buildProjectIndex, buildProjectIndexFromFiles } from "../indexer/build-index.js"; +import { getApiSurface } from "../indexer/symbols.js"; +import { supportForFile } from "../languages.js"; +import type { Edge } from "../types.js"; +import { DEFAULT_PROJECT_PATTERNS, listProjectFiles } from "../util/projectFiles.js"; +import { normalizePath, resolveFilePathFromRoot, toProjectDisplayPath } from "../util/paths.js"; +import type { + ArchitectureCycle, + ArchitectureDuplicateSummary, + ArchitectureGraphEdge, + ArchitectureHotspot, + ArchitecturePublicApiSymbol, + ArchitectureSnapshot, + ArchitectureSnapshotOptions, + ArchitectureUnresolvedImport, +} from "./types.js"; + +const DEFAULT_DUPLICATE_LIMIT = 50; + +function normalizeRoot(root: string): string { + return normalizePath(path.resolve(root)); +} + +function normalizeIncludeRoot(root: string, includeRoot: string): string { + return normalizePath(resolveFilePathFromRoot(root, includeRoot)); +} + +function isUnderIncludeRoots(filePath: string, roots: readonly string[]): boolean { + if (!roots.length) return true; + const normalizedFile = normalizePath(filePath); + return roots.some((root) => normalizedFile === root || normalizedFile.startsWith(`${root}/`)); +} + +async function listFilesForSnapshot(root: string, options: ArchitectureSnapshotOptions): Promise { + if (!options.includeRoots?.length) return undefined; + const roots = options.includeRoots.map((entry) => normalizeIncludeRoot(root, entry)); + const files = await listProjectFiles(root, DEFAULT_PROJECT_PATTERNS, options.discovery); + return files.filter((file) => isUnderIncludeRoots(file, roots)).sort(); +} + +function snapshotLanguageId(id: string): string { + if (id === "ts") return "typescript"; + return id; +} + +function languageCounts(files: Iterable): Record { + const counts: Record = {}; + for (const file of files) { + const support = supportForFile(file); + if (!support) continue; + const languageId = snapshotLanguageId(support.id); + counts[languageId] = (counts[languageId] ?? 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); +} + +function cycleKey(files: readonly string[]): string { + return [...files].sort().join("\0"); +} + +function toSnapshotCycles(root: string, cycles: ReturnType): ArchitectureCycle[] { + return sortDetailedCycles(cycles, "priority") + .map((cycle) => { + const files = cycle.files.map((file) => toProjectDisplayPath(root, file)).sort(); + return { + key: cycleKey(files), + files, + priorityScore: cycle.priorityScore, + size: cycle.fileCount, + }; + }) + .sort((left, right) => left.key.localeCompare(right.key)); +} + +function toSnapshotHotspots(root: string, hotspots: ReturnType): ArchitectureHotspot[] { + return hotspots + .map((entry) => ({ + file: toProjectDisplayPath(root, entry.file), + fanIn: entry.fanIn, + fanOut: entry.fanOut, + score: entry.score, + })) + .sort((left, right) => left.file.localeCompare(right.file)); +} + +function toSnapshotUnresolved( + root: string, + graph: Parameters[0], +): { total: number; imports: ArchitectureUnresolvedImport[] } { + const unresolved: ArchitectureUnresolvedImport[] = []; + for (const item of getUnresolvedImports(graph, { projectRoot: root })) { + for (const importer of item.importers) { + const file = toProjectDisplayPath(root, importer.file); + unresolved.push({ + key: `${file}\0${importer.raw}`, + file, + specifier: importer.raw, + }); + } + } + return { + total: unresolved.length, + imports: unresolved.sort((left, right) => left.key.localeCompare(right.key)), + }; +} + +function toSnapshotPublicApi(root: string, index: Parameters[0]): ArchitecturePublicApiSymbol[] { + const symbols: ArchitecturePublicApiSymbol[] = []; + for (const item of getApiSurface(index)) { + const file = toProjectDisplayPath(root, item.file); + for (const exp of item.exports) { + symbols.push({ + id: `${file}#${exp.exportedAs}:${exp.kind}`, + file, + name: exp.exportedAs, + kind: exp.kind, + }); + } + } + return symbols.sort((left, right) => left.id.localeCompare(right.id)); +} + +function duplicateGroupKey( + group: { primaryLeft: { file: string; startLine: number }; primaryRight: { file: string; startLine: number } }, +): string { + const left = `${group.primaryLeft.file}:${group.primaryLeft.startLine}`; + const right = `${group.primaryRight.file}:${group.primaryRight.startLine}`; + return left < right ? `${left}<->${right}` : `${right}<->${left}`; +} + +async function duplicateSummary(index: Parameters[0], limit: number): Promise { + const duplicateOptions = { + limit, + minConfidence: "medium" as const, + ...(index.projectRoot ? { projectRoot: index.projectRoot } : {}), + }; + const result = await findDuplicates(index, duplicateOptions); + const topGroupKeys = result.groups.map(duplicateGroupKey).sort(); + return { + groups: { total: result.groups.length + result.omittedCounts.groups }, + topGroupKeys, + }; +} + +function edgeTarget(edge: Edge, root: string): string { + if (edge.to.type === "file") return toProjectDisplayPath(root, edge.to.path); + return `external:${edge.to.name}`; +} + +function edgeKey(edge: Edge, root: string): string { + return `${toProjectDisplayPath(root, edge.from)}\0${edge.raw}\0${edgeTarget(edge, root)}`; +} + +function toSnapshotEdges(root: string, edges: readonly Edge[]): ArchitectureGraphEdge[] { + return edges + .map((edge) => ({ + key: edgeKey(edge, root), + from: toProjectDisplayPath(root, edge.from), + to: edgeTarget(edge, root), + raw: edge.raw, + })) + .sort((left, right) => left.key.localeCompare(right.key)); +} + +export async function buildArchitectureSnapshot( + rootInput: string, + options: ArchitectureSnapshotOptions = {}, +): Promise { + const root = normalizeRoot(rootInput); + const files = await listFilesForSnapshot(root, options); + const indexOptions = { + ...options.index, + ...(options.discovery !== undefined ? { discovery: options.discovery } : {}), + ...(options.graph !== undefined ? { graph: options.graph } : {}), + ...(options.native !== undefined ? { native: options.native } : {}), + }; + const index = files + ? await buildProjectIndexFromFiles(root, files, indexOptions) + : await buildProjectIndex(root, indexOptions); + const includeRoots = options.includeRoots?.map((entry) => normalizeIncludeRoot(root, entry)) ?? []; + const indexedFiles = [...index.byFile.keys()].sort(); + + return { + schemaVersion: 1, + root, + files: { + total: indexedFiles.length, + byLanguage: languageCounts(indexedFiles), + }, + hotspots: toSnapshotHotspots(root, getHotspots(index.graph, { includeRoots })), + cycles: toSnapshotCycles(root, findDetailedCycles(index.graph)), + unresolved: toSnapshotUnresolved(root, index.graph), + publicApi: toSnapshotPublicApi(root, index), + duplicates: await duplicateSummary(index, options.duplicateLimit ?? DEFAULT_DUPLICATE_LIMIT), + graphEdges: toSnapshotEdges(root, index.graph.edges), + }; +} diff --git a/src/drift/types.ts b/src/drift/types.ts new file mode 100644 index 00000000..d1040200 --- /dev/null +++ b/src/drift/types.ts @@ -0,0 +1,150 @@ +import type { BuildOptions } from "../indexer/types.js"; +import type { GraphBuildOptions } from "../graphs/types.js"; +import type { NativeRuntimeMode } from "../native/treeSitterNative.js"; +import type { ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; + +export type ArchitectureDriftFindingKind = + | "new-cycle" + | "resolved-cycle" + | "hotspot-jump" + | "hotspot-drop" + | "unresolved-import" + | "resolved-unresolved-import" + | "public-api-addition" + | "public-api-removal" + | "duplicate-increase" + | "duplicate-decrease" + | "graph-edge-added" + | "graph-edge-removed"; + +export type ArchitectureDriftSeverity = "error" | "warning" | "info"; + +export interface ArchitectureHotspot { + file: string; + fanIn: number; + fanOut: number; + score: number; +} + +export interface ArchitectureCycle { + key: string; + files: string[]; + priorityScore: number; + size: number; +} + +export interface ArchitectureUnresolvedImport { + key: string; + file: string; + specifier: string; +} + +export interface ArchitecturePublicApiSymbol { + id: string; + file: string; + name: string; + kind: string; +} + +export interface ArchitectureDuplicateSummary { + groups: { + total: number; + }; + topGroupKeys: string[]; +} + +export interface ArchitectureGraphEdge { + key: string; + from: string; + to: string; + raw: string; +} + +export interface ArchitectureUnresolvedImportSummary { + total: number; + imports: ArchitectureUnresolvedImport[]; +} + + +export interface ArchitectureSignalAvailability { + unresolved?: boolean; + publicApi?: boolean; + duplicates?: boolean; +} + +export interface ArchitectureSnapshot { + schemaVersion: 1; + root: string; + files: { total: number; byLanguage: Record }; + hotspots: ArchitectureHotspot[]; + cycles: ArchitectureCycle[]; + unresolved: ArchitectureUnresolvedImportSummary; + publicApi: ArchitecturePublicApiSymbol[]; + duplicates: ArchitectureDuplicateSummary; + graphEdges: ArchitectureGraphEdge[]; + signalAvailability?: ArchitectureSignalAvailability; +} + +export type ArchitectureSnapshotSummary = Pick< + ArchitectureSnapshot, + "root" | "files" | "hotspots" | "cycles" | "unresolved" | "publicApi" | "duplicates" +>; + +export interface ArchitectureDriftFinding { + kind: ArchitectureDriftFindingKind; + severity: ArchitectureDriftSeverity; + key: string; + title: string; + before?: number; + after?: number; + files?: string[]; + file?: string; + specifier?: string; + symbol?: ArchitecturePublicApiSymbol; + edge?: ArchitectureGraphEdge; + details?: Record; +} + +export interface ArchitectureDriftReport { + schemaVersion: 1; + root: string; + base: ArchitectureSnapshotSummary; + head: ArchitectureSnapshotSummary; + findings: ArchitectureDriftFinding[]; + policy: { + failed: boolean; + failOn: ArchitectureDriftFindingKind[]; + failedKinds: ArchitectureDriftFindingKind[]; + }; + omittedCounts: { + findings: number; + }; +} + +export interface ArchitectureDriftThresholds { + hotspotJump: number; + maxFindings: number; +} + +export interface ArchitectureDriftCompareOptions { + failOn?: ArchitectureDriftFindingKind[]; + thresholds?: Partial; +} + +export interface ArchitectureSnapshotOptions { + includeRoots?: string[]; + discovery?: ProjectFileDiscoveryOptions; + graph?: GraphBuildOptions; + index?: BuildOptions; + native?: NativeRuntimeMode; + duplicateLimit?: number; +} + +export type ArchitectureDriftProvider = "git"; + +export interface ArchitectureDriftOptions extends ArchitectureSnapshotOptions, ArchitectureDriftCompareOptions { + provider?: ArchitectureDriftProvider; + base?: string; + head?: string; + baseArtifact?: string; +} diff --git a/src/index.ts b/src/index.ts index 85631bff..f7f33c65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -354,6 +354,34 @@ export { type DuplicateUnitRef, } from "./duplicates.js"; +/** Architecture drift snapshots, comparisons, and report rendering. */ +export { + buildArchitectureSnapshot, + analyzeArchitectureDrift, + loadArchitectureSnapshotFromArtifact, + compareArchitectureSnapshots, + renderArchitectureDriftReport, + ARCHITECTURE_DRIFT_FINDING_KINDS, + DEFAULT_DRIFT_THRESHOLDS, + type ArchitectureCycle, + type ArchitectureDriftCompareOptions, + type ArchitectureDriftFinding, + type ArchitectureDriftFindingKind, + type ArchitectureDriftOptions, + type ArchitectureDriftProvider, + type ArchitectureDriftReport, + type ArchitectureDriftSeverity, + type ArchitectureDriftThresholds, + type ArchitectureDuplicateSummary, + type ArchitectureGraphEdge, + type ArchitectureHotspot, + type ArchitecturePublicApiSymbol, + type ArchitectureSnapshot, + type ArchitectureSnapshotOptions, + type ArchitectureSnapshotSummary, + type ArchitectureUnresolvedImport, +} from "./drift/index.js"; + /** Tree-sitter language configuration registry. */ export { LANG_CONFIGS, type LanguageConfig } from "./bootstrap/treeSitterLanguages.js"; diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 7739459a..6f74f7be 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -233,6 +233,7 @@ describe("CLI command modules", () => { usage: "Usage: codegraph explain ", }, { args: ["artifact", "--help"], heading: "codegraph artifact", usage: "Usage: codegraph artifact build" }, + { args: ["drift", "--help"], heading: "codegraph drift", usage: "Usage: codegraph drift [roots...]" }, { args: ["mcp", "--help"], heading: "codegraph mcp", usage: "Usage: codegraph mcp serve" }, ]; @@ -247,6 +248,15 @@ describe("CLI command modules", () => { } }); + test("documents drift-specific flags and head semantics in drift help", async () => { + const result = await captureCli(["drift", "--help"]); + + expect(result.stdout).toContain("--limit"); + expect(result.stdout).toContain("--hotspot-jump-threshold"); + expect(result.stdout).toContain("--head "); + expect(result.stdout).toContain("with --base-artifact, only the current checkout is supported"); + }); + test("rejects ambiguous MCP serve transport flags before starting a server", async () => { const result = await captureCli(["mcp", "serve", "--stdio", "--port", "3000"]); @@ -747,6 +757,58 @@ describe("CLI command modules", () => { } }); + test("runs drift through the main CLI dispatcher with policy exits", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-cli-drift-")); + await fsp.mkdir(path.join(tempDir, "src"), { recursive: true }); + await fsp.writeFile(path.join(tempDir, "src", "a.ts"), "import { b } from './b'; export function a() { return b(); }\n", "utf8"); + await fsp.writeFile(path.join(tempDir, "src", "b.ts"), "export function b() { return 1; }\n", "utf8"); + await import("./helpers/git.js").then(({ runGit }) => { + runGit(tempDir, ["init"]); + runGit(tempDir, ["add", "."]); + runGit(tempDir, ["commit", "-m", "base"]); + }); + await fsp.writeFile(path.join(tempDir, "src", "b.ts"), "import { a } from './a'; export function b() { return a(); }\n", "utf8"); + await import("./helpers/git.js").then(({ runGit }) => { + runGit(tempDir, ["add", "."]); + runGit(tempDir, ["commit", "-m", "head"]); + }); + + try { + const json = await captureCli(["drift", "src", "--root", tempDir, "--base", "HEAD~1", "--head", "HEAD", "--json"]); + const noFail = await captureCli([ + "drift", + "src", + "--root", + tempDir, + "--base", + "HEAD~1", + "--head", + "HEAD", + "--fail-on", + "public-api-removal", + ]); + const fail = await captureCli([ + "drift", + "src", + "--root", + tempDir, + "--base", + "HEAD~1", + "--head", + "HEAD", + "--fail-on", + "new-cycle", + ]); + + expect(JSON.parse(json.stdout)).toMatchObject({ schemaVersion: 1 }); + expect(noFail.exitCode).toBeUndefined(); + expect(fail.exitCode).toBe(1); + expect(fail.stdout).toContain("new-cycle"); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } + }); + test("parses space-separated cycle sort values through the main CLI dispatcher", async () => { const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-cli-cycle-sort-")); await fsp.writeFile(path.join(tempDir, "main.ts"), "export const value = 1;\n", "utf8"); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index f907a9a1..750f9311 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -85,6 +85,36 @@ async function runCliInProcess(args: string[], cwd: string): Promise<{ stdout: s return { stdout, stderr }; } +async function runCliWithExit( + args: string[], + cwd: string, +): Promise<{ stdout: string; stderr: string; exitCode: number | undefined }> { + let stdout = ""; + let stderr = ""; + let exitCode: number | undefined; + + await runCli(args, { + cwd: () => cwd, + stdout: (chunk) => { + stdout += chunk; + }, + stderr: (chunk) => { + stderr += chunk; + }, + exit: (code) => { + exitCode = code; + throw new Error(`codegraph CLI exited ${code}`); + }, + }).catch((error: unknown) => { + if (error instanceof Error && exitCode !== undefined && error.message === `codegraph CLI exited ${exitCode}`) { + return; + } + throw error; + }); + + return { stdout, stderr, exitCode }; +} + function normalize(p: string): string { return p.replace(/\\/g, "/"); } @@ -1464,6 +1494,111 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) expect(await fsp.stat(path.join(outDir, "manifest.json"))).toBeTruthy(); }); + it("drift CLI prints JSON and honors fail-on policy", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "import { b } from './b'; export function a() { return b(); }\n", "utf8"); + await fsp.writeFile(path.join(root, "src", "b.ts"), "export function b() { 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'; export function b() { return a(); }\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "head"]); + + const json = await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD~1", "--head", "HEAD", "--json"]); + const failed = await runCliWithExit( + ["drift", "src", "--root", root, "--base", "HEAD~1", "--head", "HEAD", "--fail-on", "new-cycle"], + root, + ); + + expect(JSON.parse(json)).toMatchObject({ schemaVersion: 1 }); + expect(failed.exitCode).toBe(1); + expect(failed.stdout).toContain("new-cycle"); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("drift defaults to JSON output when no explicit output flag is passed", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-default-json-")); + 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 stdout = await runCliCommand(["drift", "src", "--root", root, "--base", "HEAD", "--head", "WORKTREE"]); + + expect(JSON.parse(stdout)).toMatchObject({ schemaVersion: 1 }); + } 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 { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "export const value = 1;\n", "utf8"); + const baselineDir = path.join(root, "baseline"); + await runCliCommand(["artifact", "build", "--root", root, "--out", baselineDir, "--json"]); + + const result = await runCliWithExit( + ["drift", "src", "--root", root, "--base", "HEAD~1", "--base-artifact", baselineDir, "--json"], + root, + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("either --base or --base-artifact"); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("drift reports invalid git refs without a stack trace", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-invalid-ref-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "export const value = 1;\n", "utf8"); + git(root, ["init"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + + const result = await runCliWithExit(["drift", "src", "--root", root, "--base", "definitely-not-a-real-ref"], root); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("definitely-not-a-real-ref"); + expect(result.stderr).not.toContain("at analyzeArchitectureDrift"); + expect(result.stderr).not.toContain("node:child_process"); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it("drift treats a single positional path as an include root", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-root-")); + try { + await fsp.mkdir(path.join(root, "src"), { recursive: true }); + await fsp.writeFile(path.join(root, "src", "a.ts"), "import { b } from './b'; export function a() { return b(); }\n", "utf8"); + await fsp.writeFile(path.join(root, "src", "b.ts"), "export function b() { 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'; export function b() { return a(); }\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "head"]); + + const json = await runCliCommandDetailed(["drift", "./src", "--base", "HEAD~1", "--head", "HEAD", "--json"], undefined, root); + + expect(JSON.parse(json.stdout)).toMatchObject({ schemaVersion: 1 }); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + it("artifact build treats --sqlite as a boolean artifact selector", async () => { const root = await fsp.mkdtemp(path.join(os.tmpdir(), "cg-cli-artifact-sqlite-")); const outDir = path.join(root, "out"); diff --git a/tests/drift-artifact.test.ts b/tests/drift-artifact.test.ts new file mode 100644 index 00000000..4ecd5fad --- /dev/null +++ b/tests/drift-artifact.test.ts @@ -0,0 +1,239 @@ +import path from "node:path"; +import fsp from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { buildCodegraphArtifact } from "../src/agent/artifact.js"; +import { analyzeArchitectureDrift, loadArchitectureSnapshotFromArtifact } from "../src/drift/index.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +async function writeFile(root: string, file: string, content: string): Promise { + const fullPath = path.join(root, file); + await fsp.mkdir(path.dirname(fullPath), { recursive: true }); + await fsp.writeFile(fullPath, content, "utf8"); +} + +describe("architecture drift artifact baselines", () => { + it("loads a manifest-backed graph artifact and ignores unrelated files", async () => { + const root = await mkTmpDir("cg-drift-artifact-"); + await writeFile(root, "src/a.ts", "import { b } from './b'; export function a() { return b(); }\n"); + await writeFile(root, "src/b.ts", "export function b() { return 1; }\n"); + const outDir = path.join(root, "baseline"); + await buildCodegraphArtifact({ root, outDir, graphJson: true }); + await writeFile(outDir, "notes.txt", "operator notes\n"); + + const snapshot = await loadArchitectureSnapshotFromArtifact(outDir); + + expect(snapshot.files.total).toBe(2); + expect(snapshot.unresolved.total).toBe(0); + }); + + it("loads artifact cycles in deterministic key order", async () => { + const root = await mkTmpDir("cg-drift-artifact-cycles-"); + await writeFile( + root, + "manifest.json", + JSON.stringify({ + schemaVersion: 1, + graphJsonSchema: "codegraph.graph-json", + artifacts: { graphJson: "graph.json" }, + }), + ); + await writeFile( + root, + "graph.json", + JSON.stringify({ + schemaVersion: 1, + format: "codegraph.graph-json", + files: ["z/a.ts", "z/b.ts", "a/a.ts", "a/b.ts"], + fileEdges: [ + { from: "z/a.ts", to: { type: "file", path: "z/b.ts" }, raw: "./b" }, + { from: "z/b.ts", to: { type: "file", path: "z/a.ts" }, raw: "./a" }, + { from: "a/a.ts", to: { type: "file", path: "a/b.ts" }, raw: "./b" }, + { from: "a/b.ts", to: { type: "file", path: "a/a.ts" }, raw: "./a" }, + ], + symbols: [], + symbolEdges: [], + graph: { + files: ["z/a.ts", "z/b.ts", "a/a.ts", "a/b.ts"], + fileEdges: [ + { from: "z/a.ts", to: { type: "file", path: "z/b.ts" }, raw: "./b" }, + { from: "z/b.ts", to: { type: "file", path: "z/a.ts" }, raw: "./a" }, + { from: "a/a.ts", to: { type: "file", path: "a/b.ts" }, raw: "./b" }, + { from: "a/b.ts", to: { type: "file", path: "a/a.ts" }, raw: "./a" }, + ], + symbols: [], + symbolEdges: [], + }, + }), + ); + + const snapshot = await loadArchitectureSnapshotFromArtifact(root); + + expect(snapshot.cycles.map((cycle) => cycle.key)).toEqual(["a/a.ts\u0000a/b.ts", "z/a.ts\u0000z/b.ts"]); + }); + + it("builds distinct artifact cycle keys for ambiguous filenames", async () => { + const root = await mkTmpDir("cg-drift-artifact-cycle-keys-"); + await writeFile( + root, + "manifest.json", + JSON.stringify({ + schemaVersion: 1, + graphJsonSchema: "codegraph.graph-json", + artifacts: { graphJson: "graph.json" }, + }), + ); + await writeFile( + root, + "graph.json", + JSON.stringify({ + schemaVersion: 1, + format: "codegraph.graph-json", + graph: { + files: ["src/a.d.ts->b.ts", "src/c.ts", "src/a.d.ts", "b.ts->src/d.ts"], + fileEdges: [ + { from: "src/a.d.ts->b.ts", to: { type: "file", path: "src/c.ts" }, raw: "./c" }, + { from: "src/c.ts", to: { type: "file", path: "src/a.d.ts->b.ts" }, raw: "./a.d.ts->b" }, + { from: "src/a.d.ts", to: { type: "file", path: "b.ts->src/d.ts" }, raw: "../b.ts->src/d" }, + { from: "b.ts->src/d.ts", to: { type: "file", path: "src/a.d.ts" }, raw: "../src/a.d" }, + ], + symbols: [], + }, + }), + ); + + const snapshot = await loadArchitectureSnapshotFromArtifact(root); + + expect(snapshot.cycles).toHaveLength(2); + expect(new Set(snapshot.cycles.map((cycle) => cycle.key)).size).toBe(2); + }); + + it("loads artifact hotspots in file order", async () => { + const root = await mkTmpDir("cg-drift-artifact-hotspots-"); + await writeFile( + root, + "manifest.json", + JSON.stringify({ + schemaVersion: 1, + graphJsonSchema: "codegraph.graph-json", + artifacts: { graphJson: "graph.json" }, + }), + ); + await writeFile( + root, + "graph.json", + JSON.stringify({ + schemaVersion: 1, + format: "codegraph.graph-json", + graph: { + files: ["z.ts", "a.ts", "m.ts"], + fileEdges: [ + { from: "z.ts", to: { type: "file", path: "a.ts" }, raw: "./a" }, + { from: "z.ts", to: { type: "file", path: "m.ts" }, raw: "./m" }, + { from: "m.ts", to: { type: "file", path: "a.ts" }, raw: "./a" }, + ], + symbols: [], + }, + }), + ); + + const snapshot = await loadArchitectureSnapshotFromArtifact(root); + + expect(snapshot.hotspots.map((entry) => entry.file)).toEqual(["a.ts", "m.ts", "z.ts"]); + }); + + it("compares artifact baselines to the current checkout", async () => { + const root = await mkTmpDir("cg-drift-artifact-head-"); + await writeFile(root, "src/a.ts", "import { b } from './b'; export function a() { return b(); }\n"); + await writeFile(root, "src/b.ts", "export function b() { return 1; }\n"); + const outDir = path.join(root, "baseline"); + await buildCodegraphArtifact({ root, outDir, graphJson: true }); + + await writeFile(root, "src/b.ts", "import { a } from './a'; export function b() { return a(); }\n"); + const report = await analyzeArchitectureDrift(root, { baseArtifact: outDir, head: ".", includeRoots: ["src"] }); + + expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle" })); + }); + + it("rejects non-current heads when comparing against an artifact baseline", async () => { + const root = await mkTmpDir("cg-drift-artifact-reject-head-"); + await writeFile(root, "src/a.ts", "export function a() { return 1; }\n"); + const outDir = path.join(root, "baseline"); + await buildCodegraphArtifact({ root, outDir, graphJson: true }); + + await expect( + analyzeArchitectureDrift(root, { baseArtifact: outDir, head: "HEAD~1", includeRoots: ["src"] }), + ).rejects.toThrow("base-artifact"); + }); + + it("rejects combining base and baseArtifact in the library API", async () => { + const root = await mkTmpDir("cg-drift-artifact-reject-base-and-artifact-"); + await writeFile(root, "src/a.ts", "export function a() { return 1; }\n"); + const outDir = path.join(root, "baseline"); + await buildCodegraphArtifact({ root, outDir, graphJson: true }); + + await expect( + analyzeArchitectureDrift(root, { base: "HEAD", baseArtifact: outDir, includeRoots: ["src"] }), + ).rejects.toThrow("cannot combine"); + }); + + it("does not report unresolved-import drift for declared package imports from graph-json artifacts", async () => { + const root = await mkTmpDir("cg-drift-artifact-unresolved-"); + await writeFile(root, "package.json", '{\n "name": "artifact-unresolved",\n "dependencies": { "left-pad": "1.3.0" }\n}\n'); + await writeFile(root, "src/a.ts", 'import leftPad from "left-pad";\nexport const value = leftPad("a", 2);\n'); + const outDir = path.join(root, "baseline"); + await buildCodegraphArtifact({ root, outDir, graphJson: true }); + + const report = await analyzeArchitectureDrift(root, { baseArtifact: outDir, head: ".", includeRoots: ["src"] }); + + expect(report.findings.some((finding) => finding.kind === "unresolved-import")).toBe(false); + expect(report.findings.some((finding) => finding.kind === "resolved-unresolved-import")).toBe(false); + }); + + it("rejects missing artifact manifest and graph files with clear errors", async () => { + const missingManifest = await mkTmpDir("cg-drift-missing-manifest-"); + await expect(loadArchitectureSnapshotFromArtifact(missingManifest)).rejects.toThrow("Codegraph artifact manifest"); + + const missingGraph = await mkTmpDir("cg-drift-missing-graph-"); + await fsp.writeFile( + path.join(missingGraph, "manifest.json"), + JSON.stringify({ + schemaVersion: 1, + graphJsonSchema: "codegraph.graph-json", + artifacts: { graphJson: "graph.json" }, + }), + "utf8", + ); + + await expect(loadArchitectureSnapshotFromArtifact(missingGraph)).rejects.toThrow("Codegraph artifact graph.json"); + }); + + it("does not invent API or duplicate drift from derived artifact baselines", async () => { + const root = await mkTmpDir("cg-drift-artifact-derived-"); + await writeFile( + root, + "src/a.ts", + "function helper() { return 1; }\nexport function a() { return helper(); }\n", + ); + const outDir = path.join(root, "baseline"); + await buildCodegraphArtifact({ root, outDir, graphJson: true }); + + await writeFile( + root, + "src/a.ts", + "function renamedHelper() { return 1; }\nexport function a() { return renamedHelper(); }\n", + ); + const report = await analyzeArchitectureDrift(root, { baseArtifact: outDir, head: ".", includeRoots: ["src"] }); + + expect(report.findings.some((finding) => finding.kind === "public-api-addition")).toBe(false); + expect(report.findings.some((finding) => finding.kind === "public-api-removal")).toBe(false); + expect(report.findings.some((finding) => finding.kind === "duplicate-increase")).toBe(false); + expect(report.findings.some((finding) => finding.kind === "duplicate-decrease")).toBe(false); + }); + + it("rejects directories without required artifact files", async () => { + const root = await mkTmpDir("cg-drift-bad-artifact-"); + await fsp.writeFile(path.join(root, "manifest.json"), "{}\n", "utf8"); + + await expect(loadArchitectureSnapshotFromArtifact(root)).rejects.toThrow("Codegraph artifact manifest"); + }); +}); diff --git a/tests/drift-git-provider.test.ts b/tests/drift-git-provider.test.ts new file mode 100644 index 00000000..48a74d34 --- /dev/null +++ b/tests/drift-git-provider.test.ts @@ -0,0 +1,146 @@ +import path from "node:path"; +import fsp from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { analyzeArchitectureDrift } from "../src/drift/index.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; +import { runGit } from "./helpers/git.js"; + +async function writeFile(root: string, file: string, content: string): Promise { + const fullPath = path.join(root, file); + await fsp.mkdir(path.dirname(fullPath), { recursive: true }); + await fsp.writeFile(fullPath, content, "utf8"); +} + +async function commitAll(root: string, message: string): Promise { + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", message]); + return runGit(root, ["rev-parse", "HEAD"]); +} + + +describe("architecture drift git provider", () => { + it("compares git refs without dirtying the worktree", async () => { + const root = await mkTmpDir("cg-drift-git-"); + runGit(root, ["init"]); + await writeFile(root, "src/a.ts", "import { b } from './b'; export function a() { return b(); }\n"); + await writeFile(root, "src/b.ts", "export function b() { return 1; }\n"); + await commitAll(root, "base"); + + await writeFile(root, "src/b.ts", "import { a } from './a'; export function b() { return a(); }\n"); + await commitAll(root, "head"); + + const beforeStatus = runGit(root, ["status", "--short"]); + const report = await analyzeArchitectureDrift(root, { + provider: "git", + base: "HEAD~1", + head: "HEAD", + includeRoots: ["src"], + }); + const afterStatus = runGit(root, ["status", "--short"]); + + expect(beforeStatus).toBe(""); + expect(afterStatus).toBe(""); + expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle", severity: "error" })); + }); + + it("accepts WORKTREE as the head sentinel", async () => { + const root = await mkTmpDir("cg-drift-worktree-"); + runGit(root, ["init"]); + await writeFile(root, "src/a.ts", "import { b } from './b'; export function a() { return b(); }\n"); + await writeFile(root, "src/b.ts", "export function b() { return 1; }\n"); + await commitAll(root, "base"); + + await writeFile(root, "src/b.ts", "import { a } from './a'; export function b() { return a(); }\n"); + + const report = await analyzeArchitectureDrift(root, { + provider: "git", + base: "HEAD", + head: "WORKTREE", + includeRoots: ["src"], + }); + + expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle", severity: "error" })); + }); + + it("cleans up the base temp checkout when head materialization fails", async () => { + const root = await mkTmpDir("cg-drift-git-cleanup-"); + const isolatedTmp = await mkTmpDir("cg-drift-isolated-tmp-"); + runGit(root, ["init"]); + await writeFile(root, "src/a.ts", "export function a() { return 1; }\n"); + await commitAll(root, "base"); + + vi.resetModules(); + vi.doMock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, tmpdir: () => isolatedTmp }; + }); + + try { + const { analyzeArchitectureDrift: analyzeWithMock } = await import("../src/drift/git.js"); + const beforeEntries = await fsp.readdir(isolatedTmp); + await expect( + analyzeWithMock(root, { + provider: "git", + base: "HEAD", + head: "definitely-not-a-real-ref", + includeRoots: ["src"], + }), + ).rejects.toThrow(); + const afterEntries = await fsp.readdir(isolatedTmp); + expect(afterEntries).toEqual(beforeEntries); + } finally { + vi.doUnmock("node:os"); + vi.resetModules(); + await fsp.rm(isolatedTmp, { recursive: true, force: true }); + } + }); + + it("treats option-like refs as invalid revisions instead of checkout options", async () => { + const root = await mkTmpDir("cg-drift-git-option-like-ref-"); + runGit(root, ["init"]); + await writeFile(root, "src/a.ts", "export function a() { return 1; }\n"); + await commitAll(root, "base"); + + await expect( + analyzeArchitectureDrift(root, { + provider: "git", + base: "-h", + head: "HEAD", + includeRoots: ["src"], + }), + ).rejects.not.toThrow(/git checkout|switch branches|usage: git checkout/i); + }); + + it("preserves the original git error when cleanup fails", async () => { + const root = await mkTmpDir("cg-drift-git-cleanup-error-"); + runGit(root, ["init"]); + await writeFile(root, "src/a.ts", "export function a() { return 1; }\n"); + await commitAll(root, "base"); + + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + rm: vi.fn(async () => { + throw new Error("cleanup failed"); + }), + }; + }); + + try { + const { analyzeArchitectureDrift: analyzeWithMock } = await import("../src/drift/git.js"); + await expect( + analyzeWithMock(root, { + provider: "git", + base: "HEAD", + head: "definitely-not-a-real-ref", + includeRoots: ["src"], + }), + ).rejects.not.toThrow("cleanup failed"); + } finally { + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } + }); +}); diff --git a/tests/drift.test.ts b/tests/drift.test.ts new file mode 100644 index 00000000..698feab8 --- /dev/null +++ b/tests/drift.test.ts @@ -0,0 +1,186 @@ +import path from "node:path"; +import fsp from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { + buildArchitectureSnapshot, + compareArchitectureSnapshots, + renderArchitectureDriftReport, + type ArchitectureSnapshot, +} from "../src/drift/index.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +async function writeFile(root: string, file: string, content: string): Promise { + const fullPath = path.join(root, file); + await fsp.mkdir(path.dirname(fullPath), { recursive: true }); + await fsp.writeFile(fullPath, content, "utf8"); +} + +function makeSnapshot(overrides: Partial = {}): ArchitectureSnapshot { + return { + schemaVersion: 1, + root: "/repo", + files: { total: 0, byLanguage: {} }, + hotspots: [], + cycles: [], + unresolved: { total: 0, imports: [] }, + publicApi: [], + duplicates: { groups: { total: 0 }, topGroupKeys: [] }, + graphEdges: [], + ...overrides, + }; +} + +describe("architecture drift", () => { + it("builds a deterministic architecture snapshot", async () => { + const root = await mkTmpDir("cg-drift-snapshot-"); + await writeFile(root, "src/a.ts", "import { b } from './b'; export function a() { return b(); }\n"); + await writeFile(root, "src/b.ts", "export function b() { return 1; }\n"); + + const first = await buildArchitectureSnapshot(root, { includeRoots: ["src"] }); + const second = await buildArchitectureSnapshot(root, { includeRoots: ["src"] }); + + expect(first).toEqual(second); + expect(first.files.total).toBe(2); + expect(first.files.byLanguage.typescript).toBe(2); + expect(first.unresolved.total).toBe(0); + expect(first.hotspots.length).toBeGreaterThan(0); + expect(first.cycles).toEqual([]); + }); + + it("uses the same default project file patterns when include roots cover the whole repo", async () => { + const root = await mkTmpDir("cg-drift-snapshot-roots-"); + await writeFile(root, "src/a.ts", "export function a() { return 1; }\n"); + await writeFile(root, "notes.yaml", "ignored: true\n"); + + const wholeRepo = await buildArchitectureSnapshot(root); + const explicitWholeRepo = await buildArchitectureSnapshot(root, { includeRoots: ["."] }); + + expect(explicitWholeRepo.files).toEqual(wholeRepo.files); + }); + + it("uses collision-safe cycle keys", () => { + const ambiguousA = ["a->b", "c"]; + const ambiguousB = ["a", "b->c"]; + + expect(ambiguousA.join("->")).toBe(ambiguousB.join("->")); + expect(ambiguousA.join("\u0000")).not.toBe(ambiguousB.join("\u0000")); + }); + + it("reports new cycles without reporting pre-existing cycles", () => { + const base = makeSnapshot({ + cycles: [{ key: "src/old-a.ts\u0000src/old-b.ts", files: ["src/old-a.ts", "src/old-b.ts"], priorityScore: 10, size: 2 }], + }); + const head = makeSnapshot({ + cycles: [ + { key: "src/old-a.ts\u0000src/old-b.ts", files: ["src/old-a.ts", "src/old-b.ts"], priorityScore: 10, size: 2 }, + { key: "src/a.ts\u0000src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }, + ], + }); + + const report = compareArchitectureSnapshots(base, head, { failOn: [] }); + + expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle", severity: "error" })); + expect(report.findings).not.toContainEqual( + expect.objectContaining({ kind: "new-cycle", key: "src/old-a.ts\u0000src/old-b.ts" }), + ); + }); + + it("reports public API removals", () => { + const base = makeSnapshot({ + publicApi: [{ id: "src/api.ts#oldName:function", file: "src/api.ts", name: "oldName", kind: "function" }], + }); + const head = makeSnapshot({ publicApi: [] }); + + const report = compareArchitectureSnapshots(base, head, { failOn: [] }); + + expect(report.findings).toContainEqual(expect.objectContaining({ kind: "public-api-removal", severity: "error" })); + }); + + it("compares duplicate group counts and stable top group keys", () => { + const base = makeSnapshot({ duplicates: { groups: { total: 1 }, topGroupKeys: ["a.ts:1-b.ts:1"] } }); + const head = makeSnapshot({ duplicates: { groups: { total: 3 }, topGroupKeys: ["a.ts:1-b.ts:1", "c.ts:1-d.ts:1"] } }); + + const report = compareArchitectureSnapshots(base, head, { failOn: [] }); + + expect(report.findings).toContainEqual( + expect.objectContaining({ kind: "duplicate-increase", severity: "warning", before: 1, after: 3 }), + ); + }); + + 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: [] }); + + const ignored = compareArchitectureSnapshots(base, head, { failOn: ["new-cycle"] }); + const selected = compareArchitectureSnapshots(base, head, { failOn: ["public-api-removal"] }); + + expect(ignored.policy.failed).toBe(false); + expect(selected.policy.failed).toBe(true); + expect(selected.policy.failedKinds).toEqual(["public-api-removal"]); + }); + + 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 }] }); + + const report = compareArchitectureSnapshots(base, head, { + failOn: [], + thresholds: { hotspotJump: 0, maxFindings: 100 }, + }); + + expect(report.findings.some((finding) => finding.kind === "hotspot-jump")).toBe(false); + expect(report.findings.some((finding) => finding.kind === "hotspot-drop")).toBe(false); + }); + + it("applies fail-on policy even when matching findings are omitted from the report", () => { + const base = makeSnapshot(); + const head = makeSnapshot({ + cycles: [{ key: "src/a.ts\u0000src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + }); + + const report = compareArchitectureSnapshots(base, head, { + failOn: ["new-cycle"], + thresholds: { hotspotJump: 20, maxFindings: 0 }, + }); + + expect(report.findings).toEqual([]); + expect(report.policy.failed).toBe(true); + expect(report.policy.failedKinds).toEqual(["new-cycle"]); + }); + + it("does not say there are no findings when all findings are omitted", () => { + const report = compareArchitectureSnapshots( + makeSnapshot(), + makeSnapshot({ + cycles: [{ key: "src/a.ts\u0000src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + }), + { failOn: [], thresholds: { hotspotJump: 20, maxFindings: 0 } }, + ); + + const text = renderArchitectureDriftReport(report); + + expect(text).not.toContain("No architecture drift findings."); + expect(text).toContain("All architecture drift findings were omitted by the current limit."); + expect(text).toContain("Omitted 1 finding(s)."); + }); + + 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" }] }), + makeSnapshot({ + cycles: [{ key: "src/a.ts\u0000src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + hotspots: [{ file: "src/core.ts", fanIn: 20, fanOut: 32, score: 72 }], + }), + { failOn: [], thresholds: { hotspotJump: 20, maxFindings: 100 } }, + ); + + const text = renderArchitectureDriftReport(report, { limit: 10 }); + + expect(text).toContain("Architecture drift"); + expect(text).toContain("Errors"); + expect(text).toContain("- new-cycle: src/a.ts -> src/b.ts"); + expect(text).toContain("- public-api-removal: src/api.ts#oldName"); + expect(text).toContain("Warnings"); + expect(text).toContain("- hotspot-jump: src/core.ts score 0 -> 72"); + }); +});