#!/usr/bin/env node
/**
* cli:sources-status — entry point.
*
* Health of the client-sources registry: index ↔ disk reconciliation,
* per-status counts, blocked sources named, raw/ presence. READ-ONLY —
* drift is DATA (exit 0); the verdict belongs to audit-ba (SRC-001..007).
*
* Invocation:
* npx --prefer-offline tsx skills/business-analyse/create-sources/cli/status/index.ts \
* --spec '{"baRoot":".smartstack/ba"}' [--workdir
]
*/
import { parseArgs } from 'node:util'
import { existsSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import { z } from 'zod'
import { executeEnvelope, failExecute, printEnvelope } from '../../../../lib/output.js'
import { loadSourcesRegistry, sourcesRootFor, type SourceStatus } from '../../../../lib/ba-sources.js'
const COMMAND = 'sources-status'
const StatusInputSchema = z.object({ baRoot: z.string().min(1) }).strict()
export interface StatusReport {
registryExists: boolean
root: string
reconciliation: { status: 'ok' | 'suspect'; issues: string[] }
controls: { anchors: number; parsed: number }
counts: Record
nextSeq: number | null
blocked: Array<{ code: string; title: string; status: string }>
missingRaw: string[]
warnings: string[]
}
function main(): void {
const { values } = parseArgs({
options: {
spec: { type: 'string' },
workdir: { type: 'string' },
},
strict: true,
})
if (!values.spec) {
printEnvelope(failExecute(COMMAND, ['--spec is required']))
process.exit(1)
}
let raw: unknown
try {
raw = JSON.parse(values.spec)
} catch {
printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec']))
process.exit(1)
}
const parsed = StatusInputSchema.safeParse(raw)
if (!parsed.success) {
printEnvelope(
failExecute(COMMAND, parsed.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`)),
)
process.exit(1)
}
const base = values.workdir ?? process.cwd()
const baRoot = isAbsolute(parsed.data.baRoot) ? parsed.data.baRoot : join(base, parsed.data.baRoot)
const registry = loadSourcesRegistry(sourcesRootFor(baRoot))
const counts: Record = {
ingested: 0,
'blocked/needs-export': 0,
'blocked/unreadable': 0,
superseded: 0,
}
const blocked: StatusReport['blocked'] = []
const missingRaw: string[] = []
if (registry.index) {
for (const entry of Object.values(registry.index.sources)) {
counts[entry.status] = (counts[entry.status] ?? 0) + 1
if (entry.status.startsWith('blocked/')) {
blocked.push({ code: entry.code, title: entry.title, status: entry.status })
}
if (entry.rawFile && !existsSync(join(registry.root, entry.code, ...entry.rawFile.split('/')))) {
missingRaw.push(`${entry.code}: ${entry.rawFile}`)
}
}
}
const report: StatusReport = {
registryExists: registry.exists,
root: registry.root,
reconciliation: registry.reconciliation,
controls: registry.controls,
counts,
nextSeq: registry.index?.nextSeq ?? null,
blocked,
missingRaw,
warnings: registry.warnings,
}
const nextSteps: string[] = []
if (!registry.exists) {
nextSteps.push('Aucun registre — état légitime (« sans objet ») tant qu’aucune source client n’existe.')
} else {
if (registry.reconciliation.status === 'suspect') {
nextSteps.push('Registre SUSPECT — répare index.json/les dossiers avant toute écriture ; SRC-001 rendra err tant que ça diverge.')
}
if (blocked.length > 0) {
nextSteps.push(`${blocked.length} source(s) bloquée(s) non exploitée(s) — exporte puis rattache via ingest \`"as":"SRC-NNN"\`.`)
}
if (missingRaw.length > 0) {
nextSteps.push(`${missingRaw.length} copie(s) raw/ manquante(s) — restaure-les ou ré-ingère (la citation doit rester vérifiable).`)
}
if (nextSteps.length === 0) nextSteps.push('Registre sain.')
}
printEnvelope(
executeEnvelope(COMMAND, {
success: true,
data: {
exists: registry.exists,
reconciliation: registry.reconciliation.status,
issues: registry.reconciliation.issues.length,
...counts,
},
report,
warnings: [...registry.reconciliation.issues, ...registry.warnings],
nextSteps,
}),
)
process.exit(0)
}
main()