@kernlang/review 3.3.8 → 3.4.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.
Files changed (116) hide show
  1. package/dist/cache.js +1 -1
  2. package/dist/call-graph.d.ts +10 -0
  3. package/dist/call-graph.js +138 -9
  4. package/dist/call-graph.js.map +1 -1
  5. package/dist/concept-rules/auth-drift.js +2 -0
  6. package/dist/concept-rules/auth-drift.js.map +1 -1
  7. package/dist/concept-rules/auth-propagation-drift.d.ts +10 -0
  8. package/dist/concept-rules/auth-propagation-drift.js +85 -0
  9. package/dist/concept-rules/auth-propagation-drift.js.map +1 -0
  10. package/dist/concept-rules/body-shape-drift.d.ts +32 -0
  11. package/dist/concept-rules/body-shape-drift.js +98 -0
  12. package/dist/concept-rules/body-shape-drift.js.map +1 -0
  13. package/dist/concept-rules/contract-drift.js +3 -1
  14. package/dist/concept-rules/contract-drift.js.map +1 -1
  15. package/dist/concept-rules/contract-method-drift.js +2 -0
  16. package/dist/concept-rules/contract-method-drift.js.map +1 -1
  17. package/dist/concept-rules/cross-stack-utils.d.ts +24 -0
  18. package/dist/concept-rules/cross-stack-utils.js +123 -29
  19. package/dist/concept-rules/cross-stack-utils.js.map +1 -1
  20. package/dist/concept-rules/index.d.ts +4 -2
  21. package/dist/concept-rules/index.js +22 -3
  22. package/dist/concept-rules/index.js.map +1 -1
  23. package/dist/concept-rules/mutation-without-idempotency.d.ts +10 -0
  24. package/dist/concept-rules/mutation-without-idempotency.js +47 -0
  25. package/dist/concept-rules/mutation-without-idempotency.js.map +1 -0
  26. package/dist/concept-rules/request-validation-drift.d.ts +11 -0
  27. package/dist/concept-rules/request-validation-drift.js +99 -0
  28. package/dist/concept-rules/request-validation-drift.js.map +1 -0
  29. package/dist/concept-rules/root-cause.d.ts +4 -0
  30. package/dist/concept-rules/root-cause.js +31 -0
  31. package/dist/concept-rules/root-cause.js.map +1 -0
  32. package/dist/concept-rules/unbounded-collection-query.d.ts +10 -0
  33. package/dist/concept-rules/unbounded-collection-query.js +58 -0
  34. package/dist/concept-rules/unbounded-collection-query.js.map +1 -0
  35. package/dist/concept-rules/unhandled-api-error-shape.d.ts +10 -0
  36. package/dist/concept-rules/unhandled-api-error-shape.js +59 -0
  37. package/dist/concept-rules/unhandled-api-error-shape.js.map +1 -0
  38. package/dist/default-export.d.ts +41 -0
  39. package/dist/default-export.js +76 -0
  40. package/dist/default-export.js.map +1 -0
  41. package/dist/eval.d.ts +67 -0
  42. package/dist/eval.js +177 -0
  43. package/dist/eval.js.map +1 -0
  44. package/dist/external-tools.js +52 -3
  45. package/dist/external-tools.js.map +1 -1
  46. package/dist/file-context.js +32 -13
  47. package/dist/file-context.js.map +1 -1
  48. package/dist/file-role.d.ts +6 -0
  49. package/dist/file-role.js +27 -0
  50. package/dist/file-role.js.map +1 -1
  51. package/dist/framework-seeds.d.ts +46 -0
  52. package/dist/framework-seeds.js +245 -0
  53. package/dist/framework-seeds.js.map +1 -0
  54. package/dist/git-env.d.ts +1 -0
  55. package/dist/git-env.js +25 -0
  56. package/dist/git-env.js.map +1 -0
  57. package/dist/graph.js +246 -21
  58. package/dist/graph.js.map +1 -1
  59. package/dist/index.d.ts +12 -3
  60. package/dist/index.js +314 -96
  61. package/dist/index.js.map +1 -1
  62. package/dist/mappers/ts-concepts.js +730 -1
  63. package/dist/mappers/ts-concepts.js.map +1 -1
  64. package/dist/path-canonical.d.ts +34 -0
  65. package/dist/path-canonical.js +85 -0
  66. package/dist/path-canonical.js.map +1 -0
  67. package/dist/policy.d.ts +22 -0
  68. package/dist/policy.js +47 -0
  69. package/dist/policy.js.map +1 -0
  70. package/dist/project-context.d.ts +135 -0
  71. package/dist/project-context.js +563 -0
  72. package/dist/project-context.js.map +1 -0
  73. package/dist/public-api.d.ts +21 -0
  74. package/dist/public-api.js +17 -2
  75. package/dist/public-api.js.map +1 -1
  76. package/dist/python-fallback.d.ts +2 -0
  77. package/dist/python-fallback.js +506 -0
  78. package/dist/python-fallback.js.map +1 -0
  79. package/dist/reporter.js +106 -1
  80. package/dist/reporter.js.map +1 -1
  81. package/dist/rule-quality.d.ts +58 -0
  82. package/dist/rule-quality.js +357 -0
  83. package/dist/rule-quality.js.map +1 -0
  84. package/dist/rules/base.js +21 -3
  85. package/dist/rules/base.js.map +1 -1
  86. package/dist/rules/dead-code.d.ts +2 -2
  87. package/dist/rules/dead-code.js +88 -4
  88. package/dist/rules/dead-code.js.map +1 -1
  89. package/dist/rules/index.d.ts +22 -0
  90. package/dist/rules/index.js +72 -0
  91. package/dist/rules/index.js.map +1 -1
  92. package/dist/rules/kern-source.d.ts +4 -0
  93. package/dist/rules/kern-source.js +184 -0
  94. package/dist/rules/kern-source.js.map +1 -1
  95. package/dist/rules/react.js +52 -3
  96. package/dist/rules/react.js.map +1 -1
  97. package/dist/rules/suggest-kern-primitive.js +0 -1
  98. package/dist/rules/suggest-kern-primitive.js.map +1 -1
  99. package/dist/semantic-diff.js +2 -0
  100. package/dist/semantic-diff.js.map +1 -1
  101. package/dist/suppression/apply-suppression.js +2 -0
  102. package/dist/suppression/apply-suppression.js.map +1 -1
  103. package/dist/suppression/parse-directives.d.ts +13 -5
  104. package/dist/suppression/parse-directives.js +62 -8
  105. package/dist/suppression/parse-directives.js.map +1 -1
  106. package/dist/suppression/types.d.ts +9 -0
  107. package/dist/suppression/types.js +6 -1
  108. package/dist/suppression/types.js.map +1 -1
  109. package/dist/taint-crossfile.js +15 -8
  110. package/dist/taint-crossfile.js.map +1 -1
  111. package/dist/telemetry.d.ts +126 -0
  112. package/dist/telemetry.js +303 -0
  113. package/dist/telemetry.js.map +1 -0
  114. package/dist/types.d.ts +172 -2
  115. package/dist/types.js.map +1 -1
  116. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -11,32 +11,46 @@
11
11
  import { createRequire } from 'node:module';
12
12
  import { countTokens, parseWithDiagnostics, serializeIR } from '@kernlang/core';
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
14
- import { dirname, join, relative } from 'path';
14
+ import { dirname, join, relative, sep } from 'path';
15
15
  import { Project } from 'ts-morph';
16
16
  // This module compiles to ESM (`type: "module"`), so the runtime has no `require`.
17
17
  // `@kernlang/review-python` is an optional peer — we load it on demand with a
18
18
  // createRequire shim. Without this shim the dynamic load throws
19
19
  // `ReferenceError: require is not defined`, the catch swallows the error, and
20
- // every Python file falls into the "missing-python-support" info fallback
21
- // silently disabling the fullstack wedge rules on any cross-stack repo.
20
+ // Python files use a precise optional mapper when present, then degrade to the
21
+ // built-in fallback extractor so fullstack wedge rules still run in npm installs
22
+ // that do not ship @kernlang/review-python.
22
23
  const moduleRequire = createRequire(import.meta.url);
24
+ function optionalPackageSpecifier(scope, name) {
25
+ return `${scope}/${name}`;
26
+ }
27
+ function optionalLocalSpecifier(...parts) {
28
+ return parts.join('/');
29
+ }
30
+ function requireOptionalModule(specifier) {
31
+ return moduleRequire(specifier);
32
+ }
23
33
  import { buildCallGraph } from './call-graph.js';
24
34
  import { runConceptRules } from './concept-rules/index.js';
25
35
  import { structuralDiff } from './differ.js';
26
36
  import { runTSCDiagnostics } from './external-tools.js';
27
37
  import { buildFileContextMap } from './file-context.js';
28
- import { classifyFileRole } from './file-role.js';
38
+ import { classifyFileRole, classifyFileRoleByPath } from './file-role.js';
29
39
  import { resolveImportGraph } from './graph.js';
30
40
  import { createInMemoryProject, findTsConfig, inferFromSourceFile } from './inferrer.js';
31
41
  import { flattenIR, lintKernIR } from './kern-lint.js';
32
42
  import { extractTsConcepts } from './mappers/ts-concepts.js';
33
43
  import { mineNorms } from './norm-miner.js';
34
44
  import { synthesizeObligations } from './obligations.js';
45
+ import { canonicalize } from './path-canonical.js';
46
+ import { findProjectRoot, getProjectContext, isPathIgnored, isReviewable, } from './project-context.js';
35
47
  import { buildPublicApiMap, expandPublicApiThroughReExports } from './public-api.js';
48
+ import { extractPythonConceptsFallback } from './python-fallback.js';
36
49
  import { runQualityRules } from './quality-rules.js';
37
50
  import { assignDefaultConfidence, calculateStats, sortAndDedup, sortFindings } from './reporter.js';
38
51
  import { debugDetail, ReviewHealthBuilder } from './review-health.js';
39
52
  import { loadBuiltinNativeRules, loadNativeRules } from './rule-loader.js';
53
+ import { applyOverlapCalibration, applyRoleAwareConfidence, applyRuleQualityCalibration } from './rule-quality.js';
40
54
  import { lintConfidenceGraph, lintMultiFileConfidenceGraph } from './rules/confidence.js';
41
55
  import { crossFileAsyncRule, deadExportRule } from './rules/dead-code.js';
42
56
  import { runFastapiConceptRules } from './rules/fastapi.js';
@@ -57,14 +71,84 @@ import { buildConfidenceGraph, computeConfidenceSummary, serializeGraph } from '
57
71
  import { applySuppression } from './suppression/index.js';
58
72
  import { analyzeTaint, analyzeTaintCrossFile, crossFileTaintToFindings, taintToFindings } from './taint.js';
59
73
  import { createFingerprint } from './types.js';
74
+ const TYPESCRIPT_CONCEPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
75
+ let cachedPythonExtractor;
76
+ function loadPythonConceptExtractor() {
77
+ if (cachedPythonExtractor)
78
+ return cachedPythonExtractor;
79
+ const failures = [];
80
+ const candidates = [
81
+ optionalPackageSpecifier('@kernlang', 'review-python'),
82
+ optionalLocalSpecifier('..', '..', 'review-python', 'dist', 'index.js'),
83
+ ];
84
+ for (const candidate of candidates) {
85
+ try {
86
+ const mod = requireOptionalModule(candidate);
87
+ if (typeof mod.extractPythonConcepts === 'function') {
88
+ cachedPythonExtractor = { extractor: mod.extractPythonConcepts, usingFallback: false };
89
+ return cachedPythonExtractor;
90
+ }
91
+ failures.push(`${candidate}: extractPythonConcepts export missing`);
92
+ }
93
+ catch (err) {
94
+ failures.push(`${candidate}: ${err.message}`);
95
+ }
96
+ }
97
+ cachedPythonExtractor = {
98
+ extractor: extractPythonConceptsFallback,
99
+ usingFallback: true,
100
+ failureDetail: failures.join('\n'),
101
+ };
102
+ return cachedPythonExtractor;
103
+ }
104
+ function extensionOf(filePath) {
105
+ const dot = filePath.lastIndexOf('.');
106
+ return dot === -1 ? '' : filePath.slice(dot);
107
+ }
108
+ function isTypeScriptConceptFile(filePath) {
109
+ return TYPESCRIPT_CONCEPT_EXTENSIONS.has(extensionOf(filePath));
110
+ }
111
+ function isConceptMap(value) {
112
+ if (!value || typeof value !== 'object')
113
+ return false;
114
+ const candidate = value;
115
+ return typeof candidate.filePath === 'string' && Array.isArray(candidate.nodes) && Array.isArray(candidate.edges);
116
+ }
117
+ function normalizeExternalConcepts(externalConcepts) {
118
+ const normalized = new Map();
119
+ if (!externalConcepts)
120
+ return normalized;
121
+ for (const [key, value] of externalConcepts) {
122
+ if (!isConceptMap(value))
123
+ continue;
124
+ normalized.set(key, value);
125
+ }
126
+ return normalized;
127
+ }
128
+ function extractConceptsFromSource(source, filePath, health, tsProject) {
129
+ if (isTypeScriptConceptFile(filePath)) {
130
+ const project = tsProject ?? createInMemoryProject();
131
+ const sf = project.createSourceFile(filePath, source, { overwrite: true });
132
+ return extractTsConcepts(sf, filePath);
133
+ }
134
+ if (filePath.endsWith('.py')) {
135
+ const pythonExtractor = loadPythonConceptExtractor();
136
+ if (pythonExtractor.usingFallback) {
137
+ health?.noteKind('concept-extraction', 'fallback', 'Using built-in Python fallback extractor — install/fix @kernlang/review-python for tree-sitter precision', debugDetail(pythonExtractor.failureDetail));
138
+ }
139
+ return pythonExtractor.extractor(source, filePath);
140
+ }
141
+ return undefined;
142
+ }
60
143
  export { buildCallGraph } from './call-graph.js';
61
144
  export { runConceptRules } from './concept-rules/index.js';
62
145
  // Confidence layer
63
146
  export { buildConfidenceGraph, buildMultiFileConfidenceGraph, computeConfidenceSummary, parseConfidence, propagateConfidence, resolveBaseConfidence, serializeGraph, } from './confidence.js';
64
147
  export { structuralDiff } from './differ.js';
148
+ export { evaluateReviewReports, formatReviewEvalSummary, normalizeReviewEvalManifest, summarizeReviewEvalResults, } from './eval.js';
65
149
  export { linkToNodes, runESLint, runTSCDiagnostics, runTSCDiagnosticsFromPaths } from './external-tools.js';
66
150
  export { buildFileContextMap, clearFileContextCache } from './file-context.js';
67
- export { classifyFileRole } from './file-role.js';
151
+ export { classifyFileRole, classifyFileRoleByPath } from './file-role.js';
68
152
  export { resolveImportGraph } from './graph.js';
69
153
  export { findTsConfig, inferFromFile, inferFromSource } from './inferrer.js';
70
154
  // KERN-IR lint pipeline (ground layer)
@@ -76,10 +160,12 @@ export { extractTsConcepts } from './mappers/ts-concepts.js';
76
160
  // Norm mining + obligations
77
161
  export { mineNorms } from './norm-miner.js';
78
162
  export { obligationsFromNorms, obligationsFromStructure, synthesizeObligations } from './obligations.js';
163
+ export { applyReviewPolicyDefaults, getReviewPolicyProfile, inferReviewPolicy, } from './policy.js';
79
164
  export { buildPublicApiMap, EMPTY_PUBLIC_API, expandPublicApiThroughReExports, isPublicApi, resolvePackageEntryFiles, resolveSpecifierToSrc, } from './public-api.js';
80
165
  export { runQualityRules } from './quality-rules.js';
81
166
  export { assignDefaultConfidence, calculateStats, checkEnforcement, dedup, formatEnforcement, formatReport, formatReportJSON, formatSARIF, formatSARIFWithMetadata, formatSARIFWithSuppressions, formatSummary, sortAndDedup, sortFindings, } from './reporter.js';
82
167
  export { debugDetail, ReviewHealthBuilder } from './review-health.js';
168
+ export { applyRuleQualityCalibration, applyRuleSupersession, getRuleQualityProfile, isRulePromotedForCi, validateRuleQualityRegistry, } from './rule-quality.js';
83
169
  export { CONFIDENCE_RULES, lintConfidenceGraph, lintMultiFileConfidenceGraph } from './rules/confidence.js';
84
170
  export { actionMissingIdempotent, assumeLowTrust, branchNonExhaustive, collectUnbounded, expectRangeInverted, GROUND_LAYER_RULES, guardWithoutElse, reasonWithoutBasis, } from './rules/ground-layer.js';
85
171
  export { getRuleRegistry } from './rules/index.js';
@@ -92,6 +178,7 @@ export { computeSemanticDiff, computeSemanticDiffFromSource, formatSemanticDiff,
92
178
  export { applySuppression, configDirectives, isConceptRule, parseDirectives } from './suppression/index.js';
93
179
  // Taint tracking (Phase 2 + cross-file)
94
180
  export { analyzeTaint, analyzeTaintCrossFile, buildExportMap, buildImportMap, crossFileTaintToFindings, isSanitizerSufficient, taintToFindings, } from './taint.js';
181
+ export { buildReviewTelemetry, formatReviewTelemetrySummary, parseReviewTelemetryJsonl, readReviewTelemetrySnapshots, summarizeReviewTelemetry, writeReviewTelemetrySnapshot, } from './telemetry.js';
95
182
  export { detectTemplates } from './template-detector.js';
96
183
  export { createFingerprint } from './types.js';
97
184
  // Cache (Phase 0)
@@ -251,32 +338,16 @@ export function extractConceptsForGraph(filePaths) {
251
338
  // is not cheap; on a whole-repo cache-prime (hundreds of files) the
252
339
  // per-file alloc adds real latency we don't want on a push webhook.
253
340
  let tsProject = null;
254
- let extractPythonConcepts;
255
341
  for (const filePath of filePaths) {
342
+ if (!isReviewableFile(filePath) || !existsSync(filePath))
343
+ continue;
256
344
  try {
257
345
  const source = readFileSync(filePath, 'utf-8');
258
- if (filePath.endsWith('.ts') ||
259
- filePath.endsWith('.tsx') ||
260
- filePath.endsWith('.mts') ||
261
- filePath.endsWith('.cts')) {
262
- if (!tsProject)
263
- tsProject = createInMemoryProject();
264
- const sf = tsProject.createSourceFile(filePath, source);
265
- out.set(filePath, extractTsConcepts(sf, filePath));
266
- }
267
- else if (filePath.endsWith('.py')) {
268
- if (extractPythonConcepts === undefined) {
269
- try {
270
- extractPythonConcepts = moduleRequire('@kernlang/review-python').extractPythonConcepts;
271
- }
272
- catch {
273
- extractPythonConcepts = null;
274
- }
275
- }
276
- if (extractPythonConcepts) {
277
- out.set(filePath, extractPythonConcepts(source, filePath));
278
- }
279
- }
346
+ if (isTypeScriptConceptFile(filePath) && !tsProject)
347
+ tsProject = createInMemoryProject();
348
+ const concepts = extractConceptsFromSource(source, filePath, undefined, tsProject ?? undefined);
349
+ if (concepts)
350
+ out.set(filePath, concepts);
280
351
  }
281
352
  catch {
282
353
  // Best-effort — caller sees which files made it into the map.
@@ -460,7 +531,7 @@ function reviewSourceInternal(source, filePath, config, project, sourceFile) {
460
531
  // Phase 6: Concept extraction + concept rules (universal, cross-language)
461
532
  const emptyConcepts = { filePath, language: 'typescript', nodes: [], edges: [], extractorVersion: '0' };
462
533
  const concepts = safePhase('concepts', () => extractTsConcepts(sourceFile, filePath), emptyConcepts);
463
- allFindings.push(...safePhase('concept-rules', () => runConceptRules(concepts, filePath), []));
534
+ allFindings.push(...safePhase('concept-rules', () => runConceptRules(concepts, filePath, undefined, undefined, config), []));
464
535
  // Phase 7: KERN-IR lint (ground layer + confidence rules on inferred nodes)
465
536
  const irNodes = inferred.map((r) => r.node);
466
537
  const groundFindings = safePhase('ground-lint', () => lintKernIR(irNodes, GROUND_LAYER_RULES), []);
@@ -510,8 +581,18 @@ function reviewSourceInternal(source, filePath, config, project, sourceFile) {
510
581
  }
511
582
  // Merge, dedup, sort — single shared utility
512
583
  const dedupedFindings = sortAndDedup(allFindings);
513
- // Assign calibrated confidence scores to all findings
584
+ // Assign calibrated confidence scores to all findings.
585
+ // Order matters: assign-defaults → role-aware → overlap → rule-quality. The
586
+ // rule-quality pass flips the per-finding `calibrated` flag last so that
587
+ // graph-mode rerun on a union of findings does not compound multipliers.
514
588
  assignDefaultConfidence(dedupedFindings);
589
+ applyRoleAwareConfidence(dedupedFindings, fileRole, config);
590
+ const projectRoot = findProjectRoot(dirname(filePath));
591
+ if (projectRoot) {
592
+ const projectCtx = getProjectContext(projectRoot);
593
+ applyOverlapCalibration(dedupedFindings, projectCtx.external, config);
594
+ }
595
+ applyRuleQualityCalibration(dedupedFindings, config);
515
596
  // Apply suppression (inline comments + config disabledRules)
516
597
  const suppression = applySuppression(dedupedFindings, source, filePath, config, config?.strict ?? false);
517
598
  const findings = sortAndDedup(suppression.findings);
@@ -653,6 +734,8 @@ export function reviewKernSource(source, filePath = 'input.kern', _config) {
653
734
  });
654
735
  const dedupedFindings = sortAndDedup(allFindings);
655
736
  assignDefaultConfidence(dedupedFindings);
737
+ applyRoleAwareConfidence(dedupedFindings, classifyFileRoleByPath(filePath), _config);
738
+ applyRuleQualityCalibration(dedupedFindings, _config);
656
739
  const suppression = applySuppression(dedupedFindings, source, filePath, _config, _config?.strict ?? false);
657
740
  const findings = sortAndDedup(suppression.findings);
658
741
  const kernTokens = countTokens(source);
@@ -683,11 +766,14 @@ export function reviewPythonSource(source, filePath = 'input.py', config) {
683
766
  const totalLines = source.split('\n').length;
684
767
  // Python: concept extraction + concept rules only
685
768
  let conceptFindings = [];
769
+ const health = new ReviewHealthBuilder();
686
770
  try {
687
- // Dynamic import — @kernlang/review-python is optional
688
- const { extractPythonConcepts } = moduleRequire('@kernlang/review-python');
689
- const concepts = extractPythonConcepts(source, filePath);
690
- conceptFindings = runConceptRules(concepts, filePath);
771
+ const pythonExtractor = loadPythonConceptExtractor();
772
+ if (pythonExtractor.usingFallback) {
773
+ health.noteKind('concept-extraction', 'fallback', 'Using built-in Python fallback extractor — install/fix @kernlang/review-python for tree-sitter precision', debugDetail(pythonExtractor.failureDetail));
774
+ }
775
+ const concepts = pythonExtractor.extractor(source, filePath);
776
+ conceptFindings = runConceptRules(concepts, filePath, undefined, undefined, config);
691
777
  if (config?.target === 'fastapi') {
692
778
  conceptFindings.push(...runFastapiConceptRules(concepts, filePath, source));
693
779
  }
@@ -704,30 +790,33 @@ export function reviewPythonSource(source, filePath = 'input.py', config) {
704
790
  }
705
791
  catch (err) {
706
792
  if (process.env.KERN_DEBUG)
707
- console.error(`python mapper load failed: ${err.message}`);
708
- // @kernlang/review-python not installed — skip concept extraction
793
+ console.error(`python concept extraction failed: ${err.message}`);
709
794
  conceptFindings = [
710
795
  {
711
796
  source: 'kern',
712
- ruleId: 'missing-python-support',
797
+ ruleId: 'python-concept-extraction-failed',
713
798
  severity: 'info',
714
799
  category: 'structure',
715
- message: 'Install @kernlang/review-python for Python concept analysis',
800
+ message: 'Python concept extraction failed — Python findings may be incomplete',
716
801
  primarySpan: { file: filePath, startLine: 1, startCol: 1, endLine: 1, endCol: 1 },
717
- fingerprint: 'missing-python-0',
802
+ fingerprint: 'python-concept-extraction-failed-0',
718
803
  },
719
804
  ];
720
805
  }
721
806
  const dedupedFindings = sortAndDedup(conceptFindings);
722
807
  assignDefaultConfidence(dedupedFindings);
808
+ applyRoleAwareConfidence(dedupedFindings, classifyFileRoleByPath(filePath), config);
809
+ applyRuleQualityCalibration(dedupedFindings, config);
723
810
  const suppression = applySuppression(dedupedFindings, source, filePath, config, config?.strict ?? false);
724
811
  const findings = sortAndDedup(suppression.findings);
812
+ const reviewHealth = health.build();
725
813
  return {
726
814
  filePath,
727
815
  inferred: [],
728
816
  templateMatches: [],
729
817
  findings,
730
818
  ...(suppression.suppressed.length > 0 ? { suppressedFindings: sortAndDedup(suppression.suppressed) } : {}),
819
+ ...(reviewHealth ? { health: reviewHealth } : {}),
731
820
  stats: {
732
821
  totalLines,
733
822
  coveredLines: 0,
@@ -741,10 +830,15 @@ export function reviewPythonSource(source, filePath = 'input.py', config) {
741
830
  }
742
831
  /**
743
832
  * Review all .ts/.tsx/.py files in a directory.
833
+ *
834
+ * Honors the project's .gitignore by skipping untracked-and-ignored files —
835
+ * tracked artifacts are always reviewed even when their directory matches
836
+ * .gitignore, so published `dist/*.gen.ts` and similar do not slip through.
744
837
  */
745
838
  export function reviewDirectory(dirPath, recursive = false, config) {
746
839
  const reports = [];
747
- const files = collectReviewableFiles(dirPath, recursive);
840
+ const ctx = getProjectContext(dirPath);
841
+ const files = collectReviewableFiles(dirPath, recursive, ctx);
748
842
  for (const file of files) {
749
843
  try {
750
844
  reports.push(reviewFile(file, config));
@@ -760,7 +854,7 @@ export function reviewDirectory(dirPath, recursive = false, config) {
760
854
  * Entry files get normal findings, upstream dependencies get origin='upstream'.
761
855
  */
762
856
  export function reviewGraph(entryFiles, config, graphOptions) {
763
- const graph = resolveImportGraph(entryFiles, graphOptions);
857
+ const graph = graphOptions?.precomputedGraph ?? resolveImportGraph(entryFiles, graphOptions);
764
858
  const entrySet = new Set(graph.entryFiles);
765
859
  const reports = [];
766
860
  // Graph-wide subsystem status — one entry per (subsystem, kind) across the whole run.
@@ -860,35 +954,13 @@ export function reviewGraph(entryFiles, config, graphOptions) {
860
954
  // Cross-file concept analysis — re-run concept rules with full graph context
861
955
  // This fixes false positives where guards are in middleware files and effects in handlers
862
956
  const allConcepts = new Map();
863
- // Cache the optional Python mapper: require() it once per graph run instead
864
- // of per-file, and remember if it's absent so we don't pay the throw cost.
865
- let extractPythonConcepts;
866
957
  for (const report of reports) {
867
958
  const filePath = report.filePath;
868
959
  try {
869
960
  const source = readFileSync(filePath, 'utf-8');
870
- if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
871
- const project = createInMemoryProject();
872
- const sf = project.createSourceFile(filePath, source);
873
- allConcepts.set(filePath, extractTsConcepts(sf, filePath));
874
- }
875
- else if (filePath.endsWith('.py')) {
876
- // Python concept seeding is what powers the fullstack wedge rules
877
- // (contract-drift, untyped-api-response, tainted-across-wire) in graph
878
- // mode. Without this branch, `allConcepts` only contained TS entries
879
- // and the rules silently found nothing on cross-stack repos.
880
- if (extractPythonConcepts === undefined) {
881
- try {
882
- extractPythonConcepts = moduleRequire('@kernlang/review-python').extractPythonConcepts;
883
- }
884
- catch {
885
- extractPythonConcepts = null;
886
- }
887
- }
888
- if (extractPythonConcepts) {
889
- allConcepts.set(filePath, extractPythonConcepts(source, filePath));
890
- }
891
- }
961
+ const concepts = extractConceptsFromSource(source, filePath, graphHealth);
962
+ if (concepts)
963
+ allConcepts.set(filePath, concepts);
892
964
  }
893
965
  catch (err) {
894
966
  // Per-file failure — record once at graph level (builder dedupes), then move on.
@@ -918,7 +990,7 @@ export function reviewGraph(entryFiles, config, graphOptions) {
918
990
  if (config.externalConcepts.size > MAX_EXTERNAL_CONCEPTS) {
919
991
  throw new Error(`reviewGraph: externalConcepts size ${config.externalConcepts.size} exceeds cap ${MAX_EXTERNAL_CONCEPTS}`);
920
992
  }
921
- for (const [path, cm] of config.externalConcepts) {
993
+ for (const [path, cm] of normalizeExternalConcepts(config.externalConcepts)) {
922
994
  if (!allConcepts.has(path))
923
995
  allConcepts.set(path, cm);
924
996
  }
@@ -927,10 +999,15 @@ export function reviewGraph(entryFiles, config, graphOptions) {
927
999
  // Concept rule IDs to replace (remove per-file findings, add cross-file ones)
928
1000
  const CONCEPT_RULE_IDS = new Set([
929
1001
  'boundary-mutation',
1002
+ 'auth-propagation-drift',
930
1003
  'ignored-error',
931
1004
  'missing-response-model',
1005
+ 'mutation-without-idempotency',
1006
+ 'request-validation-drift',
932
1007
  'sync-handler-does-io',
1008
+ 'unbounded-collection-query',
933
1009
  'unguarded-effect',
1010
+ 'unhandled-api-error-shape',
934
1011
  'unrecovered-effect',
935
1012
  ]);
936
1013
  for (const report of reports) {
@@ -940,7 +1017,7 @@ export function reviewGraph(entryFiles, config, graphOptions) {
940
1017
  // Remove per-file concept findings (they were run without cross-file context)
941
1018
  report.findings = report.findings.filter((f) => !CONCEPT_RULE_IDS.has(f.ruleId));
942
1019
  // Re-run concept rules with cross-file context
943
- const crossFileConceptFindings = runConceptRules(concepts, report.filePath, allConcepts, graphImports);
1020
+ const crossFileConceptFindings = runConceptRules(concepts, report.filePath, allConcepts, graphImports, graphConfig);
944
1021
  report.findings.push(...crossFileConceptFindings);
945
1022
  }
946
1023
  }
@@ -982,7 +1059,12 @@ export function reviewGraph(entryFiles, config, graphOptions) {
982
1059
  });
983
1060
  for (const gf of graph.files) {
984
1061
  try {
985
- cgProject.addSourceFileAtPath(gf.path);
1062
+ // Seed cgProject with CANONICAL paths so its internal SourceFile
1063
+ // store collapses pnpm/symlink duplicates (red-team #9). This pairs
1064
+ // with graph.ts feeding canonical paths to its own ts-morph project,
1065
+ // so all subsequent ts-morph lookups (here AND in buildCallGraph)
1066
+ // run through the same canonical key space.
1067
+ cgProject.addSourceFileAtPath(gf.canonicalPath);
986
1068
  }
987
1069
  catch {
988
1070
  /* skip unresolvable */
@@ -993,13 +1075,99 @@ export function reviewGraph(entryFiles, config, graphOptions) {
993
1075
  // Build the public-API map once per run — package.json walk is the heavy bit.
994
1076
  // Then propagate through re-export chains so curated barrels (Agon-style:
995
1077
  // `export { foo } from './worker.js'`) carry public-API status upstream.
996
- const basePublicApi = buildPublicApiMap(graph.files.map((gf) => gf.path), config?.publicApi);
997
- const publicApi = expandPublicApiThroughReExports(basePublicApi, (path) => cgProject?.getSourceFile(path));
1078
+ // Pass canonical paths so the resulting `entryFiles` set keys match the
1079
+ // canonical paths that cgProject (and therefore callGraph.functions[i]
1080
+ // .filePath) uses. Otherwise red-team #9 sneaks back in: the call graph
1081
+ // sees `/private/var/...` while the public-API map contains `/var/...`,
1082
+ // and isPublicApi() misses every entry.
1083
+ const basePublicApi = buildPublicApiMap(graph.files.map((gf) => gf.canonicalPath), config?.publicApi);
1084
+ // Step 8: seeded files (Next.js conventions, package.json entries) may
1085
+ // legitimately live outside the BFS-walked graph — for example a lazy
1086
+ // route file beyond `maxDepth`, or a barrel re-exporting a helper that
1087
+ // no eager import in the graph reaches. Without this on-demand load,
1088
+ // expandPublicApiThroughReExports would silently drop those entries
1089
+ // (sourceFileFor returns undefined → loop skips). Loading on demand
1090
+ // costs only the seed entries we actually need, no extra graph work.
1091
+ const sourceFileFor = (path) => {
1092
+ if (!cgProject)
1093
+ return undefined;
1094
+ // Lookup must use canonical too — callers from buildPublicApiMap or
1095
+ // expandPublicApiThroughReExports may hand us a path captured in its
1096
+ // symlink form. Without canonicalisation this would miss a file that
1097
+ // cgProject has stored under its realpath.
1098
+ const canonical = canonicalize(path);
1099
+ const known = cgProject.getSourceFile(canonical);
1100
+ if (known)
1101
+ return known;
1102
+ try {
1103
+ return cgProject.addSourceFileAtPath(canonical);
1104
+ }
1105
+ catch {
1106
+ return undefined;
1107
+ }
1108
+ };
1109
+ const publicApi = expandPublicApiThroughReExports(basePublicApi, sourceFileFor);
1110
+ // Step 10: blockers are passed alongside publicApi so dead-export's
1111
+ // step 9b cap+trail logic can consume them. Producer 1 (unresolved
1112
+ // named re-export with relative specifier) is wired in graph.ts and
1113
+ // rides on `graph.blockers`; Producer 2 (non-literal dynamic import)
1114
+ // is telemetry-only and surfaces via the unmappedDynamicImports counter
1115
+ // below. Empty list is a valid "no blockers detected" state — the rule
1116
+ // short-circuits without any change to existing semantics.
1117
+ const reachabilityBlockers = graph.blockers ?? [];
1118
+ // canonicalToDisplay maps canonical paths back to caller-facing forms
1119
+ // so findings emitted by the rules carry user-friendly paths even
1120
+ // though the rules themselves match against canonical fn.filePath.
1121
+ const canonicalToDisplay = new Map();
1122
+ for (const gf of graph.files) {
1123
+ canonicalToDisplay.set(gf.canonicalPath, gf.path);
1124
+ }
998
1125
  for (const report of reports) {
999
- const deadExportFindings = deadExportRule(callGraph, report.filePath, publicApi);
1000
- report.findings.push(...deadExportFindings);
1001
- const asyncFindings = crossFileAsyncRule(callGraph, report.filePath);
1002
- report.findings.push(...asyncFindings);
1126
+ // The rules iterate callGraph.functions whose filePath is canonical
1127
+ // (cgProject seeded canonical → ts-morph getFilePath returns canonical).
1128
+ // report.filePath is the user-facing display form for diagnostics —
1129
+ // canonicalise at this boundary so the rule's `fn.filePath !== filePath`
1130
+ // filter matches.
1131
+ const ruleFilePath = canonicalize(report.filePath);
1132
+ const deadExportFindings = deadExportRule(callGraph, ruleFilePath, publicApi, reachabilityBlockers);
1133
+ const asyncFindings = crossFileAsyncRule(callGraph, ruleFilePath);
1134
+ // Map canonical paths in finding spans back to display so the user
1135
+ // sees `/var/...` not `/private/var/...` (and pnpm symlink paths
1136
+ // are expressed in their workspace-relative form).
1137
+ for (const f of [...deadExportFindings, ...asyncFindings]) {
1138
+ const display = canonicalToDisplay.get(f.primarySpan.file);
1139
+ if (display)
1140
+ f.primarySpan.file = display;
1141
+ if (f.relatedSpans) {
1142
+ for (const rs of f.relatedSpans) {
1143
+ const rsDisplay = canonicalToDisplay.get(rs.file);
1144
+ if (rsDisplay)
1145
+ rs.file = rsDisplay;
1146
+ }
1147
+ }
1148
+ }
1149
+ report.findings.push(...deadExportFindings, ...asyncFindings);
1150
+ }
1151
+ // Surface graph-traversal telemetry. Two independent failure modes feed
1152
+ // a SINGLE health note here because ReviewHealthBuilder dedupes by
1153
+ // `(subsystem, kind)` — emitting two `'call-graph' / 'fallback'` notes
1154
+ // would silently drop the second one (Codex review caught this).
1155
+ // • Producer 2: non-literal `import(expr)` — dead-export findings on
1156
+ // those files may include FPs the cap mechanism cannot reach (no
1157
+ // target file/exportName to attach a blocker to).
1158
+ // • Malformed static imports — previously a silent catch swallowed
1159
+ // these; now counted and surfaced. KERN_DEBUG logs each.
1160
+ const unmappedDyn = graph.unmappedDynamicImports ?? 0;
1161
+ const malformed = graph.malformedImports ?? 0;
1162
+ if (unmappedDyn > 0 || malformed > 0) {
1163
+ const parts = [];
1164
+ if (unmappedDyn > 0) {
1165
+ parts.push(`${unmappedDyn} non-literal dynamic import(s) could not be traced — dead-export findings in those files may include false positives that cannot be capped`);
1166
+ }
1167
+ if (malformed > 0) {
1168
+ parts.push(`${malformed} static import declaration(s) could not be read by ts-morph — set KERN_DEBUG to surface the underlying errors`);
1169
+ }
1170
+ graphHealth.noteKind('call-graph', 'fallback', `${parts.join('; ')}.`);
1003
1171
  }
1004
1172
  }
1005
1173
  catch (err) {
@@ -1014,6 +1182,8 @@ export function reviewGraph(entryFiles, config, graphOptions) {
1014
1182
  try {
1015
1183
  const source = readFileSync(report.filePath, 'utf-8');
1016
1184
  const unsuppressedCandidates = [...report.findings, ...(report.suppressedFindings ?? [])];
1185
+ assignDefaultConfidence(unsuppressedCandidates);
1186
+ applyRuleQualityCalibration(unsuppressedCandidates, config);
1017
1187
  const suppression = applySuppression(sortAndDedup(unsuppressedCandidates), source, report.filePath, config, config?.strict ?? false);
1018
1188
  report.findings = sortAndDedup(suppression.findings);
1019
1189
  report.suppressedFindings = suppression.suppressed.length > 0 ? sortAndDedup(suppression.suppressed) : undefined;
@@ -1022,6 +1192,35 @@ export function reviewGraph(entryFiles, config, graphOptions) {
1022
1192
  report.findings = sortAndDedup(report.findings);
1023
1193
  }
1024
1194
  }
1195
+ // Universal canonical → display remap. Multiple graph-aware rules
1196
+ // (use-client-drilled-too-high, missing-use-client, server-hook,
1197
+ // next-client-api-in-server, …) emit findings whose `relatedSpans`
1198
+ // reference cross-file paths sourced from cgProject SourceFiles —
1199
+ // those paths are canonical (red-team #9 fix). Walk every finding
1200
+ // once and translate canonical paths back to the caller-facing
1201
+ // display via the graph's display index. Cheap: O(findings * spans)
1202
+ // with a pre-built Map; symmetric across all rules without each one
1203
+ // needing to know about the canonicalisation invariant.
1204
+ const canonicalToDisplayFinal = new Map();
1205
+ for (const gf of graph.files) {
1206
+ canonicalToDisplayFinal.set(gf.canonicalPath, gf.path);
1207
+ }
1208
+ if (canonicalToDisplayFinal.size > 0) {
1209
+ for (const report of reports) {
1210
+ for (const f of report.findings) {
1211
+ const displayPrimary = canonicalToDisplayFinal.get(f.primarySpan.file);
1212
+ if (displayPrimary)
1213
+ f.primarySpan.file = displayPrimary;
1214
+ if (f.relatedSpans) {
1215
+ for (const rs of f.relatedSpans) {
1216
+ const display = canonicalToDisplayFinal.get(rs.file);
1217
+ if (display)
1218
+ rs.file = display;
1219
+ }
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1025
1224
  // Merge graph-level health into every report. Each report may already carry per-file health
1026
1225
  // (e.g. fs-project fallback); fold those entries into the graph builder so every report sees
1027
1226
  // the complete, deduped picture before we emit.
@@ -1035,35 +1234,54 @@ export function reviewGraph(entryFiles, config, graphOptions) {
1035
1234
  }
1036
1235
  return reports;
1037
1236
  }
1038
- function collectReviewableFiles(dirPath, recursive) {
1237
+ function collectReviewableFiles(dirPath, recursive, ctx) {
1039
1238
  const files = [];
1239
+ // Hardcoded skips for directories that are never useful to descend into,
1240
+ // even if a tracked file lives inside them. ProjectContext.isReviewable
1241
+ // handles the gitignore-vs-tracked logic for files we DO descend into.
1242
+ const HARD_SKIP_DIRS = new Set(['node_modules', '__pycache__', '.venv', 'venv']);
1040
1243
  for (const entry of readdirSync(dirPath)) {
1041
1244
  const full = join(dirPath, entry);
1042
1245
  const stat = statSync(full);
1043
- if (stat.isDirectory() &&
1044
- recursive &&
1045
- !entry.startsWith('.') &&
1046
- entry !== 'node_modules' &&
1047
- entry !== 'dist' &&
1048
- entry !== '__pycache__' &&
1049
- entry !== '.venv' &&
1050
- entry !== 'venv') {
1051
- files.push(...collectReviewableFiles(full, true));
1246
+ if (stat.isDirectory()) {
1247
+ if (!recursive)
1248
+ continue;
1249
+ if (entry.startsWith('.') || HARD_SKIP_DIRS.has(entry))
1250
+ continue;
1251
+ // Honor .gitignore at the directory level only if NO tracked file lives
1252
+ // inside it (cheap check via path prefix on the tracked set).
1253
+ if (ctx && isPathIgnored(full, ctx) && !directoryHasTrackedDescendant(full, ctx))
1254
+ continue;
1255
+ files.push(...collectReviewableFiles(full, true, ctx));
1256
+ continue;
1052
1257
  }
1053
- else if (stat.isFile() &&
1054
- (entry.endsWith('.ts') || entry.endsWith('.tsx')) &&
1258
+ if (!stat.isFile())
1259
+ continue;
1260
+ const isReviewableExt = ((entry.endsWith('.ts') || entry.endsWith('.tsx')) &&
1055
1261
  !entry.endsWith('.d.ts') &&
1056
1262
  !entry.endsWith('.test.ts') &&
1057
- !entry.endsWith('.test.tsx')) {
1058
- files.push(full);
1059
- }
1060
- else if (stat.isFile() && entry.endsWith('.py') && !entry.startsWith('test_') && !entry.endsWith('_test.py')) {
1061
- files.push(full);
1062
- }
1063
- else if (stat.isFile() && entry.endsWith('.kern')) {
1064
- files.push(full);
1065
- }
1263
+ !entry.endsWith('.test.tsx')) ||
1264
+ (entry.endsWith('.py') && !entry.startsWith('test_') && !entry.endsWith('_test.py')) ||
1265
+ entry.endsWith('.kern');
1266
+ if (!isReviewableExt)
1267
+ continue;
1268
+ // Skip-list: gitignored AND not tracked. Tracked-but-gitignored files
1269
+ // (e.g. checked-in dist/*.gen.ts) remain reviewable — Phase 1 red-team #4.
1270
+ if (ctx && !isReviewable(full, ctx))
1271
+ continue;
1272
+ files.push(full);
1066
1273
  }
1067
1274
  return files;
1068
1275
  }
1276
+ function directoryHasTrackedDescendant(dirAbsPath, ctx) {
1277
+ const rel = relative(ctx.root, dirAbsPath).split(sep).join('/');
1278
+ if (!rel || rel.startsWith('..'))
1279
+ return false;
1280
+ const prefix = rel.endsWith('/') ? rel : `${rel}/`;
1281
+ for (const tracked of ctx.gitTrackedFiles) {
1282
+ if (tracked.startsWith(prefix))
1283
+ return true;
1284
+ }
1285
+ return false;
1286
+ }
1069
1287
  //# sourceMappingURL=index.js.map