Merge pull request #824 from getagentseal/phase5/detectors

feat(core): duplicate-reads, junk-reads, context-bloat detectors over fingerprints (phase 5)
This commit is contained in:
Resham Joshi 2026-07-26 13:36:32 -07:00 committed by GitHub
commit ae624c45b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1095 additions and 41 deletions

View file

@ -63,7 +63,7 @@ export type CodeburnConfig = {
proxyPaths?: string[]
}
function getConfigDir(): string {
export function getConfigDir(): string {
return join(homedir(), '.config', 'codeburn')
}

View file

@ -4,7 +4,18 @@ import { existsSync, statSync } from 'fs'
import { basename, join } from 'path'
import { homedir } from 'os'
import { projectRef as fingerprintProjectRef, resourceFingerprint, sessionRef as fingerprintSessionRef } from '@codeburn/core/fingerprint'
import { OBSERVATION_SCHEMA_VERSION } from '@codeburn/core/schema'
import type { CallObservation, ObservationEnvelope, SessionObservation } from '@codeburn/core/observations'
import type { Finding } from '@codeburn/core/contracts'
import {
contextBloatDetector,
duplicateReadsDetector,
junkReadsDetector,
} from '@codeburn/core/detectors'
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
import { getHostPrivacyKey } from './privacy-key.js'
import { discoverAllSessions } from './providers/index.js'
import { parseJsonlLine, shouldSkipLine } from './parser.js'
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
@ -43,10 +54,10 @@ const BASH_TOKENS_PER_CHAR = 0.25
const CLAUDEMD_HEALTHY_LINES = 200
const CLAUDEMD_HIGH_THRESHOLD_LINES = 400
const MIN_JUNK_READS_TO_FLAG = 3
// junk-reads / duplicate-reads flag thresholds now live in @codeburn/core; the
// host keeps only the impact-tier cutoffs it needs to map a core Finding.
const JUNK_READS_HIGH_THRESHOLD = 20
const JUNK_READS_MEDIUM_THRESHOLD = 5
const MIN_DUPLICATE_READS_TO_FLAG = 5
const DUPLICATE_READS_HIGH_THRESHOLD = 30
const DUPLICATE_READS_MEDIUM_THRESHOLD = 10
const MIN_EDITS_FOR_RATIO = 10
@ -645,20 +656,103 @@ export function loadMcpConfigs(projectCwds: Iterable<string>, homeDir = homedir(
return servers
}
// ============================================================================
// Fingerprint-envelope bridge
// ============================================================================
//
// The junk-reads, duplicate-reads and read-to-edit-ratio detectors live in
// @codeburn/core and operate over an ObservationEnvelope of fingerprinted
// resource refs — never raw paths. The host builds that envelope from its own
// ToolCall[] (fingerprinting each read/edit's file path with the persistent host
// privacy key), runs the core detector, and maps the returned Finding onto the
// existing WasteFinding display shape. Display strings, fix payloads and trend
// stay host-derived from the CLI's own path data (D5-A); the core Finding
// supplies the decision and the authoritative counts.
const READ_RESOURCE_TOOLS = new Set(['Read', 'FileReadTool'])
const SESSION_KEY_SEP = ''
// Fields the detectors never read, filled with valid placeholders so the object
// satisfies the CallObservation/SessionObservation types. This envelope is
// consumed in-process by the pure detectors and is never schema-parsed or
// emitted, so these placeholders never reach a payload.
function bridgeCall(name: string, refs: { resourceReads?: CallObservation['resourceReads']; resourceEdits?: CallObservation['resourceEdits'] }): CallObservation {
return {
provider: 'claude',
model: 'host',
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheCreate: 0 },
webSearchRequests: 0,
speed: 'standard',
costBasis: 'estimated',
timestamp: '1970-01-01T00:00:00.000Z',
dedupKey: 'host',
toolNames: [name],
turnIndex: 0,
...refs,
}
}
function buildObservationEnvelope(calls: ToolCall[]): ObservationEnvelope {
const privacyKey = getHostPrivacyKey()
const order: string[] = []
const groups = new Map<string, ToolCall[]>()
for (const c of calls) {
const key = `${c.project}${SESSION_KEY_SEP}${c.sessionId}`
let g = groups.get(key)
if (!g) { g = []; groups.set(key, g); order.push(key) }
g.push(c)
}
const sessions: SessionObservation[] = order.map(key => {
const groupCalls = groups.get(key)!
const obs = groupCalls.map(c => {
const filePath = typeof c.input.file_path === 'string' ? c.input.file_path : undefined
if (!filePath) return bridgeCall(c.name, {})
const fp = resourceFingerprint(privacyKey, filePath)
const ref = { resourceId: fp.resourceId, resourceClass: fp.resourceClass }
if (READ_RESOURCE_TOOLS.has(c.name)) return bridgeCall(c.name, { resourceReads: [ref] })
if (EDIT_TOOL_NAMES.has(c.name)) return bridgeCall(c.name, { resourceEdits: [ref] })
return bridgeCall(c.name, {})
})
return {
sessionRef: fingerprintSessionRef(privacyKey, 'claude', key),
projectRef: fingerprintProjectRef(privacyKey, key),
providerId: 'claude',
startedAt: '1970-01-01T00:00:00.000Z',
calls: obs,
turnCount: 1,
}
})
return {
schemaVersion: OBSERVATION_SCHEMA_VERSION,
generator: { name: '@codeburn/core', version: 'host' },
sessions,
}
}
function evidenceCount(finding: Finding, kind: string): number {
return finding.evidence.find(e => e.kind === kind)?.count ?? 0
}
// ============================================================================
// Detectors
// ============================================================================
export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null {
const dirCounts = new Map<string, number>()
let totalJunkReads = 0
let recentJunkReads = 0
const findings = junkReadsDetector(buildObservationEnvelope(calls))
if (findings.length === 0) return null
const totalJunkReads = evidenceCount(findings[0], 'junk-reads')
const tokensSaved = evidenceCount(findings[0], 'tokens-saved')
// Display + trend stay host-derived from the raw path data (D5-A).
const dirCounts = new Map<string, number>()
let recentJunkReads = 0
for (const call of calls) {
if (!isReadTool(call.name)) continue
const filePath = call.input.file_path as string | undefined
if (!filePath || !JUNK_PATTERN.test(filePath)) continue
totalJunkReads++
if (call.recent) recentJunkReads++
for (const dir of JUNK_DIRS) {
if (filePath.includes(`/${dir}/`)) {
@ -668,15 +762,12 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste
}
}
if (totalJunkReads < MIN_JUNK_READS_TO_FLAG) return null
const hasRecentActivity = calls.some(c => c.recent)
const trend = sessionTrend(recentJunkReads, totalJunkReads, dateRange, hasRecentActivity)
if (trend === 'resolved') return null
const sorted = [...dirCounts.entries()].sort((a, b) => b[1] - a[1])
const dirList = sorted.slice(0, TOP_ITEMS_PREVIEW).map(([d, n]) => `${d}/ (${n}x)`).join(', ')
const tokensSaved = totalJunkReads * AVG_TOKENS_PER_READ
const detected = sorted.map(([d]) => d)
const commonDefaults = ['node_modules', '.git', 'dist', '__pycache__']
@ -700,8 +791,13 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste
}
export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null {
const sessionFiles = new Map<string, Map<string, { count: number; recent: number }>>()
const findings = duplicateReadsDetector(buildObservationEnvelope(calls))
if (findings.length === 0) return null
const totalDuplicates = evidenceCount(findings[0], 'duplicate-reads')
const tokensSaved = evidenceCount(findings[0], 'tokens-saved')
// Per-file breakdown + trend stay host-derived from the raw path data (D5-A).
const sessionFiles = new Map<string, Map<string, { count: number; recent: number }>>()
for (const call of calls) {
if (!isReadTool(call.name)) continue
const filePath = call.input.file_path as string | undefined
@ -715,23 +811,18 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange):
fm.set(filePath, entry)
}
let totalDuplicates = 0
let recentDuplicates = 0
const fileDupes = new Map<string, number>()
for (const fm of sessionFiles.values()) {
for (const [file, entry] of fm) {
if (entry.count <= 1) continue
const extra = entry.count - 1
totalDuplicates += extra
if (entry.recent > 1) recentDuplicates += entry.recent - 1
const name = basename(file)
fileDupes.set(name, (fileDupes.get(name) ?? 0) + extra)
}
}
if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return null
const hasRecentActivity = calls.some(c => c.recent)
const trend = sessionTrend(recentDuplicates, totalDuplicates, dateRange, hasRecentActivity)
if (trend === 'resolved') return null
@ -742,8 +833,6 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange):
.map(([name, n]) => `${name} (${n + 1}x)`)
.join(', ')
const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ
return {
id: 'redundant-rereads',
title: 'Claude is re-reading the same files',
@ -2191,27 +2280,25 @@ export const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool',
export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit'])
export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null {
let reads = 0
let edits = 0
const findings = contextBloatDetector(buildObservationEnvelope(calls))
if (findings.length === 0) return null
const reads = evidenceCount(findings[0], 'reads')
const edits = evidenceCount(findings[0], 'edits')
const tokensSaved = evidenceCount(findings[0], 'tokens-saved')
const ratio = reads / edits
// Recency (for trend) stays host-derived from the raw calls.
let recentEdits = 0
let recentReads = 0
for (const call of calls) {
if (READ_TOOL_NAMES.has(call.name)) {
reads++
if (call.recent) recentReads++
} else if (EDIT_TOOL_NAMES.has(call.name)) {
edits++
if (call.recent) recentEdits++
}
}
if (edits < MIN_EDITS_FOR_RATIO) return null
const ratio = reads / edits
if (ratio >= HEALTHY_READ_EDIT_RATIO) return null
const impact: Impact = ratio < LOW_RATIO_HIGH_THRESHOLD ? 'high' : ratio < LOW_RATIO_MEDIUM_THRESHOLD ? 'medium' : 'low'
const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0)
const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ
let trend: Trend | 'resolved' = 'active'
if (recentEdits >= MIN_EDITS_FOR_RATIO) {

View file

@ -0,0 +1,58 @@
// Host privacy key (decision D1). A random 32-byte key, generated once and
// persisted in the codeburn config dir alongside config.json, that scopes every
// resource fingerprint. Keeping it stable across runs makes resourceIds stable
// (so the same file always fingerprints the same way); regenerating it would
// scramble them. The key is NEVER printed and NEVER leaves the host — only the
// HMAC fingerprints it produces cross into any payload.
//
// Read synchronously (and cached) because the optimize detectors that need it
// are synchronous. This mirrors config.ts's storage location while staying on
// the sync fs API those detectors require.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { randomBytes } from 'crypto'
import { getConfigDir } from './config.js'
const KEY_FILE = 'privacy-key'
const KEY_HEX = /^[0-9a-f]{64}$/
let cached: string | undefined
function keyPath(): string {
return join(getConfigDir(), KEY_FILE)
}
/**
* Return the host privacy key, generating and persisting one on first use.
* Falls back to an in-memory ephemeral key if the config dir is unwritable, so
* a read-only environment still gets stable (per-process) fingerprints rather
* than throwing.
*/
export function getHostPrivacyKey(): string {
if (cached) return cached
const path = keyPath()
if (existsSync(path)) {
try {
const raw = readFileSync(path, 'utf-8').trim()
if (KEY_HEX.test(raw)) {
cached = raw
return cached
}
} catch {
// fall through to regenerate
}
}
const key = randomBytes(32).toString('hex')
try {
mkdirSync(getConfigDir(), { recursive: true })
writeFileSync(path, key + '\n', { mode: 0o600 })
} catch {
// Config dir unwritable — keep the key in memory for this process only.
}
cached = key
return cached
}

View file

@ -31,6 +31,10 @@
"types": "./dist/contracts.d.ts",
"import": "./dist/contracts.js"
},
"./detectors": {
"types": "./dist/detectors/index.d.ts",
"import": "./dist/detectors/index.js"
},
"./providers/claude": {
"types": "./dist/providers/claude/index.d.ts",
"import": "./dist/providers/claude/index.js"

View file

@ -0,0 +1,280 @@
{
"$ref": "#/definitions/ObservationEnvelope",
"definitions": {
"ObservationEnvelope": {
"type": "object",
"properties": {
"schemaVersion": {
"type": "string",
"const": "0.2.0"
},
"generator": {
"type": "object",
"properties": {
"name": {
"type": "string",
"const": "@codeburn/core"
},
"version": {
"type": "string",
"minLength": 1
}
},
"required": [
"name",
"version"
],
"additionalProperties": false
},
"sessions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sessionRef": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"projectRef": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"providerId": {
"type": "string",
"minLength": 1
},
"startedAt": {
"type": "string",
"format": "date-time"
},
"endedAt": {
"type": "string",
"format": "date-time"
},
"gitBranchRef": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"isSidechain": {
"type": "boolean"
},
"calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
},
"pricingModel": {
"type": "string",
"minLength": 1
},
"tokens": {
"type": "object",
"properties": {
"input": {
"type": "integer",
"minimum": 0
},
"output": {
"type": "integer",
"minimum": 0
},
"reasoning": {
"type": "integer",
"minimum": 0
},
"cacheRead": {
"type": "integer",
"minimum": 0
},
"cacheCreate": {
"type": "integer",
"minimum": 0
}
},
"required": [
"input",
"output",
"reasoning",
"cacheRead",
"cacheCreate"
],
"additionalProperties": false
},
"webSearchRequests": {
"type": "integer",
"minimum": 0
},
"speed": {
"type": "string",
"enum": [
"standard",
"fast"
]
},
"costBasis": {
"type": "string",
"enum": [
"measured",
"estimated"
]
},
"measuredCostUSD": {
"type": "number",
"minimum": 0
},
"fallbackCostUSD": {
"type": "number",
"minimum": 0
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"dedupKey": {
"type": "string",
"minLength": 1
},
"toolNames": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[A-Za-z0-9_.-]+$"
}
},
"turnIndex": {
"type": "integer",
"minimum": 0
},
"resourceReads": {
"type": "array",
"items": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"resourceClass": {
"type": "string",
"enum": [
"dependency",
"build",
"vcs",
"config",
"source",
"doc",
"other"
]
}
},
"required": [
"resourceId",
"resourceClass"
],
"additionalProperties": false
}
},
"resourceEdits": {
"type": "array",
"items": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"resourceClass": {
"type": "string",
"enum": [
"dependency",
"build",
"vcs",
"config",
"source",
"doc",
"other"
]
}
},
"required": [
"resourceId",
"resourceClass"
],
"additionalProperties": false
}
},
"locAdded": {
"type": "integer",
"minimum": 0
},
"locRemoved": {
"type": "integer",
"minimum": 0
},
"interrupted": {
"type": "boolean"
},
"userModified": {
"type": "boolean"
},
"toolErrors": {
"type": "integer",
"minimum": 0
},
"editFailed": {
"type": "integer",
"minimum": 0
}
},
"required": [
"provider",
"model",
"tokens",
"webSearchRequests",
"speed",
"costBasis",
"timestamp",
"dedupKey",
"toolNames",
"turnIndex"
],
"additionalProperties": false
}
},
"turnCount": {
"type": "integer",
"minimum": 0
}
},
"required": [
"sessionRef",
"projectRef",
"providerId",
"startedAt",
"calls",
"turnCount"
],
"additionalProperties": false
}
}
},
"required": [
"schemaVersion",
"generator",
"sessions"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View file

@ -0,0 +1,61 @@
// Context-bloat / read-to-edit-ratio detector: editing far more than reading
// leads to retries and wasted tokens. Reproduces the host CLI's
// detectLowReadEditRatio.
//
// Counting is by canonical tool name, NOT by resource ref: pattern search tools
// (Grep/Glob) count as reads even though they touch no single file, and an edit
// with no file target still counts as an edit. Each tool-use occurrence in a
// call's toolNames is one read or one edit (the two name sets are disjoint), so
// on the host's one-tool-per-call envelope this equals the legacy per-ToolCall
// tally, and generalises cleanly to a rich multi-tool call.
//
// Savings math mirrors the host: the tokens for the extra reads a healthy ratio
// would have required.
import type { Detector, Finding } from '../contracts.js'
import { AVG_TOKENS_PER_READ, EDIT_TOOL_NAMES, READ_TOOL_NAMES, clamp01, forEachCall } from './shared.js'
export const CONTEXT_BLOAT_DETECTOR_ID = 'context-bloat'
export const CONTEXT_BLOAT_ALGORITHM_VERSION = '1.0.0'
const MIN_EDITS_FOR_RATIO = 10
const HEALTHY_READ_EDIT_RATIO = 4
export const contextBloatDetector: Detector = (envelope): Finding[] => {
let reads = 0
let edits = 0
const sessionRefs = new Set<string>()
forEachCall(envelope, (call, session) => {
let touched = false
for (const name of call.toolNames) {
if (READ_TOOL_NAMES.has(name)) { reads++; touched = true }
else if (EDIT_TOOL_NAMES.has(name)) { edits++; touched = true }
}
if (touched) sessionRefs.add(session.sessionRef)
})
if (edits < MIN_EDITS_FOR_RATIO) return []
const ratio = reads / edits
if (ratio >= HEALTHY_READ_EDIT_RATIO) return []
const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0)
const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ
// Lower ratio (further below healthy) = stronger signal.
const score = clamp01(1 - ratio / HEALTHY_READ_EDIT_RATIO)
const finding: Finding = {
detectorId: CONTEXT_BLOAT_DETECTOR_ID,
algorithmVersion: CONTEXT_BLOAT_ALGORITHM_VERSION,
confidence: {
score,
basis: `read-to-edit ratio ${ratio.toFixed(2)}:1 over ${reads} reads / ${edits} edits; healthy is >=${HEALTHY_READ_EDIT_RATIO}`,
},
evidence: [
{ kind: 'reads', count: reads, sessionRefs: [...sessionRefs] },
{ kind: 'edits', count: edits },
{ kind: 'tokens-saved', count: tokensSaved },
],
}
return [finding]
}

View file

@ -0,0 +1,72 @@
// Duplicate-reads detector: the same file read more than once within a single
// session loads its content into context again for no new information.
//
// Reproduces the host CLI's detectDuplicateReads: group whole-file reads by
// (session, file), exclude junk resources, and sum the extra reads (count - 1)
// per file. Flags at >= MIN_DUPLICATE_READS_TO_FLAG total extras.
//
// Identity semantics: files are keyed by resourceId (fingerprint of the
// normalised path) rather than the raw path, and sessions by sessionRef. Two raw
// paths that normalise to one fingerprint (e.g. a trailing slash, or a
// case-only difference on a Windows-style path) count as the SAME file here — on
// a POSIX corpus with canonical read paths this is a 1:1 relabelling, so counts
// match the legacy detector. Junk exclusion uses resourceClass (see junk-reads
// for how that differs from the legacy JUNK_DIRS regex at the edges).
import type { Detector, Finding } from '../contracts.js'
import { AVG_TOKENS_PER_READ, JUNK_RESOURCE_CLASSES, clamp01, forEachCall } from './shared.js'
export const DUPLICATE_READS_DETECTOR_ID = 'duplicate-reads'
export const DUPLICATE_READS_ALGORITHM_VERSION = '1.0.0'
const MIN_DUPLICATE_READS_TO_FLAG = 5
const DUPLICATE_READS_HIGH_THRESHOLD = 30
export const duplicateReadsDetector: Detector = (envelope): Finding[] => {
// sessionRef -> resourceId -> read count.
const perSession = new Map<string, Map<string, number>>()
forEachCall(envelope, (call, session) => {
for (const ref of call.resourceReads ?? []) {
if (JUNK_RESOURCE_CLASSES.has(ref.resourceClass)) continue
let files = perSession.get(session.sessionRef)
if (!files) {
files = new Map()
perSession.set(session.sessionRef, files)
}
files.set(ref.resourceId, (files.get(ref.resourceId) ?? 0) + 1)
}
})
let totalDuplicates = 0
const dupRefs = new Set<string>()
const dupSessions = new Set<string>()
for (const [sessionRef, files] of perSession) {
for (const [resourceId, count] of files) {
if (count <= 1) continue
totalDuplicates += count - 1
dupRefs.add(resourceId)
dupSessions.add(sessionRef)
}
}
if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return []
const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ
const score = clamp01(totalDuplicates / DUPLICATE_READS_HIGH_THRESHOLD)
const finding: Finding = {
detectorId: DUPLICATE_READS_DETECTOR_ID,
algorithmVersion: DUPLICATE_READS_ALGORITHM_VERSION,
confidence: {
score,
basis: `${totalDuplicates} redundant re-reads of ${dupRefs.size} file(s) across ${dupSessions.size} session(s); flags at >=${MIN_DUPLICATE_READS_TO_FLAG}`,
},
evidence: [
{ kind: 'duplicate-reads', count: totalDuplicates, refs: [...dupRefs], sessionRefs: [...dupSessions] },
{ kind: 'tokens-saved', count: tokensSaved },
],
}
return [finding]
}

View file

@ -0,0 +1,21 @@
// Pure, fingerprint-based detectors. Each consumes an ObservationEnvelope and
// emits Finding[] with zero fs/env/clock access — the detector-purity property
// the import-smoke guardrail enforces over this subpath.
import type { Detector } from '../contracts.js'
export * from './shared.js'
export * from './duplicate-reads.js'
export * from './junk-reads.js'
export * from './context-bloat.js'
import { duplicateReadsDetector } from './duplicate-reads.js'
import { junkReadsDetector } from './junk-reads.js'
import { contextBloatDetector } from './context-bloat.js'
/** All fingerprint-based detectors, in a stable order. */
export const detectors: readonly Detector[] = [
junkReadsDetector,
duplicateReadsDetector,
contextBloatDetector,
]

View file

@ -0,0 +1,60 @@
// Junk-reads detector: flags reads into dependency/build/vcs resources (the
// path-free redesign of the host CLI's JUNK_DIRS check).
//
// Identity semantics vs the legacy host detector: the CLI matched a raw-path
// regex over JUNK_DIRS. Here "junk" is resourceClass ∈ {dependency, build, vcs}
// (see JUNK_RESOURCE_CLASSES). classifyResource's segment tables are kept a
// STRICT SUPERSET of that regex: every directory the regex named is now a junk
// class — including the extras that used to map to 'other' and are folded in as
// of this phase: '__pycache__', 'coverage', '.cache', '.nuxt', '.output' (build),
// bare 'venv' (dependency), '.svn' and '.hg' (vcs). The class set ALSO catches
// vendor / site-packages / out / target, which the old regex missed — the
// intended superset improvement, so the redesign never flags FEWER reads than
// the legacy detector, only the same or (deliberately) more. The one legacy
// token not added is '.tsbuildinfo', which names a file (tsconfig.tsbuildinfo),
// never a directory segment, so `/.tsbuildinfo/` can match no real read path.
// Grouping is by resourceId, so two raw paths that normalise to one fingerprint
// count once.
import type { Detector, Finding } from '../contracts.js'
import { AVG_TOKENS_PER_READ, JUNK_RESOURCE_CLASSES, clamp01, forEachCall } from './shared.js'
export const JUNK_READS_DETECTOR_ID = 'junk-reads'
export const JUNK_READS_ALGORITHM_VERSION = '1.0.0'
const MIN_JUNK_READS_TO_FLAG = 3
const JUNK_READS_HIGH_THRESHOLD = 20
export const junkReadsDetector: Detector = (envelope): Finding[] => {
let total = 0
const junkRefs = new Set<string>()
const sessionRefs = new Set<string>()
forEachCall(envelope, (call, session) => {
for (const ref of call.resourceReads ?? []) {
if (!JUNK_RESOURCE_CLASSES.has(ref.resourceClass)) continue
total++
junkRefs.add(ref.resourceId)
sessionRefs.add(session.sessionRef)
}
})
if (total < MIN_JUNK_READS_TO_FLAG) return []
const tokensSaved = total * AVG_TOKENS_PER_READ
const score = clamp01(total / JUNK_READS_HIGH_THRESHOLD)
const finding: Finding = {
detectorId: JUNK_READS_DETECTOR_ID,
algorithmVersion: JUNK_READS_ALGORITHM_VERSION,
confidence: {
score,
basis: `${total} reads into dependency/build/vcs resources across ${sessionRefs.size} session(s); flags at >=${MIN_JUNK_READS_TO_FLAG}`,
},
evidence: [
{ kind: 'junk-reads', count: total, refs: [...junkRefs], sessionRefs: [...sessionRefs] },
{ kind: 'tokens-saved', count: tokensSaved },
],
}
return [finding]
}

View file

@ -0,0 +1,50 @@
// Shared, pure helpers for the fingerprint-based detectors. A detector sees ONLY
// an ObservationEnvelope — no fs, no env, no clock — and returns Finding[]. Any
// value it emits is a number, an enum, or a 16-hex fingerprint (never a path).
import type { ResourceClassName } from '../schema.js'
import type { CallObservation, ObservationEnvelope, SessionObservation } from '../observations.js'
/** One read/edit charged at this many tokens — mirrors the host's AVG_TOKENS_PER_READ. */
export const AVG_TOKENS_PER_READ = 600
/**
* Resource classes treated as "junk" (generated or third-party): a read into one
* is not a read of the user's own code. This is the redesigned, path-free basis
* for what the host CLI historically matched with a JUNK_DIRS regex. See the
* junk-reads detector for the identity-semantics note on where the two differ.
*/
export const JUNK_RESOURCE_CLASSES: ReadonlySet<ResourceClassName> = new Set<ResourceClassName>([
'dependency',
'build',
'vcs',
])
/**
* Tool names counted as reads / edits for the read-to-edit ratio. Kept in
* lockstep with the host CLI's READ_TOOL_NAMES / EDIT_TOOL_NAMES. Pattern search
* tools (Grep/Glob) count as reads here even though they target no single file,
* so they contribute to the ratio without ever producing a resource ref.
*/
export const READ_TOOL_NAMES: ReadonlySet<string> = new Set([
'Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool',
])
export const EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
'Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit',
])
export function clamp01(n: number): number {
if (n < 0) return 0
if (n > 1) return 1
return n
}
/** Every (session, call) pair in the envelope, in order. */
export function forEachCall(
envelope: ObservationEnvelope,
fn: (call: CallObservation, session: SessionObservation) => void,
): void {
for (const session of envelope.sessions) {
for (const call of session.calls) fn(call, session)
}
}

View file

@ -40,8 +40,18 @@ export interface ResourceFingerprint {
resourceId: string
}
const DEPENDENCY_SEGMENTS = new Set(['node_modules', 'vendor', '.venv', 'site-packages'])
const BUILD_SEGMENTS = new Set(['dist', 'build', 'out', 'target', '.next'])
// The dependency / build / vcs segment tables are the source of truth for what
// the junk-reads detector treats as junk. They are kept a strict SUPERSET of the
// CLI's legacy JUNK_DIRS regex: every directory that regex named classifies here
// as junk too (the extras — 'venv', '__pycache__', 'coverage', '.cache',
// '.nuxt', '.output', '.svn', '.hg' — are added below), plus vendor /
// site-packages / out / target, which the old regex missed.
const DEPENDENCY_SEGMENTS = new Set(['node_modules', 'vendor', '.venv', 'venv', 'site-packages'])
const BUILD_SEGMENTS = new Set([
'dist', 'build', 'out', 'target', '.next', '.nuxt', '.output',
'__pycache__', 'coverage', '.cache',
])
const VCS_SEGMENTS = new Set(['.git', '.svn', '.hg'])
const CONFIG_EXTENSIONS = new Set(['json', 'yaml', 'yml', 'toml'])
const DOC_EXTENSIONS = new Set(['md', 'txt', 'rst'])
const SOURCE_EXTENSIONS = new Set([
@ -92,7 +102,7 @@ export function classifyResource(absolutePath: string): ResourceClass {
if (BUILD_SEGMENTS.has(seg)) return 'build'
}
for (const seg of segments) {
if (seg === '.git') return 'vcs'
if (VCS_SEGMENTS.has(seg)) return 'vcs'
}
const basename = segments[segments.length - 1] ?? ''

View file

@ -8,6 +8,7 @@ import {
NonNegInt,
NonNegUSD,
OBSERVATION_SCHEMA_VERSION,
ResourceRef,
Speed,
TokenBuckets,
} from './schema.js'
@ -44,6 +45,14 @@ export const CallObservation = z
toolNames: z.array(CanonicalToolName),
turnIndex: NonNegInt,
// Resource refs (schema 0.2.0): the fingerprinted files this call read /
// edited. Populated from the host's rich decode; each entry is an opaque
// fingerprint + coarse class, never a raw path. `resourceReads` carries only
// whole-file reads (Read/FileReadTool); pattern tools (Grep/Glob) target no
// single file and contribute nothing here.
resourceReads: z.array(ResourceRef).optional(),
resourceEdits: z.array(ResourceRef).optional(),
// Rich-capture numerics: first-class optional fields.
locAdded: NonNegInt.optional(),
locRemoved: NonNegInt.optional(),

View file

@ -8,6 +8,7 @@
import { branchRef, projectRef, sessionRef } from '../../fingerprint.js'
import type { RecordDiagnostic } from '../../diagnostics.js'
import type { CallObservation, SessionObservation } from '../../observations.js'
import { extractResourceRefs } from '../resource-refs.js'
import type { DecodedCall, DecodedTurn } from './types.js'
/** One session's rich decode, as the host holds it before minimization. */
@ -34,7 +35,7 @@ export interface ToObservationsContext {
// enforced at the source, so the output also survives strict schema parse.
const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/
function toCallObservation(call: DecodedCall, turnIndex: number): CallObservation {
function toCallObservation(call: DecodedCall, turnIndex: number, privacyKey: string): CallObservation {
return {
provider: call.provider,
model: call.model,
@ -59,6 +60,7 @@ function toCallObservation(call: DecodedCall, turnIndex: number): CallObservatio
...(call.interrupted !== undefined ? { interrupted: call.interrupted } : {}),
...(call.userModified !== undefined ? { userModified: call.userModified } : {}),
...(call.toolErrors !== undefined ? { toolErrors: call.toolErrors } : {}),
...extractResourceRefs(privacyKey, call.toolSequence),
}
}
@ -66,7 +68,7 @@ function toSessionObservation(decode: RichSessionDecode, ctx: ToObservationsCont
const provider = ctx.provider ?? 'claude'
const calls: CallObservation[] = []
decode.turns.forEach((turn, turnIndex) => {
for (const call of turn.assistantCalls) calls.push(toCallObservation(call, turnIndex))
for (const call of turn.assistantCalls) calls.push(toCallObservation(call, turnIndex, ctx.privacyKey))
})
const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort()

View file

@ -8,6 +8,7 @@
import { projectRef, sessionRef } from '../../fingerprint.js'
import type { RecordDiagnostic } from '../../diagnostics.js'
import type { CallObservation, SessionObservation } from '../../observations.js'
import { extractResourceRefs } from '../resource-refs.js'
import type { CodexDecodedCall } from './types.js'
/** One Codex session's rich decode, as the host holds it before minimization. */
@ -31,7 +32,7 @@ export interface CodexToObservationsContext {
// is dropped rather than emitted.
const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/
function toCallObservation(call: CodexDecodedCall, turnIndex: number): CallObservation {
function toCallObservation(call: CodexDecodedCall, turnIndex: number, privacyKey: string): CallObservation {
return {
provider: call.provider,
model: call.model,
@ -54,6 +55,7 @@ function toCallObservation(call: CodexDecodedCall, turnIndex: number): CallObser
...(call.locAdded !== undefined ? { locAdded: call.locAdded } : {}),
...(call.locRemoved !== undefined ? { locRemoved: call.locRemoved } : {}),
...(call.editFailed !== undefined ? { editFailed: call.editFailed } : {}),
...extractResourceRefs(privacyKey, call.toolSequence),
}
}
@ -71,7 +73,7 @@ function toSessionObservation(decode: RichCodexSessionDecode, ctx: CodexToObserv
turnCount++
lastTurnId = call.turnId
}
calls.push(toCallObservation(call, turnIndex))
calls.push(toCallObservation(call, turnIndex, ctx.privacyKey))
}
const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort()

View file

@ -0,0 +1,46 @@
// Shared minimizing step: turn a rich decode's toolSequence (which carries raw
// file paths host-side) into fingerprinted ResourceRefs for the observation
// envelope. The RAW path is HMAC'd via resourceFingerprint and never emitted —
// only its 16-hex id and coarse class cross the boundary.
import { resourceFingerprint } from '../fingerprint.js'
import type { ResourceRef } from '../schema.js'
import { EDIT_TOOLS } from './claude/tool-vocab.js'
/** Whole-file read tools. Pattern tools (Grep/Glob) target no single file. */
const READ_RESOURCE_TOOLS = new Set(['Read', 'FileReadTool'])
/** One tool invocation as the rich decoders record it (tool name + optional file). */
export interface ToolSequenceEntry {
tool: string
file?: string
command?: string
}
/**
* Fingerprint the file paths in a call's toolSequence into resourceReads /
* resourceEdits. Read-family reads become `resourceReads`; edit-family writes
* become `resourceEdits`. Absent arrays are omitted so a call with no file
* touches stays byte-identical to schema 0.1.0.
*/
export function extractResourceRefs(
privacyKey: string,
toolSequence: ToolSequenceEntry[][] | undefined,
): { resourceReads?: ResourceRef[]; resourceEdits?: ResourceRef[] } {
if (!toolSequence) return {}
const reads: ResourceRef[] = []
const edits: ResourceRef[] = []
for (const group of toolSequence) {
for (const tc of group) {
if (!tc.file) continue
const fp = resourceFingerprint(privacyKey, tc.file)
const ref: ResourceRef = { resourceId: fp.resourceId, resourceClass: fp.resourceClass }
if (READ_RESOURCE_TOOLS.has(tc.tool)) reads.push(ref)
else if (EDIT_TOOLS.has(tc.tool)) edits.push(ref)
}
}
const out: { resourceReads?: ResourceRef[]; resourceEdits?: ResourceRef[] } = {}
if (reads.length > 0) out.resourceReads = reads
if (edits.length > 0) out.resourceEdits = edits
return out
}

View file

@ -3,8 +3,12 @@ import { z } from 'zod'
/**
* ObservationEnvelope schema version. 0.x per decision D8: the observation
* contract is pre-stability, so consumers must treat minor bumps as breaking.
*
* 0.2.0 adds the optional per-call `resourceReads` / `resourceEdits` arrays
* (ResourceRef). Strictness rules are unchanged: every added field is either a
* fingerprint or a coarse enum, so the anti-smuggling property still holds.
*/
export const OBSERVATION_SCHEMA_VERSION = '0.1.0'
export const OBSERVATION_SCHEMA_VERSION = '0.2.0'
/**
* A privacy-preserving fingerprint: the first 16 hex chars of an HMAC-SHA256.
@ -54,6 +58,36 @@ export type TokenBuckets = z.infer<typeof TokenBuckets>
export const Speed = z.enum(['standard', 'fast'])
export type Speed = z.infer<typeof Speed>
/**
* Coarse, non-identifying bucket for a filesystem resource. Mirrors the
* `ResourceClass` union produced by `classifyResource` in fingerprint.ts. It is
* a small closed enum so it can never carry a raw path or free text.
*/
export const ResourceClassName = z.enum([
'dependency',
'build',
'vcs',
'config',
'source',
'doc',
'other',
])
export type ResourceClassName = z.infer<typeof ResourceClassName>
/**
* A reference to a filesystem resource a call touched: the opaque 16-hex
* fingerprint of its normalised path plus its coarse class. `.strict()` blocks
* any extra field, so the RAW path can never ride along the structural
* anti-smuggling property extended to resource refs.
*/
export const ResourceRef = z
.object({
resourceId: FingerprintHex,
resourceClass: ResourceClassName,
})
.strict()
export type ResourceRef = z.infer<typeof ResourceRef>
/**
* How a call's cost was determined.
* - 'measured' : a provider-reported dollar figure is authoritative.

View file

@ -200,6 +200,18 @@ describe('content-smuggling guardrail: real claude decode -> toObservations is s
expect(allToolNames).toContain('Read')
expect(allToolNames).not.toContain(SECRETS.commandLine)
})
it('fingerprints the tool-sequence Read path into a 16-hex resourceRead, never the raw path', () => {
const env = buildEnvelope()
const reads = env.sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? []))
expect(reads.length).toBeGreaterThan(0)
for (const ref of reads) {
expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/)
expect(typeof ref.resourceClass).toBe('string')
}
// The planted absolute path must appear nowhere inside the refs.
expect(allStrings(reads)).not.toContain(SECRETS.absPath)
})
})
describe('content-smuggling guardrail: real codex decode -> toObservations is secret-free', () => {
@ -260,6 +272,17 @@ describe('content-smuggling guardrail: real codex decode -> toObservations is se
expect(allToolNames).toContain('Read')
expect(allToolNames).not.toContain(SECRETS.commandLine)
})
it('fingerprints the read_file path into a 16-hex resourceRead, never the raw path', () => {
const env = decodeAndMinimize()
const reads = env.sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? []))
expect(reads.length).toBeGreaterThan(0)
for (const ref of reads) {
expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/)
expect(typeof ref.resourceClass).toBe('string')
}
expect(allStrings(reads)).not.toContain(SECRETS.absPath)
})
})
describe('content-smuggling guardrail: diagnostic detail rejects paths', () => {

View file

@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import { Finding } from '../src/contracts.js'
import type { CallObservation, ObservationEnvelope, SessionObservation } from '../src/observations.js'
import type { ResourceClassName, ResourceRef } from '../src/schema.js'
import {
contextBloatDetector,
detectors,
duplicateReadsDetector,
junkReadsDetector,
} from '../src/detectors/index.js'
// ── Envelope builders ──────────────────────────────────────────────────────
let idSeq = 0
function hex16(): string {
idSeq++
return idSeq.toString(16).padStart(16, '0')
}
function ref(resourceClass: ResourceClassName, resourceId = hex16()): ResourceRef {
return { resourceId, resourceClass }
}
function callWith(fields: Partial<CallObservation>): CallObservation {
return {
provider: 'claude',
model: 'claude-opus-4-8',
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheCreate: 0 },
webSearchRequests: 0,
speed: 'standard',
costBasis: 'estimated',
timestamp: '2026-07-17T10:00:00.000Z',
dedupKey: `d${idSeq++}`,
toolNames: [],
turnIndex: 0,
...fields,
}
}
// Map a short test label to a stable 16-hex sessionRef (evidence.sessionRefs
// must be fingerprints, so a bare 's1' would fail Finding validation).
const srefs = new Map<string, string>()
function srefFor(label: string): string {
let v = srefs.get(label)
if (!v) { v = hex16(); srefs.set(label, v) }
return v
}
function session(label: string, calls: CallObservation[]): SessionObservation {
return {
sessionRef: srefFor(label),
projectRef: 'aaaaaaaaaaaaaaaa',
providerId: 'claude',
startedAt: '2026-07-17T10:00:00.000Z',
calls,
turnCount: 1,
}
}
function envelope(sessions: SessionObservation[]): ObservationEnvelope {
return {
schemaVersion: '0.2.0',
generator: { name: '@codeburn/core', version: '0.0.0-test' },
sessions,
}
}
/** Every finding must satisfy the wire contract and the gate-4 invariants. */
function assertWellFormed(f: unknown) {
expect(Finding.safeParse(f).success).toBe(true)
const finding = f as import('../src/contracts.js').Finding
expect(finding.evidence.length).toBeGreaterThan(0)
expect(finding.confidence.basis.length).toBeGreaterThan(0)
expect(finding.confidence.score).toBeGreaterThanOrEqual(0)
expect(finding.confidence.score).toBeLessThanOrEqual(1)
expect(finding.algorithmVersion).toBe('1.0.0')
}
// ── junk-reads ─────────────────────────────────────────────────────────────
describe('junkReadsDetector', () => {
const junkCall = (n: number, cls: ResourceClassName = 'dependency') =>
callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: n }, () => ref(cls)) })
it('returns nothing below the 3-read threshold', () => {
expect(junkReadsDetector(envelope([session('s1', [junkCall(2)])]))).toEqual([])
})
it('flags at exactly the threshold (boundary)', () => {
const findings = junkReadsDetector(envelope([session('s1', [junkCall(3)])]))
expect(findings).toHaveLength(1)
assertWellFormed(findings[0])
const ev = findings[0].evidence.find(e => e.kind === 'junk-reads')!
expect(ev.count).toBe(3)
expect(findings[0].evidence.find(e => e.kind === 'tokens-saved')!.count).toBe(1800)
})
it('counts dependency, build and vcs classes as junk', () => {
const call = callWith({
toolNames: ['Read'],
resourceReads: [ref('dependency'), ref('build'), ref('vcs')],
})
expect(junkReadsDetector(envelope([session('s1', [call])]))).toHaveLength(1)
})
it('ignores non-junk resource classes (source/config/doc/other)', () => {
const call = callWith({
toolNames: ['Read'],
resourceReads: [ref('source'), ref('config'), ref('doc'), ref('other')],
})
expect(junkReadsDetector(envelope([session('s1', [call])]))).toEqual([])
})
it('scales confidence with read count', () => {
const low = junkReadsDetector(envelope([session('s1', [junkCall(3)])]))[0]
const high = junkReadsDetector(envelope([session('s1', [junkCall(20)])]))[0]
expect(high.confidence.score).toBeGreaterThan(low.confidence.score)
})
})
// ── duplicate-reads ──────────────────────────────────────────────────────────
describe('duplicateReadsDetector', () => {
it('sums extra reads of the same file within a session', () => {
const id = hex16()
const call = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 6 }, () => ref('source', id)) })
const findings = duplicateReadsDetector(envelope([session('s1', [call])]))
expect(findings).toHaveLength(1)
assertWellFormed(findings[0])
expect(findings[0].evidence.find(e => e.kind === 'duplicate-reads')!.count).toBe(5)
})
it('does not count the same file across different sessions', () => {
const id = hex16()
const mk = (s: string) => session(s, [callWith({ toolNames: ['Read'], resourceReads: [ref('source', id)] })])
expect(duplicateReadsDetector(envelope([mk('s1'), mk('s2'), mk('s3')]))).toEqual([])
})
it('excludes junk-class re-reads', () => {
const id = hex16()
const call = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 10 }, () => ref('dependency', id)) })
expect(duplicateReadsDetector(envelope([session('s1', [call])]))).toEqual([])
})
it('is null just below the 5-extra threshold and flags at it (boundary)', () => {
const idA = hex16()
const four = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 5 }, () => ref('source', idA)) })
expect(duplicateReadsDetector(envelope([session('s1', [four])]))).toEqual([]) // 4 extras
const idB = hex16()
const five = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 6 }, () => ref('source', idB)) })
expect(duplicateReadsDetector(envelope([session('s1', [five])]))).toHaveLength(1) // 5 extras
})
})
// ── context-bloat (read-to-edit ratio) ────────────────────────────────────────
describe('contextBloatDetector', () => {
const reads = (n: number, name = 'Read') => Array.from({ length: n }, () => callWith({ toolNames: [name] }))
const edits = (n: number, name = 'Edit') => Array.from({ length: n }, () => callWith({ toolNames: [name] }))
it('returns nothing below the minimum edit count', () => {
expect(contextBloatDetector(envelope([session('s1', [...reads(1), ...edits(2)])]))).toEqual([])
})
it('returns nothing when the ratio is healthy (boundary at 4:1)', () => {
expect(contextBloatDetector(envelope([session('s1', [...reads(40), ...edits(10)])]))).toEqual([])
})
it('flags when edits outpace reads', () => {
const findings = contextBloatDetector(envelope([session('s1', [...reads(5), ...edits(10)])]))
expect(findings).toHaveLength(1)
assertWellFormed(findings[0])
expect(findings[0].evidence.find(e => e.kind === 'reads')!.count).toBe(5)
expect(findings[0].evidence.find(e => e.kind === 'edits')!.count).toBe(10)
// extraReadsNeeded = round(10*4) - 5 = 35 -> 35 * 600
expect(findings[0].evidence.find(e => e.kind === 'tokens-saved')!.count).toBe(21000)
})
it('counts Grep and Glob as reads', () => {
// 40 Grep reads / 10 edits = healthy -> no finding
expect(contextBloatDetector(envelope([session('s1', [...reads(40, 'Grep'), ...edits(10)])]))).toEqual([])
})
it('counts Write and NotebookEdit as edits', () => {
const findings = contextBloatDetector(envelope([session('s1', [...reads(15), ...edits(6, 'Write'), ...edits(4, 'NotebookEdit')])]))
expect(findings).toHaveLength(1)
})
})
describe('detectors registry', () => {
it('exports all three detectors', () => {
expect(detectors).toHaveLength(3)
})
it('every emitted finding across detectors is well-formed', () => {
const env = envelope([
session('s1', [
callWith({ toolNames: ['Read'], resourceReads: [ref('dependency'), ref('build'), ref('vcs')] }),
callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 6 }, () => ref('source', 'cccccccccccccccc')) }),
...Array.from({ length: 5 }, () => callWith({ toolNames: ['Read'] })),
...Array.from({ length: 10 }, () => callWith({ toolNames: ['Edit'] })),
]),
])
const all = detectors.flatMap(d => d(env))
expect(all.length).toBe(3)
for (const f of all) assertWellFormed(f)
})
})

View file

@ -96,11 +96,19 @@ describe('resource classification', () => {
const cases: Array<[string, string]> = [
['/repo/node_modules/lodash/index.js', 'dependency'],
['/repo/.venv/lib/site.py', 'dependency'],
['/repo/venv/lib/site.py', 'dependency'],
['/repo/backend/site-packages/x.py', 'dependency'],
['/repo/dist/index.js', 'build'],
['/repo/.next/server/page.js', 'build'],
['/repo/.nuxt/dist/app.js', 'build'],
['/repo/.output/server/index.mjs', 'build'],
['/repo/target/debug/app', 'build'],
['/repo/pkg/__pycache__/mod.cpython-312.pyc', 'build'],
['/repo/coverage/lcov.info', 'build'],
['/repo/.cache/webpack/index.pack', 'build'],
['/repo/.git/HEAD', 'vcs'],
['/repo/.svn/entries', 'vcs'],
['/repo/.hg/store/data.i', 'vcs'],
['/repo/.eslintrc', 'config'],
['/repo/tsconfig.json', 'config'],
['/repo/config/app.yaml', 'config'],

View file

@ -1,5 +1,5 @@
{
"schemaVersion": "0.1.0",
"schemaVersion": "0.2.0",
"generator": {
"name": "@codeburn/core",
"version": "0.9.19"
@ -37,7 +37,13 @@
"locAdded": 12,
"locRemoved": 3,
"interrupted": false,
"toolErrors": 0
"toolErrors": 0,
"resourceReads": [
{ "resourceId": "1122334455667788", "resourceClass": "source" }
],
"resourceEdits": [
{ "resourceId": "8877665544332211", "resourceClass": "source" }
]
},
{
"provider": "claude",

View file

@ -36,11 +36,11 @@ if (!/^[0-9a-f]{16}$/.test(ref)) {
// Trivial schema parse.
const env = barrel.parseObservationEnvelope({
schemaVersion: '0.1.0',
schemaVersion: '0.2.0',
generator: { name: '@codeburn/core', version: '0.0.0-smoke' },
sessions: [],
})
if (env.schemaVersion !== '0.1.0') {
if (env.schemaVersion !== '0.2.0') {
console.error('import-smoke: parse returned unexpected envelope')
process.exit(5)
}

View file

@ -23,4 +23,15 @@ describe('JSON Schema drift', () => {
expect(onDisk).toEqual(fresh[name])
})
}
// The superseded 0.1.0 observation schema is kept as a FROZEN historical
// artifact: it is no longer emitted from the current zod (which is 0.2.0), so
// it has no fresh counterpart. Assert it stays pinned at its own version so a
// careless re-emit can never overwrite it with 0.2.0 content.
it('observation-0.1.0.json remains frozen at schemaVersion 0.1.0', () => {
const onDisk = JSON.parse(readFileSync(resolve(schemasDir, 'observation-0.1.0.json'), 'utf8'))
const root = onDisk?.definitions?.ObservationEnvelope ?? onDisk
expect(root?.properties?.schemaVersion?.const).toBe('0.1.0')
expect(Object.keys(fresh)).not.toContain('observation-0.1.0')
})
})

View file

@ -17,7 +17,7 @@ function readJson(rel: string): unknown {
const goldenEnvelope = readJson('tests/fixtures/golden-envelope.json')
const goldenFinding = readJson('tests/fixtures/golden-finding.json')
const observationSchema = readJson('schemas/observation-0.1.0.json') as object
const observationSchema = readJson('schemas/observation-0.2.0.json') as object
const findingSchema = readJson('schemas/finding-0.1.0.json') as object
// strict:false so unknown string formats (date-time) are ignored rather than

View file

@ -10,6 +10,7 @@ export default defineConfig({
'src/diagnostics.ts',
'src/fingerprint.ts',
'src/contracts.ts',
'src/detectors/index.ts',
'src/providers/claude/index.ts',
'src/providers/codex/index.ts',
],