From ae6c8088452565a9c435322f9d24a8bc3335b61c Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:12:12 -0400 Subject: [PATCH 01/15] Add architecture drift snapshots --- src/drift/compare.ts | 269 ++++++++++++++++++++++++++++++++++++++++++ src/drift/index.ts | 26 ++++ src/drift/report.ts | 71 +++++++++++ src/drift/snapshot.ts | 200 +++++++++++++++++++++++++++++++ src/drift/types.ts | 142 ++++++++++++++++++++++ src/index.ts | 26 ++++ tests/drift.test.ts | 122 +++++++++++++++++++ 7 files changed, 856 insertions(+) create mode 100644 src/drift/compare.ts create mode 100644 src/drift/index.ts create mode 100644 src/drift/report.ts create mode 100644 src/drift/snapshot.ts create mode 100644 src/drift/types.ts create mode 100644 tests/drift.test.ts diff --git a/src/drift/compare.ts b/src/drift/compare.ts new file mode 100644 index 00000000..fcb46143 --- /dev/null +++ b/src/drift/compare.ts @@ -0,0 +1,269 @@ +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 (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 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); + compareUnresolved(findings, base.unresolved.imports, head.unresolved.imports); + comparePublicApi(findings, base.publicApi, head.publicApi); + 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 failedKinds = Array.from(new Set(limitedFindings.filter((finding) => failOnSet.has(finding.kind)).map((finding) => finding.kind))).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/index.ts b/src/drift/index.ts new file mode 100644 index 00000000..86e51353 --- /dev/null +++ b/src/drift/index.ts @@ -0,0 +1,26 @@ +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 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..40e6948a --- /dev/null +++ b/src/drift/report.ts @@ -0,0 +1,71 @@ +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) { + 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..d2f3bd36 --- /dev/null +++ b/src/drift/snapshot.ts @@ -0,0 +1,200 @@ +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 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 { + 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(); +} + +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("->"); +} + +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..3bdbce1a --- /dev/null +++ b/src/drift/types.ts @@ -0,0 +1,142 @@ +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 ArchitectureSnapshot { + schemaVersion: 1; + root: string; + files: { total: number; byLanguage: Record }; + hotspots: ArchitectureHotspot[]; + cycles: ArchitectureCycle[]; + unresolved: ArchitectureUnresolvedImportSummary; + publicApi: ArchitecturePublicApiSymbol[]; + duplicates: ArchitectureDuplicateSummary; + graphEdges: ArchitectureGraphEdge[]; +} + +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..e86a9f6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -354,6 +354,32 @@ export { type DuplicateUnitRef, } from "./duplicates.js"; +/** Architecture drift snapshots, comparisons, and report rendering. */ +export { + buildArchitectureSnapshot, + 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/drift.test.ts b/tests/drift.test.ts new file mode 100644 index 00000000..3a789fea --- /dev/null +++ b/tests/drift.test.ts @@ -0,0 +1,122 @@ +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("reports new cycles without reporting pre-existing cycles", () => { + const base = makeSnapshot({ + cycles: [{ key: "src/old-a.ts->src/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->src/old-b.ts", files: ["src/old-a.ts", "src/old-b.ts"], priorityScore: 10, size: 2 }, + { key: "src/a.ts->src/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->src/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("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->src/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"); + }); +}); From 7934d6dc631f480ccc65080600231f78b00fd694 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:13:47 -0400 Subject: [PATCH 02/15] Compare architecture drift across git refs --- src/drift/git.ts | 65 ++++++++++++++++++++++++++++++++ src/drift/index.ts | 1 + src/index.ts | 1 + tests/drift-git-provider.test.ts | 44 +++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 src/drift/git.ts create mode 100644 tests/drift-git-provider.test.ts diff --git a/src/drift/git.ts b/src/drift/git.ts new file mode 100644 index 00000000..cd727bb7 --- /dev/null +++ b/src/drift/git.ts @@ -0,0 +1,65 @@ +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 { compareArchitectureSnapshots } from "./compare.js"; +import { buildArchitectureSnapshot } from "./snapshot.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 === "."; +} + +async function materializeGitRef(root: string, ref: string | undefined, prefix: string): Promise<{ root: string; cleanup?: string }> { + const checkoutRef = ref; + if (checkoutRef === undefined || 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 }); + 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 }); + throw error; + } +} + +export async function analyzeArchitectureDrift( + root: string, + options: ArchitectureDriftOptions, +): Promise { + if (options.provider && options.provider !== "git") { + throw new Error(`Unsupported architecture drift provider: ${options.provider}`); + } + if (!options.base) { + 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-"); + const head = await materializeGitRef(resolvedRoot, options.head, "cg-drift-head-"); + try { + 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 { + if (base.cleanup) await fsp.rm(base.cleanup, { recursive: true, force: true }); + if (head.cleanup) await fsp.rm(head.cleanup, { recursive: true, force: true }); + } +} diff --git a/src/drift/index.ts b/src/drift/index.ts index 86e51353..75b8e406 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -5,6 +5,7 @@ export { compareArchitectureSnapshots, } from "./compare.js"; export { renderArchitectureDriftReport, type ArchitectureDriftRenderOptions } from "./report.js"; +export { analyzeArchitectureDrift } from "./git.js"; export type { ArchitectureCycle, ArchitectureDriftCompareOptions, diff --git a/src/index.ts b/src/index.ts index e86a9f6e..e94b5b96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -357,6 +357,7 @@ export { /** Architecture drift snapshots, comparisons, and report rendering. */ export { buildArchitectureSnapshot, + analyzeArchitectureDrift, compareArchitectureSnapshots, renderArchitectureDriftReport, ARCHITECTURE_DRIFT_FINDING_KINDS, diff --git a/tests/drift-git-provider.test.ts b/tests/drift-git-provider.test.ts new file mode 100644 index 00000000..03031188 --- /dev/null +++ b/tests/drift-git-provider.test.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import fsp from "node:fs/promises"; +import { describe, expect, it } 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" })); + }); +}); From 72468914dd594ce7d83bf38bc123e43aa08f0949 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:15:10 -0400 Subject: [PATCH 03/15] Load drift baselines from artifacts --- src/drift/artifact.ts | 155 +++++++++++++++++++++++++++++++++++ src/drift/git.ts | 9 ++ src/drift/index.ts | 1 + src/index.ts | 1 + tests/drift-artifact.test.ts | 48 +++++++++++ 5 files changed, 214 insertions(+) create mode 100644 src/drift/artifact.ts create mode 100644 tests/drift-artifact.test.ts diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts new file mode 100644 index 00000000..ee67054d --- /dev/null +++ b/src/drift/artifact.ts @@ -0,0 +1,155 @@ +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): Promise { + return JSON.parse(await fsp.readFile(filePath, "utf8")); +} + +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"))); + if (!manifest) { + throw new Error("Codegraph artifact manifest is missing graph.json metadata."); + } + const graphJson = parseGraphJson(await readJson(assertArtifactChild(outDir, manifest.artifacts.graphJson))); + 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 })), + 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 }; + }), + unresolved: unresolvedImports(graph), + publicApi: graphJson.graph.symbols + ? graphJson.graph.symbols + .map((symbol) => ({ id: `${normalizePath(symbol.file)}#${symbol.name}:${symbol.kind}`, file: normalizePath(symbol.file), name: symbol.name, kind: symbol.kind })) + .sort((left, right) => left.id.localeCompare(right.id)) + : [], + duplicates: { groups: { total: 0 }, topGroupKeys: [] }, + graphEdges: graphEdges(graph.edges), + }; +} diff --git a/src/drift/git.ts b/src/drift/git.ts index cd727bb7..4b92727c 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { promisify } from "node:util"; 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); @@ -45,6 +46,14 @@ export async function analyzeArchitectureDrift( if (options.provider && options.provider !== "git") { throw new Error(`Unsupported architecture drift provider: ${options.provider}`); } + if (options.baseArtifact) { + 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."); } diff --git a/src/drift/index.ts b/src/drift/index.ts index 75b8e406..2cdcb084 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -6,6 +6,7 @@ export { } from "./compare.js"; export { renderArchitectureDriftReport, type ArchitectureDriftRenderOptions } from "./report.js"; export { analyzeArchitectureDrift } from "./git.js"; +export { loadArchitectureSnapshotFromArtifact } from "./artifact.js"; export type { ArchitectureCycle, ArchitectureDriftCompareOptions, diff --git a/src/index.ts b/src/index.ts index e94b5b96..f7f33c65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -358,6 +358,7 @@ export { export { buildArchitectureSnapshot, analyzeArchitectureDrift, + loadArchitectureSnapshotFromArtifact, compareArchitectureSnapshots, renderArchitectureDriftReport, ARCHITECTURE_DRIFT_FINDING_KINDS, diff --git a/tests/drift-artifact.test.ts b/tests/drift-artifact.test.ts new file mode 100644 index 00000000..31f23dfa --- /dev/null +++ b/tests/drift-artifact.test.ts @@ -0,0 +1,48 @@ +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("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 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"); + }); +}); From 88e8a9b44649123755eb67cc5b1d75cacd661ae5 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:17:25 -0400 Subject: [PATCH 04/15] Add architecture drift CLI --- src/cli.ts | 26 ++++++++++ src/cli/drift.ts | 85 +++++++++++++++++++++++++++++++ src/cli/help.ts | 15 ++++++ src/cli/options.ts | 3 ++ tests/cli-command-modules.test.ts | 53 +++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/cli/drift.ts diff --git a/src/cli.ts b/src/cli.ts index d5ddbbc3..6425b13c 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"; @@ -158,6 +159,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "hotspots" || cmd === "inspect" || cmd === "duplicates" || + cmd === "drift" || cmd === "impact") && !rootOpt && parsed.positionals.length === 1 && @@ -249,6 +251,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "hotspots" || cmd === "inspect" || cmd === "duplicates" || + cmd === "drift" || cmd === "orient"; let includeRoots: string[] = []; if (supportsIncludeRoots) { @@ -502,6 +505,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..29604799 --- /dev/null +++ b/src/cli/drift.ts @@ -0,0 +1,85 @@ +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("Usage: codegraph drift [roots...] --base [--head ] [--json | --pretty]"); + context.writeStderrLine("Provide either --base or --base-artifact."); + context.exit(2); + } + + 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 } : {}), + }); + + 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..0ce20aba 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,24 @@ 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 [--head ] | --base-artifact ) [--json | --pretty] [--fail-on ] + +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. + +Policy: + --fail-on exits 1 only when one of the selected finding kinds is present. +`; + 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/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 7739459a..5d0b0ca0 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" }, ]; @@ -747,6 +748,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"); From 55faf2e3b3b61c445467076ad7fcfc65c3fc5b28 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:18:54 -0400 Subject: [PATCH 05/15] Document architecture drift checks --- README.md | 22 ++++++++++++++++++---- codegraph-skill/codegraph/SKILL.md | 7 +++++++ docs/agent-workflows.md | 9 +++++++++ docs/cli.md | 17 ++++++++++++++++- docs/library-api.md | 18 ++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) 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. From 885cb2ebc8c2af9511766741879403c907540e91 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:24:37 -0400 Subject: [PATCH 06/15] Refine drift artifact baselines --- src/drift/artifact.ts | 10 +++--- src/drift/compare.ts | 12 +++++-- src/drift/git.ts | 8 +++-- src/drift/types.ts | 7 ++++ tests/cli-regressions.test.ts | 57 ++++++++++++++++++++++++++++++++ tests/drift-artifact.test.ts | 23 +++++++++++++ tests/drift-git-provider.test.ts | 19 +++++++++++ 7 files changed, 127 insertions(+), 9 deletions(-) diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts index ee67054d..33f72258 100644 --- a/src/drift/artifact.ts +++ b/src/drift/artifact.ts @@ -144,12 +144,12 @@ export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): return { key: files.join("->"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; }), unresolved: unresolvedImports(graph), - publicApi: graphJson.graph.symbols - ? graphJson.graph.symbols - .map((symbol) => ({ id: `${normalizePath(symbol.file)}#${symbol.name}:${symbol.kind}`, file: normalizePath(symbol.file), name: symbol.name, kind: symbol.kind })) - .sort((left, right) => left.id.localeCompare(right.id)) - : [], + publicApi: [], duplicates: { groups: { total: 0 }, topGroupKeys: [] }, graphEdges: graphEdges(graph.edges), + signalAvailability: { + publicApi: false, + duplicates: false, + }, }; } diff --git a/src/drift/compare.ts b/src/drift/compare.ts index fcb46143..0679de81 100644 --- a/src/drift/compare.ts +++ b/src/drift/compare.ts @@ -173,6 +173,10 @@ function comparePublicApi( } } +function signalEnabled(snapshot: ArchitectureSnapshot, signal: "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; @@ -241,8 +245,12 @@ export function compareArchitectureSnapshots( compareCycles(findings, base.cycles, head.cycles); compareHotspots(findings, base.hotspots, head.hotspots, thresholds.hotspotJump); compareUnresolved(findings, base.unresolved.imports, head.unresolved.imports); - comparePublicApi(findings, base.publicApi, head.publicApi); - compareDuplicates(findings, base, head); + 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); diff --git a/src/drift/git.ts b/src/drift/git.ts index 4b92727c..a6ddf32f 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -3,6 +3,7 @@ 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"; @@ -22,12 +23,15 @@ function snapshotOptions(options: ArchitectureDriftOptions): ArchitectureSnapsho } function isCurrentCheckoutRef(ref: string | undefined): boolean { - return ref === undefined || ref === "."; + return ref === undefined || ref === "." || (typeof ref === "string" && isGitWorktreeSentinel(ref)); } async function materializeGitRef(root: string, ref: string | undefined, prefix: string): Promise<{ root: string; cleanup?: string }> { const checkoutRef = ref; - if (checkoutRef === undefined || checkoutRef === ".") return { root }; + 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 }); diff --git a/src/drift/types.ts b/src/drift/types.ts index 3bdbce1a..3dad3e51 100644 --- a/src/drift/types.ts +++ b/src/drift/types.ts @@ -65,6 +65,12 @@ export interface ArchitectureUnresolvedImportSummary { imports: ArchitectureUnresolvedImport[]; } + +export interface ArchitectureSignalAvailability { + publicApi?: boolean; + duplicates?: boolean; +} + export interface ArchitectureSnapshot { schemaVersion: 1; root: string; @@ -75,6 +81,7 @@ export interface ArchitectureSnapshot { publicApi: ArchitecturePublicApiSymbol[]; duplicates: ArchitectureDuplicateSummary; graphEdges: ArchitectureGraphEdge[]; + signalAvailability?: ArchitectureSignalAvailability; } export type ArchitectureSnapshotSummary = Pick< diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index f907a9a1..205769d3 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,33 @@ 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("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 index 31f23dfa..9a937f25 100644 --- a/tests/drift-artifact.test.ts +++ b/tests/drift-artifact.test.ts @@ -39,6 +39,29 @@ describe("architecture drift artifact baselines", () => { expect(report.findings).toContainEqual(expect.objectContaining({ kind: "new-cycle" })); }); + 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"); diff --git a/tests/drift-git-provider.test.ts b/tests/drift-git-provider.test.ts index 03031188..55d56d1d 100644 --- a/tests/drift-git-provider.test.ts +++ b/tests/drift-git-provider.test.ts @@ -41,4 +41,23 @@ describe("architecture drift git provider", () => { 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" })); + }); }); From 0a6e2c28a5ef02efa17667ce38027ce85a0e5e17 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 30 May 2026 21:26:46 -0400 Subject: [PATCH 07/15] Polish drift snapshot comparisons --- src/drift/compare.ts | 5 ++++- src/drift/git.ts | 3 --- src/drift/snapshot.ts | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/drift/compare.ts b/src/drift/compare.ts index 0679de81..0e2e8805 100644 --- a/src/drift/compare.ts +++ b/src/drift/compare.ts @@ -257,7 +257,10 @@ export function compareArchitectureSnapshots( const limitedFindings = findings.slice(0, thresholds.maxFindings); const failOn = [...(options.failOn ?? [])].sort(); const failOnSet = new Set(failOn); - const failedKinds = Array.from(new Set(limitedFindings.filter((finding) => failOnSet.has(finding.kind)).map((finding) => finding.kind))).sort(); + const matchedFailKinds = limitedFindings + .filter((finding) => failOnSet.has(finding.kind)) + .map((finding) => finding.kind); + const failedKinds = Array.from(new Set(matchedFailKinds)).sort(); return { schemaVersion: 1, diff --git a/src/drift/git.ts b/src/drift/git.ts index a6ddf32f..9024a287 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -47,9 +47,6 @@ export async function analyzeArchitectureDrift( root: string, options: ArchitectureDriftOptions, ): Promise { - if (options.provider && options.provider !== "git") { - throw new Error(`Unsupported architecture drift provider: ${options.provider}`); - } if (options.baseArtifact) { const baseSnapshot = await loadArchitectureSnapshotFromArtifact(options.baseArtifact); const headSnapshot = await buildArchitectureSnapshot(path.resolve(root), snapshotOptions(options)); diff --git a/src/drift/snapshot.ts b/src/drift/snapshot.ts index d2f3bd36..db872231 100644 --- a/src/drift/snapshot.ts +++ b/src/drift/snapshot.ts @@ -125,7 +125,9 @@ function toSnapshotPublicApi(root: string, index: Parameters left.id.localeCompare(right.id)); } -function duplicateGroupKey(group: { primaryLeft: { file: string; startLine: number }; primaryRight: { file: string; startLine: number } }): string { +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}`; From 4c723760e74f38cc47d61c75bfd5844a8b56aecb Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 11:57:40 -0400 Subject: [PATCH 08/15] Fix drift review findings --- src/cli.ts | 5 ++--- src/drift/artifact.ts | 20 +++++++++++++---- src/drift/compare.ts | 9 ++++++-- src/drift/git.ts | 3 +++ src/drift/types.ts | 1 + tests/cli-regressions.test.ts | 21 ++++++++++++++++++ tests/drift-artifact.test.ts | 42 +++++++++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 6425b13c..4b8a3de9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -159,7 +159,6 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "hotspots" || cmd === "inspect" || cmd === "duplicates" || - cmd === "drift" || cmd === "impact") && !rootOpt && parsed.positionals.length === 1 && @@ -258,8 +257,8 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { 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). diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts index 33f72258..e21d0019 100644 --- a/src/drift/artifact.ts +++ b/src/drift/artifact.ts @@ -39,8 +39,18 @@ function parseManifest(value: unknown): ArtifactManifest | null { return { artifacts: { graphJson: artifacts.graphJson } }; } -async function readJson(filePath: string): Promise { - return JSON.parse(await fsp.readFile(filePath, "utf8")); +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 { @@ -128,11 +138,12 @@ function unresolvedImports(graph: Graph): { total: number; imports: Architecture export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): Promise { const outDir = path.resolve(outDirInput); - const manifest = parseManifest(await readJson(path.join(outDir, "manifest.json"))); + 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 graphJson = parseGraphJson(await readJson(assertArtifactChild(outDir, manifest.artifacts.graphJson))); + 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, @@ -148,6 +159,7 @@ export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): 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 index 0e2e8805..71e55c35 100644 --- a/src/drift/compare.ts +++ b/src/drift/compare.ts @@ -173,7 +173,10 @@ function comparePublicApi( } } -function signalEnabled(snapshot: ArchitectureSnapshot, signal: "publicApi" | "duplicates"): boolean { +function signalEnabled( + snapshot: ArchitectureSnapshot, + signal: "unresolved" | "publicApi" | "duplicates", +): boolean { return snapshot.signalAvailability?.[signal] !== false; } @@ -244,7 +247,9 @@ export function compareArchitectureSnapshots( const findings: ArchitectureDriftFinding[] = []; compareCycles(findings, base.cycles, head.cycles); compareHotspots(findings, base.hotspots, head.hotspots, thresholds.hotspotJump); - compareUnresolved(findings, base.unresolved.imports, head.unresolved.imports); + 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); } diff --git a/src/drift/git.ts b/src/drift/git.ts index 9024a287..1b3d52e4 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -48,6 +48,9 @@ export async function analyzeArchitectureDrift( options: ArchitectureDriftOptions, ): Promise { 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, { diff --git a/src/drift/types.ts b/src/drift/types.ts index 3dad3e51..d1040200 100644 --- a/src/drift/types.ts +++ b/src/drift/types.ts @@ -67,6 +67,7 @@ export interface ArchitectureUnresolvedImportSummary { export interface ArchitectureSignalAvailability { + unresolved?: boolean; publicApi?: boolean; duplicates?: boolean; } diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index 205769d3..189f9dab 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1521,6 +1521,27 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) } }); + 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 index 9a937f25..47be4351 100644 --- a/tests/drift-artifact.test.ts +++ b/tests/drift-artifact.test.ts @@ -39,6 +39,48 @@ describe("architecture drift artifact baselines", () => { 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("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( From 2d428e1f98a702ff8741a3935a2dd46253768b38 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 13:58:40 -0400 Subject: [PATCH 09/15] Fix drift PR review feedback --- src/cli/drift.ts | 4 ++++ src/cli/help.ts | 8 +++++--- src/drift/compare.ts | 6 ++---- tests/cli-command-modules.test.ts | 7 +++++++ tests/cli-regressions.test.ts | 20 ++++++++++++++++++++ tests/drift.test.ts | 29 +++++++++++++++++++++++++++++ 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/cli/drift.ts b/src/cli/drift.ts index 29604799..529bf7c9 100644 --- a/src/cli/drift.ts +++ b/src/cli/drift.ts @@ -49,6 +49,10 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< 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."); diff --git a/src/cli/help.ts b/src/cli/help.ts index 0ce20aba..70de9b61 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -251,14 +251,16 @@ Output: export const DRIFT_HELP_TEXT = `codegraph drift - Compare architecture drift between graph states -Usage: codegraph drift [roots...] [--root ] (--base [--head ] | --base-artifact ) [--json | --pretty] [--fail-on ] +Usage: codegraph drift [roots...] [--root ] (--base [--head ] | --base-artifact ) [--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. -Policy: - --fail-on exits 1 only when one of the selected finding kinds is present. +Options: + --fail-on Exit 1 only when one of the selected finding kinds is present. + --hotspot-jump-threshold Minimum absolute hotspot score delta to report. + --limit Maximum findings to emit in the report output. `; export function helpTextForCommand(command: string, positionals: readonly string[]): string | undefined { diff --git a/src/drift/compare.ts b/src/drift/compare.ts index 71e55c35..632529f7 100644 --- a/src/drift/compare.ts +++ b/src/drift/compare.ts @@ -99,7 +99,7 @@ function compareHotspots( const before = baseByFile.get(file)?.score ?? 0; const after = headByFile.get(file)?.score ?? 0; const delta = after - before; - if (Math.abs(delta) < threshold) continue; + if (!delta || Math.abs(delta) < threshold) continue; const kind: ArchitectureDriftFindingKind = delta > 0 ? "hotspot-jump" : "hotspot-drop"; findings.push({ kind, @@ -262,9 +262,7 @@ export function compareArchitectureSnapshots( 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 matchedFailKinds = findings.filter((finding) => failOnSet.has(finding.kind)).map((finding) => finding.kind); const failedKinds = Array.from(new Set(matchedFailKinds)).sort(); return { diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 5d0b0ca0..1e04a429 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -248,6 +248,13 @@ describe("CLI command modules", () => { } }); + test("documents drift-specific flags in drift help", async () => { + const result = await captureCli(["drift", "--help"]); + + expect(result.stdout).toContain("--limit"); + expect(result.stdout).toContain("--hotspot-jump-threshold"); + }); + test("rejects ambiguous MCP serve transport flags before starting a server", async () => { const result = await captureCli(["mcp", "serve", "--stdio", "--port", "3000"]); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index 189f9dab..444e0ff7 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1521,6 +1521,26 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) } }); + 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 treats a single positional path as an include root", async () => { const root = await fsp.mkdtemp(path.join(os.tmpdir(), "codegraph-drift-cli-root-")); try { diff --git a/tests/drift.test.ts b/tests/drift.test.ts index 3a789fea..69bd25dd 100644 --- a/tests/drift.test.ts +++ b/tests/drift.test.ts @@ -100,6 +100,35 @@ describe("architecture drift", () => { 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->src/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("renders a short grouped pretty report", () => { const report = compareArchitectureSnapshots( makeSnapshot({ publicApi: [{ id: "src/api.ts#oldName:function", file: "src/api.ts", name: "oldName", kind: "function" }] }), From ebee68fe6dccb2ce15a84322c6b81a6518ffa9dc Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 14:25:42 -0400 Subject: [PATCH 10/15] Fix drift snapshot determinism feedback --- src/drift/artifact.ts | 10 ++++---- src/drift/report.ts | 6 ++++- src/drift/snapshot.ts | 3 +-- tests/drift-artifact.test.ts | 45 ++++++++++++++++++++++++++++++++++++ tests/drift.test.ts | 27 ++++++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts index e21d0019..ff65c699 100644 --- a/src/drift/artifact.ts +++ b/src/drift/artifact.ts @@ -150,10 +150,12 @@ export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): 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 })), - 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 }; - }), + 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 }; + }) + .sort((left, right) => left.key.localeCompare(right.key)), unresolved: unresolvedImports(graph), publicApi: [], duplicates: { groups: { total: 0 }, topGroupKeys: [] }, diff --git a/src/drift/report.ts b/src/drift/report.ts index 40e6948a..7c1b97ca 100644 --- a/src/drift/report.ts +++ b/src/drift/report.ts @@ -50,7 +50,11 @@ export function renderArchitectureDriftReport( const findings = report.findings.slice(0, limit); const lines = ["Architecture drift", ""]; if (!findings.length) { - lines.push("No architecture drift findings."); + 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( diff --git a/src/drift/snapshot.ts b/src/drift/snapshot.ts index db872231..0cb38ffc 100644 --- a/src/drift/snapshot.ts +++ b/src/drift/snapshot.ts @@ -19,7 +19,6 @@ import type { ArchitectureUnresolvedImport, } from "./types.js"; -const DUPLICATE_PROJECT_PATTERNS = [...DEFAULT_PROJECT_PATTERNS, "**/*.{json,jsonc,toml,txt,yaml,yml}"]; const DEFAULT_DUPLICATE_LIMIT = 50; function normalizeRoot(root: string): string { @@ -39,7 +38,7 @@ function isUnderIncludeRoots(filePath: string, roots: readonly string[]): boolea 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, DUPLICATE_PROJECT_PATTERNS, options.discovery); + const files = await listProjectFiles(root, DEFAULT_PROJECT_PATTERNS, options.discovery); return files.filter((file) => isUnderIncludeRoots(file, roots)).sort(); } diff --git a/tests/drift-artifact.test.ts b/tests/drift-artifact.test.ts index 47be4351..d11663cd 100644 --- a/tests/drift-artifact.test.ts +++ b/tests/drift-artifact.test.ts @@ -26,6 +26,51 @@ describe("architecture drift artifact baselines", () => { 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->a/b.ts", "z/a.ts->z/b.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"); diff --git a/tests/drift.test.ts b/tests/drift.test.ts index 69bd25dd..e8b86d47 100644 --- a/tests/drift.test.ts +++ b/tests/drift.test.ts @@ -47,6 +47,17 @@ describe("architecture drift", () => { 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("reports new cycles without reporting pre-existing cycles", () => { const base = makeSnapshot({ cycles: [{ key: "src/old-a.ts->src/old-b.ts", files: ["src/old-a.ts", "src/old-b.ts"], priorityScore: 10, size: 2 }], @@ -129,6 +140,22 @@ describe("architecture drift", () => { 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->src/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" }] }), From 255f854b366f0902d9365714cc8bcd8d9245cfe2 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 15:12:26 -0400 Subject: [PATCH 11/15] Harden drift temp cleanup and cycle keys --- src/drift/artifact.ts | 2 +- src/drift/git.ts | 22 +++++++--- src/drift/snapshot.ts | 2 +- tests/drift-artifact.test.ts | 38 +++++++++++++++++- tests/drift-git-provider.test.ts | 69 +++++++++++++++++++++++++++++++- tests/drift.test.ts | 22 ++++++---- 6 files changed, 139 insertions(+), 16 deletions(-) diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts index ff65c699..f2610599 100644 --- a/src/drift/artifact.ts +++ b/src/drift/artifact.ts @@ -153,7 +153,7 @@ export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): 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 }; + return { key: files.join("\0"), files, priorityScore: cycle.priorityScore, size: cycle.fileCount }; }) .sort((left, right) => left.key.localeCompare(right.key)), unresolved: unresolvedImports(graph), diff --git a/src/drift/git.ts b/src/drift/git.ts index 1b3d52e4..876c37a4 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -26,6 +26,16 @@ 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 materializeGitRef(root: string, ref: string | undefined, prefix: string): Promise<{ root: string; cleanup?: string }> { const checkoutRef = ref; if (checkoutRef !== undefined && isGitIndexSentinel(checkoutRef)) { @@ -38,7 +48,7 @@ async function materializeGitRef(root: string, ref: string | undefined, prefix: 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 }); + await cleanupTempDir(tempRoot); throw error; } } @@ -62,9 +72,11 @@ export async function analyzeArchitectureDrift( 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-"); - const head = await materializeGitRef(resolvedRoot, options.head, "cg-drift-head-"); + 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, { @@ -72,7 +84,7 @@ export async function analyzeArchitectureDrift( ...(options.thresholds ? { thresholds: options.thresholds } : {}), }); } finally { - if (base.cleanup) await fsp.rm(base.cleanup, { recursive: true, force: true }); - if (head.cleanup) await fsp.rm(head.cleanup, { recursive: true, force: true }); + await cleanupTempDir(head?.cleanup); + await cleanupTempDir(base?.cleanup); } } diff --git a/src/drift/snapshot.ts b/src/drift/snapshot.ts index 0cb38ffc..7ae2f876 100644 --- a/src/drift/snapshot.ts +++ b/src/drift/snapshot.ts @@ -59,7 +59,7 @@ function languageCounts(files: Iterable): Record { } function cycleKey(files: readonly string[]): string { - return [...files].sort().join("->"); + return [...files].sort().join("\0"); } function toSnapshotCycles(root: string, cycles: ReturnType): ArchitectureCycle[] { diff --git a/tests/drift-artifact.test.ts b/tests/drift-artifact.test.ts index d11663cd..439bcbaf 100644 --- a/tests/drift-artifact.test.ts +++ b/tests/drift-artifact.test.ts @@ -68,7 +68,43 @@ describe("architecture drift artifact baselines", () => { const snapshot = await loadArchitectureSnapshotFromArtifact(root); - expect(snapshot.cycles.map((cycle) => cycle.key)).toEqual(["a/a.ts->a/b.ts", "z/a.ts->z/b.ts"]); + 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("compares artifact baselines to the current checkout", async () => { diff --git a/tests/drift-git-provider.test.ts b/tests/drift-git-provider.test.ts index 55d56d1d..0a65c23b 100644 --- a/tests/drift-git-provider.test.ts +++ b/tests/drift-git-provider.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import fsp from "node:fs/promises"; -import { describe, expect, it } from "vitest"; +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"; @@ -17,6 +17,7 @@ async function commitAll(root: string, message: string): Promise { 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-"); @@ -60,4 +61,70 @@ describe("architecture drift git provider", () => { 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("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 index e8b86d47..698feab8 100644 --- a/tests/drift.test.ts +++ b/tests/drift.test.ts @@ -58,14 +58,22 @@ describe("architecture drift", () => { 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->src/old-b.ts", files: ["src/old-a.ts", "src/old-b.ts"], priorityScore: 10, size: 2 }], + 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->src/old-b.ts", files: ["src/old-a.ts", "src/old-b.ts"], priorityScore: 10, size: 2 }, - { key: "src/a.ts->src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }, + { 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 }, ], }); @@ -73,7 +81,7 @@ describe("architecture drift", () => { 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->src/old-b.ts" }), + expect.objectContaining({ kind: "new-cycle", key: "src/old-a.ts\u0000src/old-b.ts" }), ); }); @@ -127,7 +135,7 @@ describe("architecture drift", () => { 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->src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + cycles: [{ key: "src/a.ts\u0000src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], }); const report = compareArchitectureSnapshots(base, head, { @@ -144,7 +152,7 @@ describe("architecture drift", () => { const report = compareArchitectureSnapshots( makeSnapshot(), makeSnapshot({ - cycles: [{ key: "src/a.ts->src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + 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 } }, ); @@ -160,7 +168,7 @@ describe("architecture drift", () => { 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->src/b.ts", files: ["src/a.ts", "src/b.ts"], priorityScore: 20, size: 2 }], + 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 } }, From 5ba8be46f98bbafec404a7c5516717e83210cbe4 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 20:57:17 -0400 Subject: [PATCH 12/15] Tighten drift artifact and output behavior --- src/cli/drift.ts | 2 +- src/drift/artifact.ts | 4 +++- src/drift/git.ts | 4 ++++ tests/cli-regressions.test.ts | 17 +++++++++++++ tests/drift-artifact.test.ts | 45 +++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/cli/drift.ts b/src/cli/drift.ts index 529bf7c9..fef602eb 100644 --- a/src/cli/drift.ts +++ b/src/cli/drift.ts @@ -75,7 +75,7 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< ...(context.nativeMode !== "auto" ? { native: context.nativeMode } : {}), }); - if (context.hasFlag("--json") && !context.hasFlag("--pretty")) { + if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { context.writeJSONLine(report); } else { for (const line of renderArchitectureDriftReport(report, { limit: maxFindings }).trimEnd().split("\n")) { diff --git a/src/drift/artifact.ts b/src/drift/artifact.ts index f2610599..03ed252f 100644 --- a/src/drift/artifact.ts +++ b/src/drift/artifact.ts @@ -149,7 +149,9 @@ export async function loadArchitectureSnapshotFromArtifact(outDirInput: string): 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 })), + 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(); diff --git a/src/drift/git.ts b/src/drift/git.ts index 876c37a4..7e9d1830 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -57,6 +57,10 @@ 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."); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index 444e0ff7..bfd9321a 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1521,6 +1521,23 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) } }); + 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 { diff --git a/tests/drift-artifact.test.ts b/tests/drift-artifact.test.ts index 439bcbaf..4ecd5fad 100644 --- a/tests/drift-artifact.test.ts +++ b/tests/drift-artifact.test.ts @@ -107,6 +107,40 @@ describe("architecture drift artifact baselines", () => { 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"); @@ -131,6 +165,17 @@ describe("architecture drift artifact baselines", () => { ).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'); From bf151d871d939b4dead9ce2386df3d91af179fde Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 21:00:20 -0400 Subject: [PATCH 13/15] Mark drift plan checklist complete --- .../2026-05-26-architecture-drift-check.md | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) 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 From e2dcf4e77fda0376cde69186e03e475eeb529590 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 21:20:44 -0400 Subject: [PATCH 14/15] Resolve drift refs before checkout --- src/drift/git.ts | 12 +++++++++++- tests/drift-git-provider.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/drift/git.ts b/src/drift/git.ts index 7e9d1830..125cb327 100644 --- a/src/drift/git.ts +++ b/src/drift/git.ts @@ -35,6 +35,15 @@ async function cleanupTempDir(dir: string | undefined): Promise { } } +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; @@ -45,7 +54,8 @@ async function materializeGitRef(root: string, ref: string | undefined, prefix: const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); try { await execFileAsync("git", ["clone", "--quiet", "--no-checkout", root, tempRoot], { env: process.env }); - await execFileAsync("git", ["checkout", "--quiet", checkoutRef], { cwd: 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); diff --git a/tests/drift-git-provider.test.ts b/tests/drift-git-provider.test.ts index 0a65c23b..48a74d34 100644 --- a/tests/drift-git-provider.test.ts +++ b/tests/drift-git-provider.test.ts @@ -95,6 +95,22 @@ describe("architecture drift git provider", () => { } }); + 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"]); From c575c78e00795f756c3670e7ee9b68d4bea2acc6 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 31 May 2026 22:29:17 -0400 Subject: [PATCH 15/15] Polish drift CLI help and errors --- src/cli/drift.ts | 34 ++++++++++++++++++------------- src/cli/help.ts | 3 ++- tests/cli-command-modules.test.ts | 4 +++- tests/cli-regressions.test.ts | 20 ++++++++++++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/cli/drift.ts b/src/cli/drift.ts index fef602eb..72fb7486 100644 --- a/src/cli/drift.ts +++ b/src/cli/drift.ts @@ -60,20 +60,26 @@ export async function handleDriftCommand(context: DriftCommandContext): Promise< } 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 } : {}), - }); + 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); diff --git a/src/cli/help.ts b/src/cli/help.ts index 70de9b61..87b0af94 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -251,13 +251,14 @@ Output: export const DRIFT_HELP_TEXT = `codegraph drift - Compare architecture drift between graph states -Usage: codegraph drift [roots...] [--root ] (--base [--head ] | --base-artifact ) [--json | --pretty] [--fail-on ] [--hotspot-jump-threshold ] [--limit ] +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. diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 1e04a429..6f74f7be 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -248,11 +248,13 @@ describe("CLI command modules", () => { } }); - test("documents drift-specific flags in drift help", async () => { + 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 () => { diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index bfd9321a..750f9311 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1558,6 +1558,26 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) } }); + 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 {