Skip to content

scripts/archon-claim-verifier.mjs

Source location: docs/source-files/scripts/archon-claim-verifier.mjs — this page is a rendered mirror; the file is the source of truth.

archon-claim-verifier.mjs
js
#!/usr/bin/env node
// ADR-27 Claim Verifier — unified assertion-vs-reality drift detection.
//
// Single script consolidates the family of "delivery says one thing, repo
// says another" checks that previously lived as separate prose / tests:
//
//   DEBT-058 numeric-claim cross-check    → mode `numeric`      (spot-check N tests / N files claims)
//   DEBT-066 borrowed-concepts substance   → mode `borrowed`    (drift-record substance gate)
//   DEBT-070 soul-edit self-citation       → mode `self-cite`   (soul edits citing newly-added clauses)
//   DEBT-071 missed-trigger guard          → mode `missed-trig` (Critical debt deadlines vs fired triggers)
//   DEBT-074 preservation substance gate   → mode `preservation` (none-this-cycle evidence + first-pass degeneracy)
//
// Each mode is a pure read-only scan returning structured findings. The
// `verify` subcommand runs all modes; `verify --mode=X` runs one. Findings
// land as a stable line-prefixed report so the auditor + pre-commit hook
// can grep them.
//
// Wire-up: `npm run archon:verify` runs `verify`; `npm run validate` is
// extended to call it after `archon:check`. Pre-commit hook calls the same.
//
// Zero dependencies (only `node:fs` + `node:child_process` for git diff).
// Mirrors the architecture of `scripts/archon-records.mjs`.
//
// Usage:
//   node scripts/archon-claim-verifier.mjs verify [--mode=numeric|borrowed|self-cite|missed-trig|preservation|all] [--base=main]
//   node scripts/archon-claim-verifier.mjs help

import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { resolve, join, sep } from 'node:path'

const ROOT = resolve(process.cwd())
const REPO_RELATIVE = (p) => p.startsWith(ROOT + sep) ? p.slice(ROOT.length + 1) : p

const MODES = ['numeric', 'borrowed', 'self-cite', 'missed-trig', 'preservation']

function usage() {
  return `Usage:
  node scripts/archon-claim-verifier.mjs verify [--mode=numeric|borrowed|self-cite|missed-trig|preservation|all] [--base=<git-ref>]
  node scripts/archon-claim-verifier.mjs help

Modes:
  numeric        Spot-check 'N tests / N files' claims in manifest hot-path against live repo.
  borrowed       Substance gate for drift records' 'Borrowed concepts (if any):' clauses.
  self-cite      Detect soul edits that cite a clause newly added by the same delivery (DEBT-070).
  missed-trig    Flag pending Critical debts whose deadline names an event already past (DEBT-071).
  preservation   Substance gate for ADR-28 Preservation pass evidence; first-pass degeneracy guard (DEBT-074).
  all            Run every mode (default).
`
}

function git(args) {
  try {
    return execSync(`git ${args}`, { cwd: ROOT, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
  } catch {
    return ''
  }
}

function gitFileAtRev(rev, path) {
  try {
    return execSync(`git show ${rev}:${path}`, { cwd: ROOT, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
  } catch {
    return ''
  }
}

// ---------------------------------------------------------------------------
// Mode: numeric — manifest 'N files / M tests' must match live repo counts.
// ---------------------------------------------------------------------------

function modeNumeric() {
  const findings = []
  const manifestPath = resolve(ROOT, '.archon/manifest.md')
  if (!existsSync(manifestPath)) return findings
  const manifest = readFileSync(manifestPath, 'utf-8')

  // Scan only the M-quality-gate row (mirror of manifest-numeric-ground-truth.test.ts surface).
  // Pattern: "(N files / M tests"
  const fileMatch = manifest.match(/\((\d+)\s+files\s*\/\s*(\d+)\s+tests/)
  if (!fileMatch) return findings

  const claimedFiles = parseInt(fileMatch[1], 10)
  const claimedTests = parseInt(fileMatch[2], 10)

  // Count actual *.test.ts and *.test.tsx files under web/src/
  const testFiles = walkSync(resolve(ROOT, 'web/src'))
    .filter((p) => /\.test\.tsx?$/.test(p))
  if (testFiles.length !== claimedFiles) {
    findings.push({
      mode: 'numeric',
      severity: 'error',
      file: '.archon/manifest.md',
      message: `M-quality-gate row claims "${claimedFiles} files" but live repo has ${testFiles.length} test files under web/src/`,
    })
  }
  // Test count is harder without running vitest; defer to manifest-numeric-ground-truth.test.ts for that surface.
  return findings
}

function walkSync(dir) {
  const out = []
  if (!existsSync(dir)) return out
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry)
    let st
    try { st = statSync(full) } catch { continue }
    if (st.isDirectory()) {
      if (entry === 'node_modules' || entry === 'dist' || entry === '.git') continue
      out.push(...walkSync(full))
    } else {
      out.push(full)
    }
  }
  return out
}

// ---------------------------------------------------------------------------
// Mode: borrowed — drift records combining external-framework reject/alternative
// semantics with a `Borrowed concepts (if any):` clause must have a substantive
// clause body (mirrors web/src/test/archon/drift-borrowed-concepts-substance.test.ts).
// This mode duplicates the test logic so the same gate fires at validate time
// without a TS toolchain, and so DEBT-066 has a single conceptual owner.
// ---------------------------------------------------------------------------

const PLACEHOLDER_TOKENS = ['n/a', 'na', 'tbd', 'todo', 'pending', 'unknown', 'nothing', 'none', '?', '-', '—', '–']

function normalizeClause(clause) {
  return clause.trim().replace(/^\[+/, '').replace(/\]+$/, '').trim().toLowerCase()
}

function isShallowBorrowed(clause) {
  const trimmed = clause.trim()
  if (trimmed === '' || trimmed === '[]' || trimmed === '[ ]') {
    return { shallow: true, reason: 'empty borrowed-concepts clause' }
  }
  const noneRationale = trimmed.match(/^none\s*,\s*rationale\s*:\s*(.+)$/i)
  if (noneRationale) {
    if (noneRationale[1].trim().length < 60) {
      return { shallow: true, reason: `none, rationale rationale only ${noneRationale[1].trim().length} chars (<60)` }
    }
    return { shallow: false, reason: '' }
  }
  const normalized = normalizeClause(trimmed)
  if (PLACEHOLDER_TOKENS.includes(normalized)) {
    return { shallow: true, reason: `placeholder \`${trimmed}\`` }
  }
  const trailingStripped = normalizeClause(trimmed.replace(/[.,;:!?—–-]+\s*$/, ''))
  if (PLACEHOLDER_TOKENS.includes(trailingStripped)) {
    return { shallow: true, reason: `placeholder with trailing punctuation \`${trimmed}\`` }
  }
  if (trimmed.length < 12) {
    return { shallow: true, reason: `clause only ${trimmed.length} chars (<12 floor)` }
  }
  return { shallow: false, reason: '' }
}

function modeBorrowed() {
  const findings = []
  const recordsDir = resolve(ROOT, '.archon/drift/records')
  if (!existsSync(recordsDir)) return findings
  for (const file of readdirSync(recordsDir).filter((f) => f.endsWith('.md'))) {
    const body = readFileSync(join(recordsDir, file), 'utf-8')
    const lower = body.toLowerCase()
    if (!lower.includes('external')) continue
    if (!/reject|alternative/i.test(body)) continue
    if (!/Borrowed concepts \(if any\)/.test(body)) continue
    const m = body.match(/(?:\*\*)?Borrowed concepts \(if any\)(?:\*\*)?\s*:\s*([^\n]*?)(?=\s*(?:\*\*[A-Z]|\.\s+[A-Z][a-z]|\n)|$)/)
    if (!m) {
      findings.push({
        mode: 'borrowed',
        severity: 'error',
        file: `.archon/drift/records/${file}`,
        message: 'external-framework reject/alternative record has unparseable Borrowed-concepts clause',
      })
      continue
    }
    const verdict = isShallowBorrowed(m[1])
    if (verdict.shallow) {
      findings.push({
        mode: 'borrowed',
        severity: 'error',
        file: `.archon/drift/records/${file}`,
        message: `borrowed-concepts substance gate failed: ${verdict.reason}`,
      })
    }
  }
  return findings
}

// ---------------------------------------------------------------------------
// Mode: self-cite — soul edits that cite a clause newly added by the same
// delivery. Pattern: any line in a NEWLY-ADDED hunk (vs --base) of soul.md /
// soul/*.md that introduces an "MUST/MAY/SHOULD/exempts/requires/forbids"
// clause; cross-check that the same clause is NOT cited as authority in any
// drift / memo / archon-demand / archon-plan diff hunk of the same delivery.
//
// In its current form the script raises the alarm conservatively: it flags
// soul ADD-hunks that contain prose tokens (MUST / SHOULD / exempts /
// requires / forbids / clarification) WHEN the same delivery's diff also
// contains a quoted reference to the same line. Operators interpret the
// finding (full mechanism is the W7 self-coup pattern; this is the
// stop-gap until ADR-27 evolves).
// ---------------------------------------------------------------------------

function modeSelfCite(baseRef) {
  const findings = []
  const base = baseRef || git('merge-base HEAD origin/main') || git('merge-base HEAD main') || 'HEAD~1'
  const changedSoul = git(`diff --name-only ${base}...HEAD -- .archon/soul.md '.archon/soul/*.md'`)
    .split('\n').filter(Boolean)
  if (changedSoul.length === 0) return findings

  // Distinctive normative tokens that identify a NEW exemption / clarification
  // / restriction clause. Generic prose ("the user", "delivery", etc.) is
  // excluded so the probe stays tied to clause-introduction shape.
  const NORMATIVE = /\b(exempts?|requires?|forbids?|clarification|allowed|prohibited|exception|carve-?out|exempt)\b/i

  for (const path of changedSoul) {
    // For each soul file changed, compute the line-level NEW substrings:
    // for any line whose +version differs from its prior-commit content, the
    // NEW substring is what's been added/changed (not the whole +line).
    const priorContent = gitFileAtRev(base, path)
    const priorLines = new Set(priorContent.split('\n').map((l) => l.trim()))

    // Collect ADDED lines from the diff (this captures both new lines and
    // modified-line replacements in unified-diff form).
    const diff = git(`diff ${base}...HEAD -- ${path}`)
    const addedLines = diff.split('\n')
      .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
      .map((l) => l.slice(1))

    // For each added line, extract NEW SUBSTRINGS — sentence-level fragments
    // that are NOT present in any prior-version line. This catches the
    // failure mode where a normative clause is APPENDED to an existing line
    // (the W7 self-coup pattern: line as a whole is "+" but only the tail is
    // genuinely new prose).
    for (const addedLine of addedLines) {
      // Split the added line into clauses on sentence-boundary punctuation.
      // Each clause is a candidate for "new normative content".
      const clauses = addedLine.split(/(?<=[.!?])\s+(?=[A-Z*])|(?<=:)|(?<=。)/)
        .map((c) => c.trim())
        .filter((c) => c.length >= 30 && NORMATIVE.test(c))

      // Cache the rest-of-delivery diff once per file (not per clause).
      const otherDiff = git(`diff ${base}...HEAD -- '.archon/drift/records/*.md' '.archon/memos/records/*.md' '.cursor/commands/*.md' '.archon/decisions.md' 'docs/archon/decisions.md'`).toLowerCase()

      for (const clause of clauses) {
        // Skip if the clause is fully present in any prior-version line.
        const inPrior = [...priorLines].some((pl) => pl.includes(clause.slice(0, Math.min(60, clause.length))))
        if (inPrior) continue

        // Build MULTIPLE probes per clause and OR them — this catches the
        // W7 pattern where the cited fragment is at the tail of the clause,
        // not adjacent to the first normative token. Probes:
        //   (1) 6 words starting at first normative token
        //   (2) last 6 words of the clause
        //   (3) longest 8-word substring around any normative token (overlap with both)
        const allWords = clause.toLowerCase().match(/\b[a-z][a-z\u4e00-\u9fff-]+\b/g) || []
        if (allWords.length < 4) continue

        const probes = new Set()
        // Probe (1): tail starting at first normative token.
        const normMatch = clause.match(NORMATIVE)
        if (normMatch && normMatch.index !== undefined) {
          const tail = clause.slice(normMatch.index).toLowerCase()
          const tailWords = tail.match(/\b[a-z][a-z\u4e00-\u9fff-]+\b/g) || []
          if (tailWords.length >= 4) probes.add(tailWords.slice(0, 6).join(' '))
        }
        // Probe (2): last 6 words of the clause.
        if (allWords.length >= 6) probes.add(allWords.slice(-6).join(' '))
        // Probe (3): for each normative-token occurrence, take 4 words AFTER it.
        const normRe = new RegExp(NORMATIVE.source, 'gi')
        for (const m of clause.matchAll(normRe)) {
          if (m.index === undefined) continue
          const after = clause.slice(m.index + m[0].length).toLowerCase()
          const afterWords = after.match(/\b[a-z][a-z\u4e00-\u9fff-]+\b/g) || []
          if (afterWords.length >= 4) probes.add(afterWords.slice(0, 5).join(' '))
        }

        for (const probe of probes) {
          if (otherDiff.includes(probe)) {
            findings.push({
              mode: 'self-cite',
              severity: 'warn',
              file: path,
              message: `possible soul-edit self-citation — new clause "${clause.slice(0, 80).trim()}…" (probe="${probe}") cited in same-delivery drift/memo/command/ADR diff. Verify the cited clause exists in ${path}@${base} (prior commit), or split into a prior soul-change demand.`,
            })
            break // one finding per clause
          }
        }
      }
    }
  }
  return findings
}

// ---------------------------------------------------------------------------
// Mode: missed-trig — Critical pending debts whose deadline names an event
// already past in drift records or git diff (DEBT-071).
// ---------------------------------------------------------------------------

function modeMissedTrig(baseRef) {
  const findings = []
  const itemsDir = resolve(ROOT, '.archon/debt/items')
  if (!existsSync(itemsDir)) return findings
  const base = baseRef || git('merge-base HEAD origin/main') || git('merge-base HEAD main') || 'HEAD~1'

  // Build the recent drift summary corpus (last 30 records by name).
  const recordsDir = resolve(ROOT, '.archon/drift/records')
  const recentDriftSummaries = !existsSync(recordsDir) ? '' :
    readdirSync(recordsDir).filter((f) => f.endsWith('.md')).sort().slice(-30)
      .map((f) => readFileSync(join(recordsDir, f), 'utf-8')).join('\n').toLowerCase()

  // Build the diff path corpus.
  const diffPaths = git(`diff --name-only ${base}...HEAD`).toLowerCase()

  for (const file of readdirSync(itemsDir).filter((f) => f.endsWith('.md'))) {
    const body = readFileSync(join(itemsDir, file), 'utf-8')
    const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---/)
    if (!fmMatch) continue
    const fm = fmMatch[1]

    const severity = (fm.match(/severity:\s*(.+)/) || [])[1]?.trim()
    const status = (fm.match(/status:\s*(.+)/) || [])[1]?.trim()
    const deadline = (fm.match(/deadline:\s*(.+)/) || [])[1]?.trim().replace(/^["']|["']$/g, '')
    if (!severity || !status || !deadline) continue
    if (severity !== 'Critical') continue
    if (!/^pending/.test(status)) continue

    // Heuristic: deadline starting with `next-` names an event class.
    // If the recent drift summaries OR the diff path list contains evidence
    // that the named event already happened, the trigger has fired.
    const triggerName = deadline.replace(/^next-/, '').toLowerCase()
    if (!triggerName.includes('-') && triggerName.length < 4) continue
    // Common patterns: "soul-edit" → drift summary mentions soul edit OR diff shows soul.md
    // "framework-evolution-override" → drift summary mentions override OR demand body
    // "external-framework-verdict" → drift mentions external framework Verdict
    const probes = triggerName.split(/-+/).filter((p) => p.length >= 4)
    const hits = probes.filter((probe) =>
      recentDriftSummaries.includes(probe) || diffPaths.includes(probe)
    )
    if (hits.length >= Math.max(1, Math.floor(probes.length / 2))) {
      findings.push({
        mode: 'missed-trig',
        severity: 'warn',
        file: `.archon/debt/items/${file}`,
        message: `Critical/${status} debt deadline \`${deadline}\` matches recent activity (probes hit: ${hits.join(', ')}). Trigger may have fired without countermeasure landing — re-pin deadline (DEBT-063-update protocol) or close the debt with the actual countermeasure.`,
      })
    }
  }
  return findings
}

// ---------------------------------------------------------------------------
// Mode: preservation — ADR-28 Preservation pass output substance gate (DEBT-074).
// Two checks:
//   (a) `Preservation pass: none-this-cycle(<evidence>)` — evidence text MUST
//       be ≥40 chars AND contain a verb-of-scanning AND a target. Empty or
//       hand-wave evidence becomes the frictionless escape valve within ~3
//       cycles otherwise (symmetric to borrowed-concepts substance gate).
//   (b) First-pass degeneracy guard — if a drift record contains
//       `Preservation pass: pinned(...)` AND the same delivery's diff
//       INTRODUCES one or more of those anchors (matches a NEW substring
//       added in the same diff), the record MUST also state "first pass" /
//       "introducing delivery" / "pinned-bootstrap" framing OR use the
//       `pinned-bootstrap(...)` form. Otherwise the introducing delivery
//       self-classifies its just-added anchors as `pin-worthy-now`, which is
//       structurally invalid (cycle has not started — see soul/review.md
//       §Preservation Signals "First-pass degeneracy").
// ---------------------------------------------------------------------------

const PRESERVATION_SCAN_VERBS = /\b(scanned|reviewed|checked|surveyed|examined|inspected|audited|read|searched|grepped|verified)\b/i
const PRESERVATION_TARGETS = /\b(drift\s+records?|resolved[- ]debt|debt\s+items?|memos?|recent\s+cycles?|prior\s+commits?|critical[- ]rule|signs|capsule|anchor|delivery)\b/i

function modePreservation(baseRef) {
  const findings = []
  const recordsDir = resolve(ROOT, '.archon/drift/records')
  if (!existsSync(recordsDir)) return findings
  const base = baseRef || git('merge-base HEAD origin/main') || git('merge-base HEAD main') || 'HEAD~1'
  const diffAdded = git(`diff ${base}...HEAD`)
    .split('\n')
    .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
    .map((l) => l.slice(1))
    .join('\n')
    .toLowerCase()

  for (const file of readdirSync(recordsDir).filter((f) => f.endsWith('.md'))) {
    const body = readFileSync(join(recordsDir, file), 'utf-8')
    if (!/Preservation pass/i.test(body)) continue

    // (a) none-this-cycle substance check
    const noneMatch = body.match(/Preservation pass\*?\*?\s*:\s*none-this-cycle\s*\(([^)]*)\)/i)
    if (noneMatch) {
      const evidence = noneMatch[1].trim()
      const reasons = []
      if (evidence.length < 40) reasons.push(`evidence only ${evidence.length} chars (<40 floor)`)
      if (!PRESERVATION_SCAN_VERBS.test(evidence)) {
        reasons.push('evidence missing verb-of-scanning (scanned / reviewed / checked / etc.)')
      }
      if (!PRESERVATION_TARGETS.test(evidence)) {
        reasons.push('evidence missing scan target (drift records / resolved-debt items / etc.)')
      }
      if (reasons.length > 0) {
        findings.push({
          mode: 'preservation',
          severity: 'error',
          file: `.archon/drift/records/${file}`,
          message: `Preservation pass none-this-cycle evidence shallow: ${reasons.join('; ')}. → Substance gate (DEBT-074) requires evidence ≥40 chars + verb-of-scanning + target.`,
        })
      }
    }

    // (b) first-pass degeneracy guard
    const pinnedMatch = body.match(/Preservation pass\*?\*?\s*:\s*pinned\s*\(([^)]*)\)/i)
    if (pinnedMatch) {
      // Extract anchor candidates from the pinned(...) clause: tokens between
      // backticks, or quoted strings.
      const anchors = [...pinnedMatch[1].matchAll(/`([^`]+)`|"([^"]+)"/g)].map((m) => (m[1] ?? m[2]).trim())
      // For each anchor, check if it appears in the diff's ADDED lines (new
      // anchor introduced by this delivery's same diff).
      const introducedAnchors = anchors.filter((a) => {
        if (!a || a.length < 4) return false
        // Match anchor as substring against added-lines corpus.
        return diffAdded.includes(a.toLowerCase())
      })
      if (introducedAnchors.length > 0) {
        // Require explicit first-pass framing OR `pinned-bootstrap` form.
        const hasFirstPassFraming = /first[-\s]?pass|introducing\s+delivery|pinned-bootstrap|bootstrap\s+pin|pins?\s+the\s+concept/i.test(body)
        if (!hasFirstPassFraming) {
          findings.push({
            mode: 'preservation',
            severity: 'error',
            file: `.archon/drift/records/${file}`,
            message: `Preservation pass pinned(${introducedAnchors.length} anchor(s)) where anchor(s) [${introducedAnchors.slice(0, 3).join(', ')}${introducedAnchors.length > 3 ? '...' : ''}] are introduced by the same delivery's diff — first-pass degeneracy: introducing-delivery cannot meaningfully preservation-classify its own additions. → Add explicit "first pass / introducing delivery" framing OR use \`pinned-bootstrap(...)\` form (soul/review.md §Preservation Signals First-pass degeneracy).`,
          })
        }
      }
    }
  }
  return findings
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

function parseArgs(argv) {
  const out = { mode: 'all', base: '' }
  for (const arg of argv) {
    if (arg.startsWith('--mode=')) out.mode = arg.slice(7)
    else if (arg.startsWith('--base=')) out.base = arg.slice(7)
  }
  return out
}

function main(argv) {
  const cmd = argv[0]
  if (!cmd || cmd === 'help' || cmd === '-h' || cmd === '--help') {
    process.stdout.write(usage())
    return 0
  }
  if (cmd !== 'verify') {
    process.stderr.write(`unknown command \`${cmd}\`\n${usage()}`)
    return 2
  }

  const { mode, base } = parseArgs(argv.slice(1))
  const requested = mode === 'all' ? MODES : [mode]
  for (const m of requested) {
    if (!MODES.includes(m)) {
      process.stderr.write(`unknown mode \`${m}\`. Valid: ${MODES.join(', ')}, all\n`)
      return 2
    }
  }

  const findings = []
  for (const m of requested) {
    if (m === 'numeric') findings.push(...modeNumeric())
    else if (m === 'borrowed') findings.push(...modeBorrowed())
    else if (m === 'self-cite') findings.push(...modeSelfCite(base))
    else if (m === 'missed-trig') findings.push(...modeMissedTrig(base))
    else if (m === 'preservation') findings.push(...modePreservation(base))
  }

  // Report.
  if (findings.length === 0) {
    process.stdout.write(`[claim-verifier] OK: ${requested.join(', ')} — no findings.\n`)
    return 0
  }

  const errors = findings.filter((f) => f.severity === 'error')
  const warns = findings.filter((f) => f.severity === 'warn')

  for (const f of findings) {
    const tag = f.severity === 'error' ? 'FAIL' : 'WARN'
    process.stdout.write(`[claim-verifier:${f.mode}] ${tag}: ${f.file} — ${f.message}\n`)
  }

  if (errors.length > 0) {
    process.stdout.write(`[claim-verifier] FAIL: ${errors.length} error(s), ${warns.length} warning(s).\n`)
    return 1
  }
  process.stdout.write(`[claim-verifier] OK with warnings: 0 errors, ${warns.length} warning(s).\n`)
  return 0
}

const exitCode = main(process.argv.slice(2))
process.exit(exitCode)

Released under the Apache-2.0 License.