@erclx/canon 4.81.0 → 4.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,26 @@ import {
25
25
  testableCount,
26
26
  } from '@/context/architecture'
27
27
  import { auditCitations, type CitationReport } from '@/context/citations'
28
+ import {
29
+ CANONICAL_DOC_TYPES,
30
+ type CanonicalDocType,
31
+ } from '@/context/classify/extract'
32
+ import {
33
+ classifyDiff,
34
+ classifySweep,
35
+ type ClassifyOutcome,
36
+ type ClassifyRefusal,
37
+ type DiffFinding,
38
+ type DiffRecord,
39
+ type SweepFinding,
40
+ type SweepRecord,
41
+ } from '@/context/classify/run'
42
+ import {
43
+ CLASSIFIER_CONFIG_REL,
44
+ type ClassifierBackend,
45
+ resolveClassifier,
46
+ writeClassifierConfig,
47
+ } from '@/context/classify/settings'
28
48
  import {
29
49
  type AuditedFolder,
30
50
  DEFAULT_FOLDERS,
@@ -122,6 +142,411 @@ export function register(program: Command): void {
122
142
  .action(async (path: string | undefined, opts: AuditCommandOptions) => {
123
143
  process.exitCode = await runAudit(path, opts)
124
144
  })
145
+
146
+ const classify = context
147
+ .command('classify')
148
+ .description(
149
+ 'Classify canonical-doc content as keep, replace/rewrite, history, or move',
150
+ )
151
+ .helpOption('-h, --help', 'Show this help message')
152
+
153
+ classify
154
+ .command('diff')
155
+ .description(
156
+ 'Classify the chunks a git range changed, each with the section it landed in',
157
+ )
158
+ .argument('[path]', 'Project root, defaulting to the current directory')
159
+ .helpOption('-h, --help', 'Show this help message')
160
+ .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
161
+ .option(
162
+ '--doc-types <list>',
163
+ `Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
164
+ )
165
+ .option(
166
+ '--backend <name>',
167
+ 'Override the resolved model backend for this run',
168
+ )
169
+ .option('--model <name>', 'Override the resolved model name for this run')
170
+ .option('--json', 'Add a machine-readable record on stdout')
171
+ .addHelpText(
172
+ 'after',
173
+ [
174
+ '',
175
+ 'The regex layer always runs. The model layer runs only when a',
176
+ 'backend resolves through `canon context classifier show`, and a',
177
+ 'configured-but-unreachable backend warns and falls back to regex',
178
+ 'rather than failing the run.',
179
+ '',
180
+ 'Exit codes:',
181
+ ' 0 the run completed, whatever the findings say',
182
+ ' 1 refused: a bad range or a file this could not read',
183
+ '',
184
+ 'Examples:',
185
+ ' canon context classify diff',
186
+ ' canon context classify diff --base origin/main --json',
187
+ ' canon context classify diff --doc-types context,wireframes',
188
+ '',
189
+ ].join('\n'),
190
+ )
191
+ .action(async (path: string | undefined, opts: ClassifyDiffOptions) => {
192
+ process.exitCode = await runClassifyDiff(path, opts)
193
+ })
194
+
195
+ classify
196
+ .command('sweep')
197
+ .description(
198
+ 'Classify every section of the five canonical doc types, split at H3',
199
+ )
200
+ .argument('[path]', 'Project root, defaulting to the current directory')
201
+ .helpOption('-h, --help', 'Show this help message')
202
+ .option(
203
+ '--doc-types <list>',
204
+ `Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
205
+ )
206
+ .option(
207
+ '--backend <name>',
208
+ 'Override the resolved model backend for this run',
209
+ )
210
+ .option('--model <name>', 'Override the resolved model name for this run')
211
+ .option('--json', 'Add a machine-readable record on stdout')
212
+ .addHelpText(
213
+ 'after',
214
+ [
215
+ '',
216
+ 'Exit codes:',
217
+ ' 0 the run completed, whatever the findings say',
218
+ ' 1 refused: a file this could not read',
219
+ '',
220
+ 'Examples:',
221
+ ' canon context classify sweep',
222
+ ' canon context classify sweep --doc-types design,requirements --json',
223
+ '',
224
+ ].join('\n'),
225
+ )
226
+ .action(async (path: string | undefined, opts: ClassifySweepOptions) => {
227
+ process.exitCode = await runClassifySweep(path, opts)
228
+ })
229
+
230
+ const classifier = context
231
+ .command('classifier')
232
+ .description('Read or write the project classifier setting')
233
+ .helpOption('-h, --help', 'Show this help message')
234
+
235
+ classifier
236
+ .command('show')
237
+ .description(
238
+ 'Report the backend and model that would run, and which source decided it',
239
+ )
240
+ .argument('[path]', 'Project root, defaulting to the current directory')
241
+ .helpOption('-h, --help', 'Show this help message')
242
+ .option('--json', 'Add a machine-readable record on stdout')
243
+ .action((path: string | undefined, opts: { json?: boolean }) => {
244
+ runClassifierShow(path, opts)
245
+ })
246
+
247
+ classifier
248
+ .command('set')
249
+ .description('Write the project classifier setting')
250
+ .argument('[path]', 'Project root, defaulting to the current directory')
251
+ .helpOption('-h, --help', 'Show this help message')
252
+ .requiredOption('--backend <name>', 'ollama or off')
253
+ .option('--model <name>', 'Model name, required when --backend is ollama')
254
+ .option('--json', 'Add a machine-readable record on stdout')
255
+ .action((path: string | undefined, opts: ClassifierSetOptions) => {
256
+ process.exitCode = runClassifierSet(path, opts)
257
+ })
258
+ }
259
+
260
+ interface ClassifyDiffOptions {
261
+ readonly json?: boolean
262
+ readonly base?: string
263
+ readonly docTypes?: string
264
+ readonly backend?: string
265
+ readonly model?: string
266
+ }
267
+
268
+ interface ClassifySweepOptions {
269
+ readonly json?: boolean
270
+ readonly docTypes?: string
271
+ readonly backend?: string
272
+ readonly model?: string
273
+ }
274
+
275
+ interface ClassifierSetOptions {
276
+ readonly json?: boolean
277
+ readonly backend: string
278
+ readonly model?: string
279
+ }
280
+
281
+ function parseDocTypes(
282
+ list: string | undefined,
283
+ ): readonly CanonicalDocType[] | string {
284
+ if (!list) return CANONICAL_DOC_TYPES
285
+
286
+ const names = list
287
+ .split(',')
288
+ .map((name) => name.trim())
289
+ .filter(Boolean)
290
+
291
+ const invalid = names.filter(
292
+ (name) => !CANONICAL_DOC_TYPES.includes(name as CanonicalDocType),
293
+ )
294
+ if (invalid.length > 0) {
295
+ return `--doc-types takes ${CANONICAL_DOC_TYPES.join(', ')}: ${invalid.join(', ')}`
296
+ }
297
+
298
+ return names as CanonicalDocType[]
299
+ }
300
+
301
+ /**
302
+ * Validates `--backend` up front, so a typo'd value refuses rather than
303
+ * falling through `resolveBackend`'s tier chain to the environment, the
304
+ * file, or the default, which reads as the flag having been ignored.
305
+ */
306
+ function parseBackendFlag(value: string | undefined): string | undefined {
307
+ if (value === undefined || value === 'ollama' || value === 'off') {
308
+ return undefined
309
+ }
310
+ return `--backend takes ollama or off: ${value}`
311
+ }
312
+
313
+ /** Widens the extraction refusals with the CLI's own argument-parsing failure. */
314
+ type CliRefusal = ClassifyRefusal | 'bad-flags'
315
+
316
+ function refuseClassify(
317
+ reason: CliRefusal,
318
+ message: string,
319
+ emitJson: boolean,
320
+ ): number {
321
+ intro('canon context classify')
322
+ logStep('Refused')
323
+ logWarn(message)
324
+ outro()
325
+
326
+ if (emitJson) {
327
+ process.stdout.write(
328
+ `${JSON.stringify({ decision: 'refused', reason, message })}\n`,
329
+ )
330
+ }
331
+ return 1
332
+ }
333
+
334
+ /** How each not-off model-layer state reads in the report. */
335
+ const MODEL_LAYER_LABEL: Record<string, string> = {
336
+ ran: 'ran',
337
+ 'skipped-no-model': 'skipped, no model resolved for the configured backend',
338
+ 'skipped-unreachable': 'skipped, the configured backend did not answer',
339
+ }
340
+
341
+ function reportLayers(record: DiffRecord | SweepRecord): void {
342
+ logStep('Layers')
343
+ logInfo('regex: ran')
344
+
345
+ if (record.modelLayer === 'off') {
346
+ logInfo('model: off, no backend configured')
347
+ return
348
+ }
349
+
350
+ logInfo(
351
+ `model: ${MODEL_LAYER_LABEL[record.modelLayer]} (${record.backend ?? 'none'}${record.model ? `, ${record.model}` : ''})`,
352
+ )
353
+ if (record.modelLayer !== 'ran') {
354
+ logWarn('Falling back to the regex layer alone for this run.')
355
+ }
356
+ }
357
+
358
+ function reportFindings(
359
+ findings: readonly (DiffFinding | SweepFinding)[],
360
+ label: (finding: DiffFinding | SweepFinding) => string,
361
+ ): void {
362
+ logStep('Findings')
363
+
364
+ if (findings.length === 0) {
365
+ logInfo('Nothing met the extraction floor under the requested doc types.')
366
+ return
367
+ }
368
+
369
+ const flagged = findings.filter((finding) => finding.verdict !== 'KEEP')
370
+ logInfo(
371
+ `${plural(findings.length, 'item')} read, ${plural(flagged.length, 'flagged')}`,
372
+ )
373
+
374
+ if (flagged.length === 0) return
375
+
376
+ pipeOutput(
377
+ flagged
378
+ .map((finding) => {
379
+ const chosen =
380
+ finding.decidedBy === 'model' ? finding.model : finding.regex
381
+ return `${label(finding)} ${finding.verdict} (${finding.decidedBy})\n ${chosen?.quote ? `"${chosen.quote}" ` : ''}${chosen?.reason ?? ''}`
382
+ })
383
+ .join('\n'),
384
+ )
385
+ }
386
+
387
+ async function runClassifyDiff(
388
+ path: string | undefined,
389
+ opts: ClassifyDiffOptions,
390
+ ): Promise<number> {
391
+ const root = resolve(path ?? process.cwd())
392
+ const emitJson = opts.json ?? false
393
+
394
+ const docTypes = parseDocTypes(opts.docTypes)
395
+ if (typeof docTypes === 'string') {
396
+ return refuseClassify('bad-flags', docTypes, emitJson)
397
+ }
398
+
399
+ const backendError = parseBackendFlag(opts.backend)
400
+ if (backendError !== undefined) {
401
+ return refuseClassify('bad-flags', backendError, emitJson)
402
+ }
403
+
404
+ const result: ClassifyOutcome<DiffRecord> = await classifyDiff(
405
+ root,
406
+ opts.base,
407
+ {
408
+ docTypes,
409
+ flags: { backend: opts.backend, model: opts.model },
410
+ },
411
+ )
412
+
413
+ if (result.kind === 'refused') {
414
+ return refuseClassify(result.reason, result.message, emitJson)
415
+ }
416
+
417
+ intro('canon context classify diff')
418
+ reportLayers(result.record)
419
+ reportFindings(result.record.findings, (finding) => finding.file)
420
+ outro()
421
+
422
+ if (emitJson) {
423
+ process.stdout.write(
424
+ `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
425
+ )
426
+ }
427
+
428
+ return 0
429
+ }
430
+
431
+ async function runClassifySweep(
432
+ path: string | undefined,
433
+ opts: ClassifySweepOptions,
434
+ ): Promise<number> {
435
+ const root = resolve(path ?? process.cwd())
436
+ const emitJson = opts.json ?? false
437
+
438
+ const docTypes = parseDocTypes(opts.docTypes)
439
+ if (typeof docTypes === 'string') {
440
+ return refuseClassify('bad-flags', docTypes, emitJson)
441
+ }
442
+
443
+ const backendError = parseBackendFlag(opts.backend)
444
+ if (backendError !== undefined) {
445
+ return refuseClassify('bad-flags', backendError, emitJson)
446
+ }
447
+
448
+ const result: ClassifyOutcome<SweepRecord> = await classifySweep(root, {
449
+ docTypes,
450
+ flags: { backend: opts.backend, model: opts.model },
451
+ })
452
+
453
+ if (result.kind === 'refused') {
454
+ return refuseClassify(result.reason, result.message, emitJson)
455
+ }
456
+
457
+ intro('canon context classify sweep')
458
+ reportLayers(result.record)
459
+ reportFindings(
460
+ result.record.findings,
461
+ (finding) => `${finding.file}:${(finding as SweepFinding).heading}`,
462
+ )
463
+ outro()
464
+
465
+ if (emitJson) {
466
+ process.stdout.write(
467
+ `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
468
+ )
469
+ }
470
+
471
+ return 0
472
+ }
473
+
474
+ function runClassifierShow(
475
+ path: string | undefined,
476
+ opts: { json?: boolean },
477
+ ): void {
478
+ const root = resolve(path ?? process.cwd())
479
+ const resolution = resolveClassifier(root, {})
480
+
481
+ intro('canon context classifier show')
482
+
483
+ if (resolution.kind === 'off') {
484
+ logInfo(
485
+ `Model layer off (source: ${resolution.source}). Regex layer always runs.`,
486
+ )
487
+ } else if (resolution.kind === 'no-model') {
488
+ logWarn(
489
+ `Backend ${resolution.backend} configured (source: ${resolution.source}) with no model name. \`classify\` runs regex only.`,
490
+ )
491
+ } else {
492
+ logInfo(
493
+ `Backend ${resolution.backend}, model ${resolution.model} (source: ${resolution.source}).`,
494
+ )
495
+ }
496
+
497
+ logInfo(
498
+ `Reads ${CLASSIFIER_CONFIG_REL} when neither a flag nor an environment variable decides.`,
499
+ )
500
+ outro()
501
+
502
+ if (opts.json) {
503
+ process.stdout.write(`${JSON.stringify(resolution)}\n`)
504
+ }
505
+ }
506
+
507
+ function runClassifierSet(
508
+ path: string | undefined,
509
+ opts: ClassifierSetOptions,
510
+ ): number {
511
+ const root = resolve(path ?? process.cwd())
512
+ const emitJson = opts.json ?? false
513
+
514
+ if (opts.backend !== 'ollama' && opts.backend !== 'off') {
515
+ return refuseClassify(
516
+ 'bad-flags',
517
+ '--backend takes ollama or off.',
518
+ emitJson,
519
+ )
520
+ }
521
+
522
+ if (opts.backend === 'ollama' && !opts.model) {
523
+ return refuseClassify(
524
+ 'bad-flags',
525
+ '--model is required when --backend is ollama.',
526
+ emitJson,
527
+ )
528
+ }
529
+
530
+ const backend: ClassifierBackend | 'off' = opts.backend
531
+ writeClassifierConfig(
532
+ root,
533
+ backend,
534
+ backend === 'off' ? undefined : opts.model,
535
+ )
536
+
537
+ intro('canon context classifier set')
538
+ logInfo(
539
+ `Wrote ${CLASSIFIER_CONFIG_REL}: backend ${opts.backend}${opts.model ? `, model ${opts.model}` : ''}.`,
540
+ )
541
+ outro()
542
+
543
+ if (emitJson) {
544
+ process.stdout.write(
545
+ `${JSON.stringify({ decision: 'ok', backend: opts.backend, model: opts.model })}\n`,
546
+ )
547
+ }
548
+
549
+ return 0
125
550
  }
126
551
 
127
552
  function parseFolders(list: string | undefined): string[] | string {