@nerviq/cli 1.26.0 → 1.27.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.
package/src/audit.js CHANGED
@@ -37,6 +37,7 @@ const { detectDeprecationWarnings } = require('./deprecation');
37
37
  const { buildWorkspaceHint, formatCount, guardSkippedInstructionFiles, inspectInstructionFiles } = require('./audit/instruction-files');
38
38
  const { resolveEvidence } = require('./audit/evidence');
39
39
  const { LAYERS, summarizeLayers } = require('./audit/layers');
40
+ const { runShallowRisk, SHALLOW_RISK_BANNER_LINES } = require('./shallow-risk');
40
41
  const {
41
42
  WEIGHTS,
42
43
  buildScoreCoaching,
@@ -78,6 +79,54 @@ function formatLocation(file, line) {
78
79
  return line ? `${file}:${line}` : file;
79
80
  }
80
81
 
82
+ function hasShallowRiskData(result) {
83
+ return Boolean(result) && Object.prototype.hasOwnProperty.call(result, 'shallowRiskHints');
84
+ }
85
+
86
+ function printShallowRiskSection(result) {
87
+ if (!hasShallowRiskData(result)) return;
88
+
89
+ const hints = Array.isArray(result.shallowRiskHints) ? result.shallowRiskHints : [];
90
+ console.log(colorize(' Shallow Risk Hints (experimental, opt-in)', 'yellow'));
91
+ for (const line of SHALLOW_RISK_BANNER_LINES) {
92
+ console.log(colorize(` ${line}`, 'dim'));
93
+ }
94
+ console.log('');
95
+
96
+ if (hints.length === 0) {
97
+ console.log(colorize(' No shallow-risk hints found.', 'green'));
98
+ console.log('');
99
+ return;
100
+ }
101
+
102
+ for (const hint of hints) {
103
+ const severity = (hint.severity || 'medium').toUpperCase();
104
+ console.log(` ${colorize(`[${severity}]`, 'bold')} ${hint.name}`);
105
+ if (hint.file) {
106
+ console.log(colorize(` at ${formatLocation(hint.file, hint.line)}`, 'dim'));
107
+ }
108
+ if (hint.fix) {
109
+ console.log(colorize(` -> ${hint.fix}`, 'dim'));
110
+ }
111
+ }
112
+ console.log('');
113
+ }
114
+
115
+ function printShallowRiskOnly(result, dir) {
116
+ console.log('');
117
+ console.log(colorize(' Nerviq Shallow Risk', 'bold'));
118
+ console.log(colorize(' ═══════════════════════════════════════', 'dim'));
119
+ console.log(colorize(` ${t('audit.scanning', { dir })}`, 'dim'));
120
+ console.log('');
121
+ if (result.detectedConfigFiles && result.detectedConfigFiles.length > 0) {
122
+ console.log(colorize(` Found: ${result.detectedConfigFiles.join(', ')}`, 'dim'));
123
+ console.log('');
124
+ }
125
+ printShallowRiskSection(result);
126
+ console.log(` Next: ${colorize('nerviq audit --shallow-risk --full', 'bold')}`);
127
+ console.log('');
128
+ }
129
+
81
130
  function getAuditSpec(platform = 'claude') {
82
131
  if (platform === 'codex') {
83
132
  return {
@@ -299,6 +348,7 @@ function printLiteAudit(result, dir) {
299
348
  if (result.failed === 0) {
300
349
  const platformLabel = result.platform === 'codex' ? 'Codex' : 'Claude';
301
350
  console.log(colorize(` Your ${platformLabel} setup looks solid.`, 'green'));
351
+ printShallowRiskSection(result);
302
352
  console.log(` Next: ${colorize(result.suggestedNextCommand, 'bold')}`);
303
353
  if (result.platform === 'codex') {
304
354
  console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
@@ -333,6 +383,7 @@ function printLiteAudit(result, dir) {
333
383
  console.log(colorize(` ${item.fix}`, 'dim'));
334
384
  });
335
385
  console.log('');
386
+ printShallowRiskSection(result);
336
387
  const liteTerminology = formatTerminologyLines(collectAuditTerminology(result));
337
388
  if (liteTerminology.length > 0) {
338
389
  liteTerminology.forEach((line) => {
@@ -365,8 +416,12 @@ async function audit(options) {
365
416
  const spec = getAuditSpec(options.platform || 'claude');
366
417
  const silent = options.silent || false;
367
418
  const ctx = new spec.ContextClass(options.dir);
368
- const largeInstructionFiles = inspectInstructionFiles(spec, ctx);
369
- guardSkippedInstructionFiles(ctx, largeInstructionFiles);
419
+ const shallowRiskEnabled = Boolean(options.shallowRisk) && process.env.NERVIQ_SHALLOW_RISK !== 'off';
420
+ const shallowRiskOnly = Boolean(options.shallowRiskOnly) && shallowRiskEnabled;
421
+ const largeInstructionFiles = shallowRiskOnly ? [] : inspectInstructionFiles(spec, ctx);
422
+ if (!shallowRiskOnly) {
423
+ guardSkippedInstructionFiles(ctx, largeInstructionFiles);
424
+ }
370
425
  const stacks = ctx.detectStacks(STACKS);
371
426
  const results = [];
372
427
  const outcomeSummary = getRecommendationOutcomeSummary(options.dir);
@@ -397,46 +452,48 @@ async function audit(options) {
397
452
  const includeGenericQuality = options.verbose;
398
453
 
399
454
  // Run all technique checks
400
- for (const [key, technique] of Object.entries(techniques)) {
401
- // Skip entire stack category if the stack is not detected at a core location
402
- // Skip generic quality categories unless --verbose is set
403
- const cat = technique.category;
404
- if ((!includeGenericQuality && GENERIC_QUALITY_CATEGORIES.has(cat)) ||
405
- (STACK_CATEGORY_DETECTORS[cat] && !activeStackCategories.has(cat))) {
455
+ if (!shallowRiskOnly) {
456
+ for (const [key, technique] of Object.entries(techniques)) {
457
+ // Skip entire stack category if the stack is not detected at a core location
458
+ // Skip generic quality categories unless --verbose is set
459
+ const cat = technique.category;
460
+ if ((!includeGenericQuality && GENERIC_QUALITY_CATEGORIES.has(cat)) ||
461
+ (STACK_CATEGORY_DETECTORS[cat] && !activeStackCategories.has(cat))) {
462
+ results.push({
463
+ key,
464
+ ...technique,
465
+ file: null,
466
+ line: null,
467
+ passed: null, // not applicable
468
+ });
469
+ continue;
470
+ }
471
+
472
+ const passed = technique.check(ctx);
473
+ let file = typeof technique.file === 'function' ? (technique.file(ctx) ?? null) : (technique.file ?? null);
474
+ let line = typeof technique.line === 'function' ? (technique.line(ctx) ?? null) : (technique.line ?? null);
475
+ let snippet = null;
476
+ // CTO-04: only compute evidence on failed checks (cheap, and only where it adds trust).
477
+ if (passed === false) {
478
+ const evidence = resolveEvidence(key, ctx, { file, line });
479
+ if (evidence) {
480
+ file = evidence.file;
481
+ line = evidence.line;
482
+ snippet = evidence.snippet;
483
+ }
484
+ }
406
485
  results.push({
407
486
  key,
408
487
  ...technique,
409
- file: null,
410
- line: null,
411
- passed: null, // not applicable
488
+ file,
489
+ line: Number.isFinite(line) ? line : null,
490
+ snippet,
491
+ passed,
412
492
  });
413
- continue;
414
493
  }
415
-
416
- const passed = technique.check(ctx);
417
- let file = typeof technique.file === 'function' ? (technique.file(ctx) ?? null) : (technique.file ?? null);
418
- let line = typeof technique.line === 'function' ? (technique.line(ctx) ?? null) : (technique.line ?? null);
419
- let snippet = null;
420
- // CTO-04: only compute evidence on failed checks (cheap, and only where it adds trust).
421
- if (passed === false) {
422
- const evidence = resolveEvidence(key, ctx, { file, line });
423
- if (evidence) {
424
- file = evidence.file;
425
- line = evidence.line;
426
- snippet = evidence.snippet;
427
- }
428
- }
429
- results.push({
430
- key,
431
- ...technique,
432
- file,
433
- line: Number.isFinite(line) ? line : null,
434
- snippet,
435
- passed,
436
- });
437
494
  }
438
495
 
439
- if (largeInstructionFiles.length > 0) {
496
+ if (!shallowRiskOnly && largeInstructionFiles.length > 0) {
440
497
  results.push({
441
498
  key: 'largeInstructionFile',
442
499
  id: null,
@@ -505,8 +562,10 @@ async function audit(options) {
505
562
  const scaffoldedPassed = passed.filter(r => scaffoldedKeys.has(r.key));
506
563
  const organicEarned = organicPassed.reduce((sum, r) => sum + (WEIGHTS[r.impact] || 5), 0);
507
564
  const organicScore = maxScore > 0 ? Math.round((organicEarned / maxScore) * 100) : 0;
508
- const quickWins = getQuickWins(failed, { platform: spec.platform });
509
- const topNextActions = buildTopNextActions(failed, 5, outcomeSummary.byKey, { platform: spec.platform, fpFeedbackByKey: fpFeedback.byKey });
565
+ const quickWins = shallowRiskOnly ? [] : getQuickWins(failed, { platform: spec.platform });
566
+ const topNextActions = shallowRiskOnly
567
+ ? []
568
+ : buildTopNextActions(failed, 5, outcomeSummary.byKey, { platform: spec.platform, fpFeedbackByKey: fpFeedback.byKey });
510
569
 
511
570
  // CTO-04: enrich top actions with file/line/snippet from the corresponding
512
571
  // result record (evidence was resolved above during the check loop).
@@ -536,10 +595,10 @@ async function audit(options) {
536
595
  action.projectedOrganicScoreDelta = 0;
537
596
  }
538
597
  }
539
- const categoryScores = computeCategoryScores(applicable, passed);
540
- const platformScopeNote = getPlatformScopeNote(spec, ctx);
541
- const platformCaveats = getPlatformCaveats(spec, ctx);
542
- const deprecationWarnings = detectDeprecationWarnings(failed, packageVersion);
598
+ const categoryScores = shallowRiskOnly ? {} : computeCategoryScores(applicable, passed);
599
+ const platformScopeNote = shallowRiskOnly ? null : getPlatformScopeNote(spec, ctx);
600
+ const platformCaveats = shallowRiskOnly ? [] : getPlatformCaveats(spec, ctx);
601
+ const deprecationWarnings = shallowRiskOnly ? [] : detectDeprecationWarnings(failed, packageVersion);
543
602
  const warnings = [
544
603
  ...largeInstructionFiles.map((item) => ({
545
604
  kind: 'large-instruction-file',
@@ -557,7 +616,7 @@ async function audit(options) {
557
616
  ...item,
558
617
  })),
559
618
  ];
560
- const recommendedDomainPacks = spec.platform === 'codex'
619
+ const recommendedDomainPacks = !shallowRiskOnly && spec.platform === 'codex'
561
620
  ? detectCodexDomainPacks(ctx, stacks, getCodexDomainPackSignals(ctx))
562
621
  : [];
563
622
 
@@ -568,7 +627,7 @@ async function audit(options) {
568
627
  stackKeys.has('nextjs') || stackKeys.has('angular') || stackKeys.has('svelte') ||
569
628
  stackKeys.has('nestjs') || stackKeys.has('remix') || stackKeys.has('astro') ||
570
629
  stackKeys.has('typescript') || stackKeys.has('deno') || stackKeys.has('bun');
571
- if (!hasNodeStack) {
630
+ if (!shallowRiskOnly && !hasNodeStack) {
572
631
  let preferredTest = null;
573
632
  let preferredInstall = null;
574
633
  if (stackKeys.has('python') || stackKeys.has('django') || stackKeys.has('fastapi')) {
@@ -620,7 +679,7 @@ async function audit(options) {
620
679
  sunsetDate: r.sunsetDate || null,
621
680
  })),
622
681
  categoryScores,
623
- scoreCoaching: buildScoreCoaching({
682
+ scoreCoaching: shallowRiskOnly ? null : buildScoreCoaching({
624
683
  score,
625
684
  earnedPoints: earnedScore,
626
685
  maxPoints: maxScore,
@@ -645,6 +704,9 @@ async function audit(options) {
645
704
  // CTO-08: per-layer coverage summary (governance/drift/hygiene/shallow-risk).
646
705
  layerSummary: summarizeLayers(activeResults),
647
706
  };
707
+ if (shallowRiskEnabled) {
708
+ result.shallowRiskHints = runShallowRisk(ctx);
709
+ }
648
710
  // Detect which AI config files are present
649
711
  const configFiles = [];
650
712
  const configChecks = [
@@ -661,7 +723,9 @@ async function audit(options) {
661
723
  }
662
724
  result.detectedConfigFiles = configFiles;
663
725
 
664
- result.suggestedNextCommand = inferSuggestedNextCommand(result);
726
+ result.suggestedNextCommand = shallowRiskOnly
727
+ ? 'nerviq audit --shallow-risk --full'
728
+ : inferSuggestedNextCommand(result);
665
729
  result.liteSummary = {
666
730
  topNextActions: topNextActions.slice(0, 3),
667
731
  nextCommand: result.suggestedNextCommand,
@@ -710,6 +774,11 @@ async function audit(options) {
710
774
  return result;
711
775
  }
712
776
 
777
+ if (shallowRiskOnly) {
778
+ printShallowRiskOnly(result, options.dir);
779
+ return result;
780
+ }
781
+
713
782
  if (options.lite) {
714
783
  printLiteAudit(result, options.dir);
715
784
  sendInsights(result);
@@ -798,9 +867,8 @@ async function audit(options) {
798
867
  const layerOrder = [LAYERS.GOVERNANCE, LAYERS.DRIFT, LAYERS.HYGIENE, LAYERS.SHALLOW_RISK];
799
868
  for (const layer of layerOrder) {
800
869
  const b = layerSummary[layer] || { total: 0, passed: 0, failed: 0, skipped: 0 };
801
- const reservedNote = layer === LAYERS.SHALLOW_RISK && b.total === 0
802
- ? ' (reserved for --shallow-risk)' : '';
803
- console.log(colorize(` ${layer}: ${b.total} checks (${b.passed} passed, ${b.failed} failed)${reservedNote}`, 'dim'));
870
+ const layerNote = layer === LAYERS.SHALLOW_RISK ? ' (parallel, opt-in, not scored)' : '';
871
+ console.log(colorize(` ${layer}: ${b.total} checks (${b.passed} passed, ${b.failed} failed)${layerNote}`, 'dim'));
804
872
  }
805
873
  console.log('');
806
874
 
@@ -895,6 +963,8 @@ async function audit(options) {
895
963
  console.log('');
896
964
  }
897
965
 
966
+ printShallowRiskSection(result);
967
+
898
968
  const terminology = formatTerminologyLines(collectAuditTerminology(result));
899
969
  if (terminology.length > 0) {
900
970
  terminology.forEach((line) => {
@@ -1,85 +1,86 @@
1
- /**
2
- * CSV Formatter (RFC 4180)
3
- *
4
- * One row per check in a nerviq audit result.
5
- * Columns: key,id,name,category,layer,rating,severity,passed,file,line,sourceUrl,fix
6
- *
7
- * The `layer` column (added in CTO-08) is one of 'governance',
8
- * 'drift', 'hygiene', or 'shallow-risk' — see docs/integration-contracts.md §8.
9
- *
10
- * Quoting rules (RFC 4180):
11
- * - Fields containing comma, double-quote, CR, or LF are wrapped in
12
- * double-quotes.
13
- * - Internal double-quotes are escaped by doubling them.
14
- * - Header row is emitted first.
15
- * - No UTF-8 BOM (some consumers mishandle it).
16
- * - Line separator: LF (consumers accept LF; JUnit/XLSX/csv parsers
17
- * normalize both).
18
- */
19
-
20
- 'use strict';
21
-
22
- const COLUMNS = [
23
- 'key',
24
- 'id',
25
- 'name',
26
- 'category',
27
- 'layer',
28
- 'rating',
29
- 'severity',
30
- 'passed',
31
- 'file',
32
- 'line',
33
- 'sourceUrl',
34
- 'fix',
35
- 'projectedScoreDelta',
36
- 'projectedScoreAfter',
37
- ];
38
-
39
- function csvEscape(value) {
40
- if (value === null || value === undefined) return '';
41
- const s = String(value);
42
- if (/[",\r\n]/.test(s)) {
43
- return `"${s.replace(/"/g, '""')}"`;
44
- }
45
- return s;
46
- }
47
-
48
- function rowFor(r, projections = null) {
49
- const severity = r.severity || r.impact || '';
50
- const proj = projections && projections.get(r.key);
51
- const cells = [
52
- r.key ?? '',
53
- r.id ?? '',
54
- r.name ?? '',
55
- r.category ?? '',
56
- r.layer ?? '',
57
- r.rating ?? '',
58
- severity,
59
- r.passed === null || r.passed === undefined ? '' : String(r.passed),
60
- r.file ?? '',
61
- r.line ?? '',
62
- r.sourceUrl ?? '',
63
- r.fix ?? '',
64
- proj && Number.isFinite(proj.projectedScoreDelta) ? String(proj.projectedScoreDelta) : '',
65
- proj && Number.isFinite(proj.projectedScoreAfter) ? String(proj.projectedScoreAfter) : '',
66
- ];
67
- return cells.map(csvEscape).join(',');
68
- }
69
-
70
- function formatCsv(auditResult) {
71
- const results = Array.isArray(auditResult.results) ? auditResult.results : [];
72
- const projections = new Map();
73
- if (Array.isArray(auditResult.topNextActions)) {
74
- for (const item of auditResult.topNextActions) {
75
- if (item && item.key) projections.set(item.key, item);
76
- }
77
- }
78
- const lines = [COLUMNS.join(',')];
79
- for (const r of results) {
80
- lines.push(rowFor(r, projections));
81
- }
82
- return lines.join('\n');
83
- }
84
-
85
- module.exports = { formatCsv, CSV_COLUMNS: COLUMNS };
1
+ /**
2
+ * CSV Formatter (RFC 4180)
3
+ *
4
+ * One row per check in a nerviq audit result.
5
+ * Columns: key,id,name,category,layer,rating,severity,passed,file,line,sourceUrl,fix
6
+ *
7
+ * The `layer` column (added in CTO-08) is one of 'governance',
8
+ * 'drift', 'hygiene', or 'shallow-risk' — see docs/integration-contracts.md §8.
9
+ *
10
+ * Quoting rules (RFC 4180):
11
+ * - Fields containing comma, double-quote, CR, or LF are wrapped in
12
+ * double-quotes.
13
+ * - Internal double-quotes are escaped by doubling them.
14
+ * - Header row is emitted first.
15
+ * - No UTF-8 BOM (some consumers mishandle it).
16
+ * - Line separator: LF (consumers accept LF; JUnit/XLSX/csv parsers
17
+ * normalize both).
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const COLUMNS = [
23
+ 'key',
24
+ 'id',
25
+ 'name',
26
+ 'category',
27
+ 'layer',
28
+ 'rating',
29
+ 'severity',
30
+ 'passed',
31
+ 'file',
32
+ 'line',
33
+ 'sourceUrl',
34
+ 'fix',
35
+ 'projectedScoreDelta',
36
+ 'projectedScoreAfter',
37
+ ];
38
+
39
+ function csvEscape(value) {
40
+ if (value === null || value === undefined) return '';
41
+ const s = String(value);
42
+ if (/[",\r\n]/.test(s)) {
43
+ return `"${s.replace(/"/g, '""')}"`;
44
+ }
45
+ return s;
46
+ }
47
+
48
+ function rowFor(r, projections = null) {
49
+ const severity = r.severity || r.impact || '';
50
+ const proj = r.layer === 'shallow-risk' ? null : (projections && projections.get(r.key));
51
+ const cells = [
52
+ r.key ?? '',
53
+ r.id ?? '',
54
+ r.name ?? '',
55
+ r.category ?? '',
56
+ r.layer ?? '',
57
+ r.rating ?? '',
58
+ severity,
59
+ r.passed === null || r.passed === undefined ? '' : String(r.passed),
60
+ r.file ?? '',
61
+ r.line ?? '',
62
+ r.sourceUrl ?? '',
63
+ r.fix ?? '',
64
+ proj && Number.isFinite(proj.projectedScoreDelta) ? String(proj.projectedScoreDelta) : '',
65
+ proj && Number.isFinite(proj.projectedScoreAfter) ? String(proj.projectedScoreAfter) : '',
66
+ ];
67
+ return cells.map(csvEscape).join(',');
68
+ }
69
+
70
+ function formatCsv(auditResult) {
71
+ const results = Array.isArray(auditResult.results) ? auditResult.results : [];
72
+ const shallowRiskHints = Array.isArray(auditResult.shallowRiskHints) ? auditResult.shallowRiskHints : [];
73
+ const projections = new Map();
74
+ if (Array.isArray(auditResult.topNextActions)) {
75
+ for (const item of auditResult.topNextActions) {
76
+ if (item && item.key) projections.set(item.key, item);
77
+ }
78
+ }
79
+ const lines = [COLUMNS.join(',')];
80
+ for (const r of [...results, ...shallowRiskHints]) {
81
+ lines.push(rowFor(r, projections));
82
+ }
83
+ return lines.join('\n');
84
+ }
85
+
86
+ module.exports = { formatCsv, CSV_COLUMNS: COLUMNS };