OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / suggest-fixes.test.js

460 lines · javascript ref:
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')
})