import { test } from 'brittle'
import { mkdirSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { scan } from '../lib/scanner.js'
/**
* End-to-end checks for Phase 0a invariants.
*
* I2 — external refs resolve as 'external', not 'missing'.
* I3 — placeholder paths are not classified as references at all.
* I4 — deprecated modules don't emit undocumented-X warnings.
*
* Uses minimal in-tempdir fixtures so the test is self-contained and
* doesn't couple to pear-git-v3's current spec state (invariant I1).
*/
function mkFixture (name) {
const dir = join(tmpdir(), 'codetree-test-' + name + '-' + Date.now())
mkdirSync(dir, { recursive: true })
return dir
}
function cleanup (dir) {
rmSync(dir, { recursive: true, force: true })
}
// --- I2: external symbols resolve, don't flag as missing ---
test('I2: external symbols declared in .codetree.json resolve as external', async (t) => {
const dir = mkFixture('i2-externals')
try {
// Minimal fixture: a fake "project" with a spec that references an external symbol
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'myproject', version: '0.1.0', main: 'index.js'
}))
writeFileSync(join(dir, 'index.js'), 'export function hello () { return "hi" }\n')
writeFileSync(join(dir, '.codetree.json'), JSON.stringify({
externalSymbols: ['an.sign', 'an.open'],
externalPackages: ['anproto']
}))
writeFileSync(join(dir, 'README.md'),
'# myproject\n\n' +
'Uses `an.sign()` and `an.open()` from `anproto`.\n' +
'Our own function: `hello()`.\n'
)
const tree = await scan(dir)
t.ok(tree.docCruft, 'docCruft produced')
const readme = tree.docCruft.docs.find(d => d.path === 'README.md')
t.ok(readme, 'README.md found in report')
// hello() is local and should resolve as valid
const helloDetail = readme.references.details.find(d => d.ref === 'hello()')
t.ok(helloDetail, 'hello() reference extracted')
t.is(helloDetail.status, 'valid', 'hello() resolves as valid')
// an.sign() and an.open() should be external, not missing
const signDetail = readme.references.details.find(d => d.ref === 'an.sign()')
t.ok(signDetail, 'an.sign() reference extracted')
t.is(signDetail.status, 'external', 'an.sign() resolves as external, not missing')
const openDetail = readme.references.details.find(d => d.ref === 'an.open()')
t.ok(openDetail, 'an.open() reference extracted')
t.is(openDetail.status, 'external', 'an.open() resolves as external')
// Summary counts separate external from missing
t.ok(tree.docCruft.summary.externalReferences >= 2, 'external refs counted separately')
} finally {
cleanup(dir)
}
})
test('I2: external refs do NOT inflate staleness score', async (t) => {
// Same fixture; confirm a doc with only external refs has low/zero staleness.
const dir = mkFixture('i2-staleness')
try {
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'myproject', version: '0.1.0', main: 'index.js'
}))
writeFileSync(join(dir, 'index.js'), 'export const x = 1\n')
writeFileSync(join(dir, '.codetree.json'), JSON.stringify({
externalSymbols: ['external_thing']
}))
writeFileSync(join(dir, 'docs.md'),
'# docs\n\n' +
'References `external_thing` (should not penalize staleness).\n'
)
const tree = await scan(dir)
const doc = tree.docCruft.docs.find(d => d.path === 'docs.md')
t.ok(doc, 'docs.md found')
t.is(doc.references.missing, 0, 'external ref not counted as missing')
t.ok(doc.references.external >= 1, 'external ref counted as external')
t.ok(doc.staleness.missingRatio === 0, 'missing ratio is 0 for externals-only doc')
} finally {
cleanup(dir)
}
})
// --- I3: placeholder paths filtered out ---
test('I3: placeholder paths are not classified as references', async (t) => {
const dir = mkFixture('i3-placeholders')
try {
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name: 'myproj', version: '0.1.0', main: 'index.js'
}))
writeFileSync(join(dir, 'index.js'), 'export function run () {}\n')
writeFileSync(join(dir, 'README.md'),
'# myproj\n\n' +
'Config lives at `~/.myproj/config.json`.\n' +
'Run `run()` from `<install-dir>/bin/myproj`.\n' +
'Paths like `/path/to/install` are examples.\n' +
'Env variable: `${MY_PROJ_HOME}`.\n'
)
const tree = await scan(dir)
const readme = tree.docCruft.docs.find(d => d.path === 'README.md')
t.ok(readme, 'README.md found')
// run() should be found
const runDetail = readme.references.details.find(d => d.ref === 'run()')
t.ok(runDetail, 'run() reference extracted')
// Placeholders should NOT appear in the details at all
const placeholderDetails = readme.references.details.filter(d =>
d.ref.startsWith('~/') ||
d.ref.startsWith('/path/to/') ||
d.ref.startsWith('<') ||
d.ref.includes('${')
)
t.is(placeholderDetails.length, 0, 'placeholder refs not in details')
} finally {
cleanup(dir)
}
})
// --- I4: deprecated modules don't warn about undocumented items ---
// (Validator-side test; uses a minimal spec fixture.)
test('I4: deprecated module skips undocumented-export/method warnings', async (t) => {
const dir = mkFixture('i4-deprecated')
try {
// Fixture: a project with one module (the "legacy" module) that
// exposes methods but is marked deprecated in its spec.
const modDir = join(dir, 'legacy')
mkdirSync(modDir, { recursive: true })
writeFileSync(join(modDir, 'package.json'), JSON.stringify({
name: 'legacy', version: '0.1.0', main: 'index.js'
}))
writeFileSync(join(modDir, 'index.js'),
'export class Legacy {\n' +
' foo () {}\n' +
' bar () {}\n' +
' baz () {}\n' +
'}\n'
)
// Spec dir at scan root
const specDir = join(dir, 'spec')
const modulesDir = join(specDir, 'modules')
mkdirSync(modulesDir, { recursive: true })
writeFileSync(join(specDir, 'SYSTEM.md'),
'# System\n\n' +
'**Last verified:** 2026-04-16\n\n' +
'### Legacy modules\n\n' +
'| Module | Location | Purpose |\n' +
'|---|---|---|\n' +
'| **legacy** | `legacy/` | Deprecated module |\n'
)
writeFileSync(join(modulesDir, 'legacy.md'),
'# legacy -- Module Spec\n\n' +
'**Location:** `legacy/`\n' +
'**Status:** deprecated\n' +
'**Replaced by:** `NewClass`\n\n' +
'## What it is\n\nOld module, being removed.\n\n' +
'## API\n\n```js\nimport { Legacy } from \'legacy\'\nconst l = new Legacy()\n// (methods no longer documented)\n```\n'
)
const tree = await scan(dir)
t.ok(tree.validation, 'validation produced')
const modReport = tree.validation.modules.legacy
t.ok(modReport, 'legacy module validated')
// There should be NO warn-level "Method X is documented" checks for
// legacy's methods, because the module is deprecated.
const undocumentedMethodWarnings = modReport.checks.filter(c =>
c.status === 'warn' && /Method ".*" is documented/.test(c.claim)
)
t.is(undocumentedMethodWarnings.length, 0, 'no undocumented-method warnings for deprecated module')
const undocumentedExportWarnings = modReport.checks.filter(c =>
c.status === 'warn' && /Export ".*" is documented/.test(c.claim)
)
t.is(undocumentedExportWarnings.length, 0, 'no undocumented-export warnings for deprecated module')
} finally {
cleanup(dir)
}
})
test('I4: non-deprecated module still emits undocumented warnings', async (t) => {
// Contrast fixture — same shape, WITHOUT the deprecated marker.
const dir = mkFixture('i4-active')
try {
const modDir = join(dir, 'active')
mkdirSync(modDir, { recursive: true })
writeFileSync(join(modDir, 'package.json'), JSON.stringify({
name: 'active', version: '0.1.0', main: 'index.js'
}))
writeFileSync(join(modDir, 'index.js'),
'export class Active {\n' +
' foo () {}\n' +
' bar () {}\n' +
'}\n'
)
const specDir = join(dir, 'spec')
const modulesDir = join(specDir, 'modules')
mkdirSync(modulesDir, { recursive: true })
writeFileSync(join(specDir, 'SYSTEM.md'),
'# System\n\n**Last verified:** 2026-04-16\n\n' +
'### Active\n\n| Module | Location | Purpose |\n|---|---|---|\n' +
'| **active** | `active/` | Active module |\n'
)
writeFileSync(join(modulesDir, 'active.md'),
'# active -- Module Spec\n\n' +
'**Location:** `active/`\n\n' +
'## What it is\n\nActive module.\n\n' +
'## API\n\n```js\nimport { Active } from \'active\'\nconst a = new Active()\n// foo documented, bar is NOT\na.foo()\n```\n'
)
const tree = await scan(dir)
const modReport = tree.validation.modules.active
t.ok(modReport, 'active module validated')
// Should warn about `bar` being undocumented
const undocumentedMethodWarnings = modReport.checks.filter(c =>
c.status === 'warn' && c.claim.includes('Method "bar"')
)
t.ok(undocumentedMethodWarnings.length > 0, 'undocumented method "bar" emits warning when not deprecated')
} finally {
cleanup(dir)
}
})