#!/usr/bin/env node
/**
* cli:sources-search — entry point.
*
* THE targeted-extraction path of the BA phases into the client-sources
* registry: deterministic accent/case-folded search over the summaries and
* §-sections of every `ingested` source.md — nobody reads raw/ and nobody
* loads the whole corpus into an agent context (the 394M-token lesson).
*
* Invocation:
* npx --prefer-offline tsx skills/business-analyse/create-sources/cli/search/index.ts \
* --spec '{"baRoot":".smartstack/ba","query":"remise","tags":["regles"]}' [--workdir
]
*
* READ-ONLY. The counts ALWAYS accompany the hits: « N docs cherchés,
* M illisibles » — zero searchable never reads as « nothing matches ».
*/
import { parseArgs } from 'node:util'
import { isAbsolute, join } from 'node:path'
import { z } from 'zod'
import { executeEnvelope, failExecute, printEnvelope } from '../../../../lib/output.js'
import {
loadSourcesRegistry,
searchSources,
sourcesRootFor,
type SourceSearchHit,
} from '../../../../lib/ba-sources.js'
const COMMAND = 'sources-search'
const SearchInputSchema = z
.object({
baRoot: z.string().min(1),
query: z.string().min(2),
/** Filter: only sources carrying one of these tags (doc or section). */
tags: z.array(z.string().min(1)).optional(),
/** Filter: only these SRC codes. */
codes: z.array(z.string().regex(/^SRC-\d{3}$/)).optional(),
})
.strict()
export interface SearchReport {
query: string
registryExists: boolean
hits: SourceSearchHit[]
searchedDocs: number
unreadableDocs: number
registryIssues: 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 = SearchInputSchema.safeParse(raw)
if (!parsed.success) {
printEnvelope(
failExecute(COMMAND, parsed.error.issues.map((i) => `[${i.path.join('.')}] ${i.message}`)),
)
process.exit(1)
}
const spec = parsed.data
const base = values.workdir ?? process.cwd()
const baRoot = isAbsolute(spec.baRoot) ? spec.baRoot : join(base, spec.baRoot)
const registry = loadSourcesRegistry(sourcesRootFor(baRoot))
const result = searchSources(registry, spec.query, { tags: spec.tags, codes: spec.codes })
const report: SearchReport = {
query: spec.query,
registryExists: registry.exists,
hits: result.hits,
searchedDocs: result.searchedDocs,
unreadableDocs: result.unreadableDocs,
registryIssues: registry.reconciliation.issues,
}
const coverage = `${result.searchedDocs} doc(s) cherché(s), ${result.unreadableDocs} illisible(s)`
printEnvelope(
executeEnvelope(COMMAND, {
success: true,
data: { hits: result.hits.length, searchedDocs: result.searchedDocs, unreadableDocs: result.unreadableDocs },
report,
warnings: registry.reconciliation.issues,
nextSteps: !registry.exists
? [`Aucun registre de sources (${sourcesRootFor(baRoot)}) — rien n'a été cherché ; /ba-create-sources pour en créer un.`]
: result.hits.length === 0
? [
`0 hit — ${coverage}. Une liste vide ne conclut que sur ce corpus : reformule la requête ou élargis les tags avant d'affirmer « la source ne dit rien ».`,
]
: [`${result.hits.length} hit(s) — ${coverage}. Cite via \`SRC-NNN §n\` (ligne **Sources** de l'item).`],
}),
)
process.exit(0)
}
main()