import { test } from 'brittle'
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { scan } from '../lib/scanner.js'
import { parseSpecs } from '../lib/doc-parser.js'
import {
suggestFixes,
formatSuggest,
formatSuggestJson,
applyFixes,
CONFIDENCE_ORDER
} from '../lib/suggest-fixes.js'
/**
* Phase 0c — suggest-fixes mode.
*
* Tests use self-contained tmpdir fixtures (invariant I1 — don't couple to
* pear-git-v3's current spec state).
*/
function mkFixture (name) {
const dir = join(tmpdir(), 'codetree-suggest-' + name + '-' + Date.now())
mkdirSync(dir, { recursive: true })
return dir
}
function cleanup (dir) {
rmSync(dir, { recursive: true, force: true })
}
function mkProject (root, modules, specs) {
// modules: { name: { files: { 'index.js': 'source content ... ' } } }
// specs: { 'SYSTEM.md': '# ...', 'modules/name.md': '...' }
writeFileSync(join(root, 'package.json'), JSON.stringify({
name: 'fixture', version: '0.1.0', main: 'index.js'
}))
// Top-level index.js so the root itself is a module
if (!modules.__root) {
writeFileSync(join(root, 'index.js'), '// fixture root\n')
}
for (const [modName, mod] of Object.entries(modules)) {
if (modName === '__root') continue
const modDir = join(root, modName)
mkdirSync(modDir, { recursive: true })
writeFileSync(join(modDir, 'package.json'), JSON.stringify({
name: modName, version: '0.1.0', main: 'index.js'
}))
for (const [fname, content] of Object.entries(mod.files || {})) {
writeFileSync(join(modDir, fname), content)
}
}
const specDir = join(root, 'spec')
const modulesDir = join(specDir, 'modules')
mkdirSync(modulesDir, { recursive: true })
for (const [path, content] of Object.entries(specs)) {
const fullPath = join(specDir, path)
const parent = fullPath.substring(0, fullPath.lastIndexOf('/'))
mkdirSync(parent, { recursive: true })
writeFileSync(fullPath, content)
}
}
// --- LOC drift detection ---
test('suggest: detects LOC drift with high confidence', async (t) => {
const root = mkFixture('loc-drift')
try {
const code = 'export function a () {}\n'.repeat(50) // 50 lines of code
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Tests:** 0 pass',
'**Source verified:** 2026-04-10 (index.js 10 LOC)',
'',
'## What it is',
'',
'A test module.',
''
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const drift = result.fixes.find(f => f.type === 'loc-drift')
t.ok(drift, 'LOC drift detected')
t.is(drift.confidence, 'high', 'high confidence')
t.ok(drift.suggested.includes('index.js 50 LOC') ||
drift.suggested.includes('index.js 51 LOC'), 'suggests actual LOC (50 or 51)')
t.ok(drift.current.includes('index.js 10 LOC'), 'current has old LOC')
} finally {
cleanup(root)
}
})
test('suggest: no LOC drift when spec matches code', async (t) => {
const root = mkFixture('no-drift')
try {
const code = 'x\n'.repeat(20) // exactly 21 lines (20 x's + 1 empty from trailing \n)
const actualLines = (code + '').split('\n').length
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Tests:** 0 pass',
'**Source verified:** 2026-04-10 (index.js ' + actualLines + ' LOC)',
'',
'## What it is',
'',
'No drift.',
''
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const drift = result.fixes.find(f => f.type === 'loc-drift')
t.absent(drift, 'no LOC drift when spec is accurate')
} finally {
cleanup(root)
}
})
// --- Date staleness detection ---
test('suggest: flags stale Last verified date (medium confidence)', async (t) => {
const root = mkFixture('date-stale')
try {
// Use a date from over a year ago
const oldDate = '2024-01-01'
mkProject(root, {
mymod: { files: { 'index.js': 'export {}\n' } }
}, {
'SYSTEM.md': [
'# System',
'',
'**Last verified:** ' + oldDate,
'',
'### Core',
'',
'| Module | Location | Purpose |',
'|---|---|---|',
'| **mymod** | `mymod/` | Test |',
''
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs, { staleDateDays: 7 })
const stale = result.fixes.find(f => f.type === 'date-stale' && f.file === 'spec/SYSTEM.md')
t.ok(stale, 'stale date detected')
t.is(stale.confidence, 'medium', 'medium confidence (requires review)')
t.ok(/\d{4}-\d{2}-\d{2}/.test(stale.suggested), 'suggests a new date')
t.not(stale.suggested.includes(oldDate), 'suggested is not the old date')
} finally {
cleanup(root)
}
})
// --- Deprecation marker detection ---
test('suggest: adds Status: deprecated when config lists module as deprecated', async (t) => {
const root = mkFixture('deprec')
try {
mkProject(root, {
legacymod: { files: { 'index.js': 'export class Foo {}\n' } }
}, {
'modules/legacymod.md': [
'# legacymod -- Module Spec',
'',
'**Location:** `legacymod/`',
'',
'## What it is',
'',
'Old module.',
''
].join('\n')
})
// Config says legacymod is deprecated but spec has no marker
writeFileSync(join(root, '.codetree.json'), JSON.stringify({
deprecatedModules: ['legacymod']
}))
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const fix = result.fixes.find(f => f.type === 'deprecation-marker')
t.ok(fix, 'deprecation marker fix proposed')
t.is(fix.confidence, 'high', 'high confidence')
t.ok(fix.suggested.includes('**Status:** deprecated'), 'inserts Status: deprecated')
} finally {
cleanup(root)
}
})
test('suggest: no deprecation marker fix when spec already has one', async (t) => {
const root = mkFixture('already-deprec')
try {
mkProject(root, {
legacymod: { files: { 'index.js': 'export {}\n' } }
}, {
'modules/legacymod.md': [
'# legacymod -- Module Spec',
'',
'**Location:** `legacymod/`',
'**Status:** deprecated',
'',
'## What it is',
'',
'Already marked.',
''
].join('\n')
})
writeFileSync(join(root, '.codetree.json'), JSON.stringify({
deprecatedModules: ['legacymod']
}))
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const fix = result.fixes.find(f => f.type === 'deprecation-marker')
t.absent(fix, 'no fix when marker already present')
} finally {
cleanup(root)
}
})
// --- Narrative flags (low confidence) ---
test('suggest: flags BUG-N references as low-confidence manual review', async (t) => {
const root = mkFixture('narrative')
try {
mkProject(root, {}, {
'BUGS.md': [
'# Bugs',
'',
'BUG-1: something old',
'BUG-42: another old thing',
'',
'NOT YET BUILT: the X feature',
''
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const narrativeFixes = result.fixes.filter(f => f.type === 'narrative-flag')
t.ok(narrativeFixes.length >= 3, 'at least 3 narrative flags (2 BUG-N + NOT YET BUILT)')
for (const f of narrativeFixes) {
t.is(f.confidence, 'low', 'narrative flags are low confidence')
}
} finally {
cleanup(root)
}
})
// --- Confidence filtering ---
test('suggest: confidenceFloor filters applicable fixes', async (t) => {
const root = mkFixture('confidence')
try {
const code = 'line\n'.repeat(100)
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Source verified:** 2020-01-01 (index.js 10 LOC)',
'',
'## What it is',
'',
'BUG-5: something',
''
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const high = suggestFixes(tree, specs, { confidenceFloor: 'high' })
const med = suggestFixes(tree, specs, { confidenceFloor: 'medium' })
const low = suggestFixes(tree, specs, { confidenceFloor: 'low' })
t.ok(high.applicable.length >= 1, 'high: at least 1 applicable (LOC fix)')
t.ok(med.applicable.length >= high.applicable.length, 'medium: ≥ high (includes date-stale)')
t.ok(low.applicable.length >= med.applicable.length, 'low: ≥ medium (includes BUG-N)')
} finally {
cleanup(root)
}
})
// --- Apply ---
test('apply: dry-run does not write files', async (t) => {
const root = mkFixture('apply-dry')
try {
const code = 'x\n'.repeat(100)
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Source verified:** 2026-04-10 (index.js 10 LOC)',
'',
'## What it is',
'',
'Test.',
''
].join('\n')
})
const specPath = join(root, 'spec', 'modules', 'mymod.md')
const before = readFileSync(specPath, 'utf-8')
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const applyResult = applyFixes(result, tree, { dryRun: true })
t.ok(applyResult.applied >= 1, 'applied count > 0')
const after = readFileSync(specPath, 'utf-8')
t.is(before, after, 'file unchanged in dry-run')
} finally {
cleanup(root)
}
})
test('apply: real apply writes the fix to disk', async (t) => {
const root = mkFixture('apply-real')
try {
const code = 'x\n'.repeat(75)
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Source verified:** 2026-04-10 (index.js 10 LOC)',
'',
'## What it is',
'',
'Test.',
''
].join('\n')
})
const specPath = join(root, 'spec', 'modules', 'mymod.md')
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const applyResult = applyFixes(result, tree, { dryRun: false })
t.ok(applyResult.applied >= 1, 'applied count > 0')
const after = readFileSync(specPath, 'utf-8')
t.not(after.includes('index.js 10 LOC'), 'old LOC removed')
t.ok(/index\.js 7[56] LOC/.test(after), 'new LOC (75 or 76) written')
} finally {
cleanup(root)
}
})
// --- Format ---
test('format: formatSuggest produces human-readable text', async (t) => {
const root = mkFixture('format-text')
try {
const code = 'x\n'.repeat(50)
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Source verified:** 2026-04-10 (index.js 10 LOC)',
'',
'## What it is\n\nTest.\n'
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const text = formatSuggest(result)
t.ok(text.includes('codetree suggest'), 'has header')
t.ok(text.includes('[loc-drift]'), 'has fix type')
t.ok(text.includes('current:'), 'shows current')
t.ok(text.includes('suggested:'), 'shows suggested')
} finally {
cleanup(root)
}
})
test('format: formatSuggestJson produces parseable JSON', async (t) => {
const root = mkFixture('format-json')
try {
const code = 'x\n'.repeat(50)
mkProject(root, {
mymod: { files: { 'index.js': code } }
}, {
'modules/mymod.md': [
'# mymod -- Module Spec',
'',
'**Location:** `mymod/`',
'**Source verified:** 2026-04-10 (index.js 10 LOC)',
'',
'## What it is\n\nTest.\n'
].join('\n')
})
const tree = await scan(root)
const specs = parseSpecs(join(root, 'spec'))
const result = suggestFixes(tree, specs)
const json = formatSuggestJson(result)
const parsed = JSON.parse(json)
t.ok(typeof parsed.total === 'number', 'total is a number')
t.ok(typeof parsed.applicable === 'number', 'applicable is a number')
t.ok(Array.isArray(parsed.fixes), 'fixes is an array')
} finally {
cleanup(root)
}
})
test('CONFIDENCE_ORDER exported in expected order', (t) => {
t.is(CONFIDENCE_ORDER[0], 'high')
t.is(CONFIDENCE_ORDER[1], 'medium')
t.is(CONFIDENCE_ORDER[2], 'low')
})