Arch drift check#115
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an “architecture drift” capability to Codegraph, enabling deterministic snapshotting of architecture signals and comparison across git refs or artifact baselines, with both CLI and library entrypoints.
Changes:
- Introduces a drift snapshot/compare/report pipeline (cycles, hotspots, unresolved imports, public API, duplicates, graph edges) plus artifact baseline loading.
- Adds
codegraph driftCLI command with JSON/pretty output and--fail-onpolicy gating, wired into the main CLI dispatcher. - Adds comprehensive Vitest coverage and updates README/docs/skill guidance to document drift usage.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/drift.test.ts | Unit tests for snapshot determinism, drift findings, policy selection, and pretty report rendering. |
| tests/drift-git-provider.test.ts | Exercises git-ref comparisons and asserts the worktree is not dirtied. |
| tests/drift-artifact.test.ts | Validates artifact baseline loading, head constraints, and signal behavior for artifacts. |
| tests/cli-regressions.test.ts | Adds CLI regression coverage for drift JSON output and fail-on exit behavior. |
| tests/cli-command-modules.test.ts | Ensures drift is exposed via the CLI dispatcher and help output is discoverable. |
| src/index.ts | Exports drift APIs/types from the package entrypoint. |
| src/drift/types.ts | Defines drift snapshot/report types and finding kinds. |
| src/drift/snapshot.ts | Builds deterministic architecture snapshots from a checkout. |
| src/drift/report.ts | Renders a grouped pretty drift report. |
| src/drift/index.ts | Drift module barrel exports. |
| src/drift/git.ts | Implements drift analysis across git refs or artifact baselines. |
| src/drift/compare.ts | Compares snapshots, generates findings, applies thresholds and fail-on policy. |
| src/drift/artifact.ts | Loads drift snapshots from manifest-backed graph-json artifacts. |
| src/cli/options.ts | Registers new CLI value options (--base-artifact, --fail-on, --hotspot-jump-threshold). |
| src/cli/help.ts | Adds drift command to help output and provides drift help text. |
| src/cli/drift.ts | Implements the drift CLI command handler (policy, thresholds, output). |
| src/cli.ts | Routes drift through the main CLI dispatcher and adjusts positional include-root behavior. |
| README.md | Documents drift at a high level and shows CLI/library examples. |
| docs/library-api.md | Documents analyzeArchitectureDrift() usage and output shape. |
| docs/cli.md | Adds drift CLI examples and guidance about policy gating. |
| docs/agent-workflows.md | Adds drift to recommended agent workflows. |
| codegraph-skill/codegraph/SKILL.md | Updates skill guidance to include the drift command and CI gating examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const before = baseByFile.get(file)?.score ?? 0; | ||
| const after = headByFile.get(file)?.score ?? 0; | ||
| const delta = after - before; | ||
| if (Math.abs(delta) < threshold) continue; | ||
| const kind: ArchitectureDriftFindingKind = delta > 0 ? "hotspot-jump" : "hotspot-drop"; |
| const limitedFindings = findings.slice(0, thresholds.maxFindings); | ||
| const failOn = [...(options.failOn ?? [])].sort(); | ||
| const failOnSet = new Set(failOn); | ||
| const matchedFailKinds = limitedFindings | ||
| .filter((finding) => failOnSet.has(finding.kind)) | ||
| .map((finding) => finding.kind); | ||
| const failedKinds = Array.from(new Set(matchedFailKinds)).sort(); |
| const base = context.getOpt("--base"); | ||
| const baseArtifact = context.getOpt("--base-artifact"); | ||
| if (!base && !baseArtifact) { | ||
| context.writeStderrLine("Usage: codegraph drift [roots...] --base <ref> [--head <ref>] [--json | --pretty]"); | ||
| context.writeStderrLine("Provide either --base or --base-artifact."); | ||
| context.exit(2); | ||
| } |
| const DUPLICATE_PROJECT_PATTERNS = [...DEFAULT_PROJECT_PATTERNS, "**/*.{json,jsonc,toml,txt,yaml,yml}"]; | ||
| 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<string[] | undefined> { | ||
| if (!options.includeRoots?.length) return undefined; | ||
| const roots = options.includeRoots.map((entry) => normalizeIncludeRoot(root, entry)); | ||
| const files = await listProjectFiles(root, DUPLICATE_PROJECT_PATTERNS, options.discovery); | ||
| return files.filter((file) => isUnderIncludeRoots(file, roots)).sort(); |
| cycles: sortDetailedCycles(findDetailedCycles(graph), "priority").map((cycle) => { | ||
| const files = cycle.files.map(normalizePath).sort(); | ||
| return { key: files.join("->"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; | ||
| }), |
| const limit = options.limit ?? report.findings.length; | ||
| const findings = report.findings.slice(0, limit); | ||
| const lines = ["Architecture drift", ""]; | ||
| if (!findings.length) { | ||
| 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).`); | ||
| } |
| throw new Error("Architecture drift requires --base or --base-artifact."); | ||
| } | ||
| const resolvedRoot = path.resolve(root); | ||
| const base = await materializeGitRef(resolvedRoot, options.base, "cg-drift-base-"); |
There was a problem hiding this comment.
WARNING: Temp directory leak if head materialization fails
base is created outside the try block. If materializeGitRef for head throws, base.cleanup is never removed because control never reaches the finally block. Move both materialization calls inside the try (or add a catch around head that cleans up base).
| await execFileAsync("git", ["checkout", "--quiet", checkoutRef], { cwd: tempRoot, env: process.env }); | ||
| return { root: tempRoot, cleanup: tempRoot }; | ||
| } catch (error) { | ||
| await fsp.rm(tempRoot, { recursive: true, force: true }); |
There was a problem hiding this comment.
WARNING: Cleanup failure masks the original error
If fsp.rm throws during cleanup, the original git clone/git checkout error is lost.
| await fsp.rm(tempRoot, { recursive: true, force: true }); | |
| try { await fsp.rm(tempRoot, { recursive: true, force: true }); } catch {} |
| } | ||
|
|
||
| function cycleKey(files: readonly string[]): string { | ||
| return [...files].sort().join("->"); |
There was a problem hiding this comment.
WARNING: Cycle key collision with filenames containing ->
Using -> as a separator can produce colliding keys (e.g. ["a.ts", "b.ts", "c.ts"] and ["a.ts", "b.ts->c.ts"] both become "a.ts->b.ts->c.ts"). Other keys in this codebase use \0 for safety.
| return [...files].sort().join("->"); | |
| return [...files].sort().join("\0"); |
| cycles: sortDetailedCycles(findDetailedCycles(graph), "priority") | ||
| .map((cycle) => { | ||
| const files = cycle.files.map(normalizePath).sort(); | ||
| return { key: files.join("->"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; |
There was a problem hiding this comment.
WARNING: Cycle key collision with filenames containing ->
Joining with -> can produce colliding keys when filenames contain ->. Other keys in this module use \0 as a separator.
| return { key: files.join("->"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; | |
| return { key: files.join("\0"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; |
Code Review SummaryStatus: 12 Issues Found | Recommendation: Address before merge Resolved in this update: The previous option-injection vulnerability in Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)No new issues found in unchanged code. Files Reviewed (22 files)
Fix these issues in Kilo Cloud Reviewed by kimi-k2.6-20260420 · 228,783 tokens |
| if (options.baseArtifact) { | ||
| if (options.head && !isCurrentCheckoutRef(options.head)) { | ||
| throw new Error("Architecture drift with --base-artifact only supports the current checkout as --head."); | ||
| } |
| 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); | ||
| } | ||
| } |
| 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 })), |
| await execFileAsync("git", ["clone", "--quiet", "--no-checkout", root, tempRoot], { env: process.env }); | ||
| await execFileAsync("git", ["checkout", "--quiet", checkoutRef], { cwd: tempRoot, env: process.env }); |
| const head = context.getOpt("--head"); | ||
| const 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 } : {}), | ||
| }); |
No description provided.