@kernlang/review 3.2.3 → 3.3.5

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 (92) hide show
  1. package/dist/cache.js +140 -3
  2. package/dist/cache.js.map +1 -1
  3. package/dist/call-graph.d.ts +4 -1
  4. package/dist/call-graph.js +290 -25
  5. package/dist/call-graph.js.map +1 -1
  6. package/dist/concept-rules/contract-drift.d.ts +21 -0
  7. package/dist/concept-rules/contract-drift.js +66 -0
  8. package/dist/concept-rules/contract-drift.js.map +1 -0
  9. package/dist/concept-rules/cross-stack-utils.d.ts +50 -0
  10. package/dist/concept-rules/cross-stack-utils.js +98 -0
  11. package/dist/concept-rules/cross-stack-utils.js.map +1 -0
  12. package/dist/concept-rules/index.js +12 -1
  13. package/dist/concept-rules/index.js.map +1 -1
  14. package/dist/concept-rules/tainted-across-wire.d.ts +33 -0
  15. package/dist/concept-rules/tainted-across-wire.js +98 -0
  16. package/dist/concept-rules/tainted-across-wire.js.map +1 -0
  17. package/dist/concept-rules/untyped-api-response.d.ts +30 -0
  18. package/dist/concept-rules/untyped-api-response.js +71 -0
  19. package/dist/concept-rules/untyped-api-response.js.map +1 -0
  20. package/dist/external-tools.d.ts +36 -4
  21. package/dist/external-tools.js +79 -12
  22. package/dist/external-tools.js.map +1 -1
  23. package/dist/graph.js +149 -39
  24. package/dist/graph.js.map +1 -1
  25. package/dist/index.d.ts +29 -4
  26. package/dist/index.js +329 -47
  27. package/dist/index.js.map +1 -1
  28. package/dist/inferrer.d.ts +5 -0
  29. package/dist/inferrer.js +1 -1
  30. package/dist/inferrer.js.map +1 -1
  31. package/dist/llm-bridge.d.ts +26 -1
  32. package/dist/llm-bridge.js +42 -6
  33. package/dist/llm-bridge.js.map +1 -1
  34. package/dist/llm-review.js +29 -11
  35. package/dist/llm-review.js.map +1 -1
  36. package/dist/mappers/ts-concepts.js +278 -7
  37. package/dist/mappers/ts-concepts.js.map +1 -1
  38. package/dist/public-api.d.ts +73 -0
  39. package/dist/public-api.js +351 -0
  40. package/dist/public-api.js.map +1 -0
  41. package/dist/reporter.d.ts +5 -0
  42. package/dist/reporter.js +119 -84
  43. package/dist/reporter.js.map +1 -1
  44. package/dist/review-health.d.ts +38 -0
  45. package/dist/review-health.js +60 -0
  46. package/dist/review-health.js.map +1 -0
  47. package/dist/rules/async.js +4 -16
  48. package/dist/rules/async.js.map +1 -1
  49. package/dist/rules/base.js +112 -87
  50. package/dist/rules/base.js.map +1 -1
  51. package/dist/rules/confidence.d.ts +2 -2
  52. package/dist/rules/confidence.js +32 -15
  53. package/dist/rules/confidence.js.map +1 -1
  54. package/dist/rules/dead-code.d.ts +2 -1
  55. package/dist/rules/dead-code.js +49 -3
  56. package/dist/rules/dead-code.js.map +1 -1
  57. package/dist/rules/index.js +131 -0
  58. package/dist/rules/index.js.map +1 -1
  59. package/dist/rules/kern-source-cross-file.d.ts +2 -0
  60. package/dist/rules/kern-source-cross-file.js +102 -0
  61. package/dist/rules/kern-source-cross-file.js.map +1 -0
  62. package/dist/rules/kern-source.js +86 -9
  63. package/dist/rules/kern-source.js.map +1 -1
  64. package/dist/rules/nextjs-app-router.js +936 -31
  65. package/dist/rules/nextjs-app-router.js.map +1 -1
  66. package/dist/rules/nextjs.js +193 -10
  67. package/dist/rules/nextjs.js.map +1 -1
  68. package/dist/rules/react-composition.js +442 -61
  69. package/dist/rules/react-composition.js.map +1 -1
  70. package/dist/rules/react-hooks.js +51 -2
  71. package/dist/rules/react-hooks.js.map +1 -1
  72. package/dist/rules/react.js +265 -49
  73. package/dist/rules/react.js.map +1 -1
  74. package/dist/rules/utils.d.ts +37 -2
  75. package/dist/rules/utils.js +113 -0
  76. package/dist/rules/utils.js.map +1 -1
  77. package/dist/semantic-diff.js +1 -1
  78. package/dist/semantic-diff.js.map +1 -1
  79. package/dist/taint-ast.js +228 -4
  80. package/dist/taint-ast.js.map +1 -1
  81. package/dist/taint-crossfile.d.ts +30 -2
  82. package/dist/taint-crossfile.js +280 -59
  83. package/dist/taint-crossfile.js.map +1 -1
  84. package/dist/taint-types.d.ts +2 -1
  85. package/dist/taint-types.js +32 -2
  86. package/dist/taint-types.js.map +1 -1
  87. package/dist/taint.d.ts +1 -1
  88. package/dist/taint.js +1 -1
  89. package/dist/taint.js.map +1 -1
  90. package/dist/types.d.ts +80 -0
  91. package/dist/types.js.map +1 -1
  92. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  */
11
11
  import { countTokens, parseWithDiagnostics, serializeIR } from '@kernlang/core';
12
12
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
13
- import { join, relative } from 'path';
13
+ import { dirname, join, relative } from 'path';
14
+ import { Project } from 'ts-morph';
14
15
  import { buildCallGraph } from './call-graph.js';
15
16
  import { runConceptRules } from './concept-rules/index.js';
16
17
  import { structuralDiff } from './differ.js';
@@ -18,19 +19,22 @@ import { runTSCDiagnostics } from './external-tools.js';
18
19
  import { buildFileContextMap } from './file-context.js';
19
20
  import { classifyFileRole } from './file-role.js';
20
21
  import { resolveImportGraph } from './graph.js';
21
- import { createInMemoryProject, inferFromSourceFile } from './inferrer.js';
22
+ import { createInMemoryProject, findTsConfig, inferFromSourceFile } from './inferrer.js';
22
23
  import { flattenIR, lintKernIR } from './kern-lint.js';
23
24
  import { extractTsConcepts } from './mappers/ts-concepts.js';
24
25
  import { mineNorms } from './norm-miner.js';
25
26
  import { synthesizeObligations } from './obligations.js';
27
+ import { buildPublicApiMap, expandPublicApiThroughReExports } from './public-api.js';
26
28
  import { runQualityRules } from './quality-rules.js';
27
29
  import { assignDefaultConfidence, calculateStats, sortAndDedup, sortFindings } from './reporter.js';
30
+ import { debugDetail, ReviewHealthBuilder } from './review-health.js';
28
31
  import { loadBuiltinNativeRules, loadNativeRules } from './rule-loader.js';
29
- import { lintConfidenceGraph } from './rules/confidence.js';
32
+ import { lintConfidenceGraph, lintMultiFileConfidenceGraph } from './rules/confidence.js';
30
33
  import { crossFileAsyncRule, deadExportRule } from './rules/dead-code.js';
31
34
  import { runFastapiConceptRules } from './rules/fastapi.js';
32
35
  import { GROUND_LAYER_RULES } from './rules/ground-layer.js';
33
- import { KERN_SOURCE_RULES, lintKernSourceIR } from './rules/kern-source.js';
36
+ import { KERN_SOURCE_RULES, lintKernSourceIR, missingConfidence } from './rules/kern-source.js';
37
+ import { lintKernSourceCrossFile } from './rules/kern-source-cross-file.js';
34
38
  import { detectTemplates } from './template-detector.js';
35
39
  // Load native .kern rules once at module init
36
40
  // Guard: import.meta.url is undefined when bundled as CJS (e.g. esbuild for VS Code worker)
@@ -54,7 +58,7 @@ export { linkToNodes, runESLint, runTSCDiagnostics, runTSCDiagnosticsFromPaths }
54
58
  export { buildFileContextMap, clearFileContextCache } from './file-context.js';
55
59
  export { classifyFileRole } from './file-role.js';
56
60
  export { resolveImportGraph } from './graph.js';
57
- export { inferFromFile, inferFromSource } from './inferrer.js';
61
+ export { findTsConfig, inferFromFile, inferFromSource } from './inferrer.js';
58
62
  // KERN-IR lint pipeline (ground layer)
59
63
  export { flattenIR, lintKernIR } from './kern-lint.js';
60
64
  // LLM bridge (Phase 3)
@@ -64,8 +68,10 @@ export { extractTsConcepts } from './mappers/ts-concepts.js';
64
68
  // Norm mining + obligations
65
69
  export { mineNorms } from './norm-miner.js';
66
70
  export { obligationsFromNorms, obligationsFromStructure, synthesizeObligations } from './obligations.js';
71
+ export { buildPublicApiMap, EMPTY_PUBLIC_API, expandPublicApiThroughReExports, isPublicApi, resolvePackageEntryFiles, resolveSpecifierToSrc, } from './public-api.js';
67
72
  export { runQualityRules } from './quality-rules.js';
68
- export { assignDefaultConfidence, calculateStats, checkEnforcement, dedup, formatEnforcement, formatReport, formatReportJSON, formatSARIF, formatSARIFWithSuppressions, formatSummary, sortAndDedup, sortFindings, } from './reporter.js';
73
+ export { assignDefaultConfidence, calculateStats, checkEnforcement, dedup, formatEnforcement, formatReport, formatReportJSON, formatSARIF, formatSARIFWithMetadata, formatSARIFWithSuppressions, formatSummary, sortAndDedup, sortFindings, } from './reporter.js';
74
+ export { debugDetail, ReviewHealthBuilder } from './review-health.js';
69
75
  export { CONFIDENCE_RULES, lintConfidenceGraph, lintMultiFileConfidenceGraph } from './rules/confidence.js';
70
76
  export { actionMissingIdempotent, assumeLowTrust, branchNonExhaustive, collectUnbounded, expectRangeInverted, GROUND_LAYER_RULES, guardWithoutElse, reasonWithoutBasis, } from './rules/ground-layer.js';
71
77
  export { getRuleRegistry } from './rules/index.js';
@@ -87,27 +93,155 @@ export { checkSpec, checkSpecFiles, extractImplRoutes, extractSpecContracts, mat
87
93
  export { clearReviewCache };
88
94
  /** Shared filesystem-backed Project for type-aware analysis (reused across reviewFile calls) */
89
95
  let _fsProject;
90
- function getOrCreateFsProject() {
96
+ let _fsProjectTsConfig;
97
+ let _fsProjectTsConfigMtimeMs;
98
+ function getOrCreateFsProject(tsConfigFilePath) {
99
+ // Rebuild when either the tsconfig path OR its contents change. Watch-mode users who edit
100
+ // compilerOptions in place would otherwise keep running with the stale Project's resolver even
101
+ // though the cache key (which hashes tsconfig content) correctly invalidates — the two must stay
102
+ // consistent or findings lag a process restart behind.
103
+ let currentMtime;
104
+ if (tsConfigFilePath) {
105
+ try {
106
+ currentMtime = statSync(tsConfigFilePath).mtimeMs;
107
+ }
108
+ catch {
109
+ // Unreadable tsconfig — fall through; we'll still attempt construction and let ts-morph surface the error.
110
+ }
111
+ }
112
+ if (_fsProject && (_fsProjectTsConfig !== tsConfigFilePath || _fsProjectTsConfigMtimeMs !== currentMtime)) {
113
+ _fsProject = undefined;
114
+ }
91
115
  if (!_fsProject) {
92
- const { Project } = require('ts-morph');
93
116
  _fsProject = new Project({
94
- compilerOptions: {
95
- strict: true,
96
- target: 99 /* Latest */,
97
- module: 99 /* ESNext */,
98
- moduleResolution: 100 /* Bundler */,
99
- skipLibCheck: true,
100
- noEmit: true,
101
- },
102
- useInMemoryFileSystem: false,
117
+ tsConfigFilePath,
103
118
  skipAddingFilesFromTsConfig: true,
119
+ useInMemoryFileSystem: false,
120
+ // When a tsconfig is loaded, let it own compilerOptions (jsx/paths/lib/allowJs come from there).
121
+ // When no tsconfig, ship permissive defaults so .tsx files don't emit phantom ts17004 errors.
122
+ compilerOptions: tsConfigFilePath
123
+ ? undefined
124
+ : {
125
+ strict: true,
126
+ target: 99 /* Latest */,
127
+ module: 99 /* ESNext */,
128
+ moduleResolution: 100 /* Bundler */,
129
+ jsx: 4 /* Preserve */,
130
+ allowJs: true,
131
+ esModuleInterop: true,
132
+ allowSyntheticDefaultImports: true,
133
+ skipLibCheck: true,
134
+ noEmit: true,
135
+ },
104
136
  });
137
+ _fsProjectTsConfig = tsConfigFilePath;
138
+ _fsProjectTsConfigMtimeMs = currentMtime;
105
139
  }
106
140
  return _fsProject;
107
141
  }
108
142
  /** Reset the shared project (for tests / watch mode) */
109
143
  export function resetFsProject() {
110
144
  _fsProject = undefined;
145
+ _fsProjectTsConfig = undefined;
146
+ _fsProjectTsConfigMtimeMs = undefined;
147
+ _fsProjectSourceMtimes.clear();
148
+ }
149
+ /**
150
+ * Refresh stale source files in the shared fs Project from disk.
151
+ *
152
+ * The singleton caches every source file it has ever loaded — including transitive imports
153
+ * followed by cross-file taint and call-graph analysis. In a long-running process (watch mode,
154
+ * IDE extension, repeated CLI invocations) those cached ASTs go stale whenever the underlying
155
+ * file changes on disk outside our own `replaceWithText` path. Cross-file findings would then
156
+ * reflect the OLD imported source, not the current one.
157
+ *
158
+ * This helper is the lightweight counterpart to resetFsProject(): instead of throwing the
159
+ * whole Project away, it stat-checks each loaded source file and calls ts-morph's
160
+ * refreshFromFileSystemSync only on the ones whose mtime moved. Use it between reviews in
161
+ * watch-mode callers. One-shot CLI runs don't need it — the process exits before stale
162
+ * reads matter.
163
+ *
164
+ * Returns the number of source files actually refreshed, so callers can log "reloaded N
165
+ * files" or decide not to re-review when the count is zero.
166
+ */
167
+ export function refreshFsProjectFromDisk() {
168
+ if (!_fsProject)
169
+ return 0;
170
+ let refreshed = 0;
171
+ for (const sf of _fsProject.getSourceFiles()) {
172
+ const path = sf.getFilePath();
173
+ let diskMtime;
174
+ try {
175
+ diskMtime = statSync(path).mtimeMs;
176
+ }
177
+ catch {
178
+ // File deleted on disk since it was loaded — skip. ts-morph will raise on next access.
179
+ continue;
180
+ }
181
+ const lastKnown = _fsProjectSourceMtimes.get(path);
182
+ if (lastKnown === diskMtime)
183
+ continue;
184
+ try {
185
+ sf.refreshFromFileSystemSync();
186
+ _fsProjectSourceMtimes.set(path, diskMtime);
187
+ refreshed++;
188
+ }
189
+ catch {
190
+ // Refresh can fail for unreadable/unparseable files — leave the stale copy rather than
191
+ // hard-crashing the review. The next resetFsProject() call will clear it either way.
192
+ }
193
+ }
194
+ return refreshed;
195
+ }
196
+ /** Per-file mtimes tracked for the shared fs Project — see refreshFsProjectFromDisk. */
197
+ const _fsProjectSourceMtimes = new Map();
198
+ /** True when the file is codegen output — detected via common path patterns or a @generated header. */
199
+ export function isGeneratedFile(filePath, source) {
200
+ // Path heuristic — covers /generated/, /__generated__/, /.generated/ anywhere in the path.
201
+ if (/[/\\](?:generated|__generated__|\.generated)[/\\]/i.test(filePath))
202
+ return true;
203
+ // Leading `// @generated` or `/* @generated */` header — the standard convention enforced by many codegens.
204
+ if (source && /^\s*(?:\/\/|\/\*)\s*@generated\b/m.test(source.slice(0, 500)))
205
+ return true;
206
+ return false;
207
+ }
208
+ /** Extensions the review engine analyzes. Anything else (.md, .json, .yaml, .patch, binaries) returns an empty report at the entry point, so callers that blindly feed changed-file lists (e.g. kern-guard on a PR diff) don't surface noise findings on docs/config files. */
209
+ const REVIEWABLE_EXTENSIONS = new Set([
210
+ '.ts',
211
+ '.tsx',
212
+ '.mts',
213
+ '.cts',
214
+ '.js',
215
+ '.jsx',
216
+ '.mjs',
217
+ '.cjs',
218
+ '.kern',
219
+ '.py',
220
+ '.vue',
221
+ ]);
222
+ export function isReviewableFile(filePath) {
223
+ const dot = filePath.lastIndexOf('.');
224
+ if (dot === -1)
225
+ return false;
226
+ const ext = filePath.slice(dot);
227
+ return REVIEWABLE_EXTENSIONS.has(ext);
228
+ }
229
+ function emptyReport(filePath) {
230
+ return {
231
+ filePath,
232
+ inferred: [],
233
+ templateMatches: [],
234
+ findings: [],
235
+ stats: {
236
+ totalLines: 0,
237
+ coveredLines: 0,
238
+ coveragePct: 0,
239
+ totalTsTokens: 0,
240
+ totalKernTokens: 0,
241
+ reductionPct: 0,
242
+ constructCount: 0,
243
+ },
244
+ };
111
245
  }
112
246
  /**
113
247
  * Review a single file. Auto-detects language from extension.
@@ -115,24 +249,53 @@ export function resetFsProject() {
115
249
  * Supports: .ts, .tsx, .py, .kern
116
250
  */
117
251
  export function reviewFile(filePath, config) {
252
+ if (!isReviewableFile(filePath))
253
+ return emptyReport(filePath);
118
254
  const source = readFileSync(filePath, 'utf-8');
255
+ // Resolve the effective tsconfig up-front so both the cache key and the ts-morph Project see the
256
+ // same path. If we only discovered it later inside reviewSourceWithProject, adding or changing the
257
+ // nearest tsconfig without editing the source would serve stale cached findings.
258
+ const effectiveConfig = config?.tsConfigFilePath || filePath.endsWith('.kern') || filePath.endsWith('.py')
259
+ ? config
260
+ : { ...(config ?? {}), tsConfigFilePath: findTsConfig(dirname(filePath)) };
119
261
  let key;
120
- if (config?.noCache !== true) {
121
- key = computeCacheKey(source, config || {}, filePath);
262
+ if (effectiveConfig?.noCache !== true) {
263
+ key = computeCacheKey(source, effectiveConfig || {}, filePath);
122
264
  const cached = reviewCache.get(key);
123
265
  if (cached)
124
266
  return cached;
125
267
  }
126
268
  let report;
127
269
  if (filePath.endsWith('.kern')) {
128
- report = reviewKernSource(source, filePath, config);
270
+ report = reviewKernSource(source, filePath, effectiveConfig);
129
271
  }
130
272
  else if (filePath.endsWith('.py')) {
131
- report = reviewPythonSource(source, filePath, config);
273
+ report = reviewPythonSource(source, filePath, effectiveConfig);
132
274
  }
133
- else {
275
+ else if (/\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/.test(filePath)) {
134
276
  // Use filesystem-backed project for real files (enables TypeChecker)
135
- report = reviewSourceWithProject(source, filePath, config);
277
+ report = reviewSourceWithProject(source, filePath, effectiveConfig);
278
+ }
279
+ else {
280
+ // Non-source file (markdown, JSON, patch, yaml, etc.) — skip review entirely
281
+ return {
282
+ filePath,
283
+ inferred: [],
284
+ templateMatches: [],
285
+ findings: [],
286
+ stats: {
287
+ totalLines: source.split('\n').length,
288
+ coveredLines: 0,
289
+ coveragePct: 0,
290
+ totalTsTokens: 0,
291
+ totalKernTokens: 0,
292
+ reductionPct: 0,
293
+ constructCount: 0,
294
+ },
295
+ };
296
+ }
297
+ if (isGeneratedFile(filePath, source)) {
298
+ report.generated = true;
136
299
  }
137
300
  if (key) {
138
301
  reviewCache.set(key, report);
@@ -145,7 +308,11 @@ export function reviewFile(filePath, config) {
145
308
  */
146
309
  function reviewSourceWithProject(source, filePath, config) {
147
310
  try {
148
- const fsProject = getOrCreateFsProject();
311
+ // Prefer explicit override from caller; otherwise discover the nearest tsconfig from this file's directory.
312
+ // Discovering per-file (not cwd) lets monorepo reviews pick up the per-package tsconfig with real paths/jsx,
313
+ // instead of the root solution-style tsconfig that only lists `references`.
314
+ const tsConfigFilePath = config?.tsConfigFilePath ?? findTsConfig(dirname(filePath));
315
+ const fsProject = getOrCreateFsProject(tsConfigFilePath);
149
316
  // Add or update the file in the project
150
317
  let sf = fsProject.getSourceFile(filePath);
151
318
  if (sf) {
@@ -154,11 +321,29 @@ function reviewSourceWithProject(source, filePath, config) {
154
321
  else {
155
322
  sf = fsProject.addSourceFileAtPath(filePath);
156
323
  }
324
+ // Track the disk mtime we just synced with — refreshFsProjectFromDisk uses this to decide
325
+ // whether the cached AST has drifted from disk on later calls. Best-effort: if stat fails
326
+ // we simply don't record a mtime (refresh will unconditionally refresh such files later).
327
+ try {
328
+ _fsProjectSourceMtimes.set(filePath, statSync(filePath).mtimeMs);
329
+ }
330
+ catch {
331
+ // File may have been deleted between read and stat; leave mtime unrecorded.
332
+ }
157
333
  return reviewSourceInternal(source, filePath, config, fsProject, sf);
158
334
  }
159
- catch {
160
- // Fallback to in-memory project if fs project fails
161
- return reviewSource(source, filePath, config);
335
+ catch (err) {
336
+ // Fs project failed — fall back to in-memory project, but record the degradation on the
337
+ // report so callers can tell this file was reviewed without full type resolution.
338
+ const report = reviewSource(source, filePath, config);
339
+ const health = new ReviewHealthBuilder();
340
+ for (const e of report.health?.entries ?? [])
341
+ health.note(e);
342
+ health.noteKind('fs-project', 'fallback', 'Fell back to in-memory ts-morph project — cross-module type resolution is limited for this file', debugDetail(err));
343
+ if (process.env.KERN_DEBUG)
344
+ console.error('fs-project failure, using in-memory fallback:', err.message);
345
+ report.health = health.build();
346
+ return report;
162
347
  }
163
348
  }
164
349
  /**
@@ -166,6 +351,8 @@ function reviewSourceWithProject(source, filePath, config) {
166
351
  * For file-from-disk review with type resolution, use reviewFile() instead.
167
352
  */
168
353
  export function reviewSource(source, filePath = 'input.ts', config) {
354
+ if (!isReviewableFile(filePath))
355
+ return emptyReport(filePath);
169
356
  const project = createInMemoryProject();
170
357
  const sourceFile = project.createSourceFile(filePath, source);
171
358
  return reviewSourceInternal(source, filePath, config, project, sourceFile);
@@ -204,8 +391,12 @@ function reviewSourceInternal(source, filePath, config, project, sourceFile) {
204
391
  }, []));
205
392
  // Phase 3: Template detection (config-aware)
206
393
  const templateMatches = safePhase('templates', () => detectTemplates(sourceFile, config), []);
207
- // Phase 4: Structural diff → unified findings
208
- allFindings.push(...safePhase('diff', () => structuralDiff(source, inferred, filePath), []));
394
+ // Phase 4: Structural diff → unified findings.
395
+ // `extra-code` and `inconsistent-pattern` are only meaningful on runtime source; on codegen/barrel/test/example
396
+ // files they produce noise (e.g. entire barrel flagged as extra-code). Keep other diff findings regardless.
397
+ const diffFindings = safePhase('diff', () => structuralDiff(source, inferred, filePath), []);
398
+ const diffNoiseRules = new Set(['extra-code', 'inconsistent-pattern']);
399
+ allFindings.push(...(fileRole === 'runtime' ? diffFindings : diffFindings.filter((f) => !diffNoiseRules.has(f.ruleId))));
209
400
  // Phase 5: Quality rules → unified findings (receives fileRole)
210
401
  allFindings.push(...safePhase('quality', () => runQualityRules(sourceFile, inferred, templateMatches, config, fileRole, project), []));
211
402
  // Phase 6: Concept extraction + concept rules (universal, cross-language)
@@ -241,8 +432,15 @@ function reviewSourceInternal(source, filePath, config, project, sourceFile) {
241
432
  }
242
433
  allFindings.push(...nativeFindings);
243
434
  }
244
- // Phase 8: TSC diagnostics — native TypeScript compiler errors
245
- allFindings.push(...safePhase('tsc', () => runTSCDiagnostics(project), []));
435
+ // Phase 8: TSC diagnostics — native TypeScript compiler errors.
436
+ // runTSCDiagnostics returns findings for every file in the shared Project, so filter down to
437
+ // just the file we're reviewing — otherwise findings-for-project-file leaks into unrelated reports.
438
+ // ts-morph normalizes filePaths (absolute, posix separators) while callers may pass relative paths,
439
+ // so compare against the sourceFile's own normalized path rather than the raw argument.
440
+ // downgradeProjectLoadingErrors: we injected this file ad-hoc into a Project that carries the
441
+ // host tsconfig, so TS6059/TS6307 are our noise, not the user's bug.
442
+ const normalizedCurrentPath = sourceFile.getFilePath();
443
+ allFindings.push(...safePhase('tsc', () => runTSCDiagnostics(project, { downgradeProjectLoadingErrors: true }), []).filter((f) => f.primarySpan.file === normalizedCurrentPath || f.primarySpan.file === filePath));
246
444
  // Build confidence graph if any nodes have confidence props
247
445
  let confidenceGraph;
248
446
  let confidenceSummary;
@@ -266,6 +464,7 @@ function reviewSourceInternal(source, filePath, config, project, sourceFile) {
266
464
  inferred,
267
465
  templateMatches,
268
466
  findings,
467
+ ...(suppression.suppressed.length > 0 ? { suppressedFindings: sortAndDedup(suppression.suppressed) } : {}),
269
468
  stats,
270
469
  ...(confidenceGraph ? { confidenceGraph } : {}),
271
470
  ...(confidenceSummary ? { confidenceSummary } : {}),
@@ -333,8 +532,13 @@ export function reviewKernSource(source, filePath = 'input.kern', _config) {
333
532
  f.primarySpan.file = filePath;
334
533
  }
335
534
  allFindings.push(...confFindings);
336
- // File-aware .kern review rules on flattened IR nodes
337
- const kernSourceFindings = safePhase('kern-source-lint', () => lintKernSourceIR(flatNodes, filePath, KERN_SOURCE_RULES), []);
535
+ // File-aware .kern review rules on flattened IR nodes.
536
+ // missing-confidence fires only when the user opts into confidence annotations — defaulting
537
+ // it on produced noise for every .kern file that didn't use the feature (see Agon kern-guard run, 2026-04-19).
538
+ const kernSourceRules = _config?.requireConfidenceAnnotations
539
+ ? KERN_SOURCE_RULES
540
+ : KERN_SOURCE_RULES.filter((r) => r !== missingConfidence);
541
+ const kernSourceFindings = safePhase('kern-source-lint', () => lintKernSourceIR(flatNodes, filePath, kernSourceRules), []);
338
542
  allFindings.push(...kernSourceFindings);
339
543
  // Native .kern rules (built-in + custom)
340
544
  const rulesToRunKern = [...NATIVE_RULES];
@@ -399,6 +603,7 @@ export function reviewKernSource(source, filePath = 'input.kern', _config) {
399
603
  inferred,
400
604
  templateMatches: [],
401
605
  findings,
606
+ ...(suppression.suppressed.length > 0 ? { suppressedFindings: sortAndDedup(suppression.suppressed) } : {}),
402
607
  stats: {
403
608
  totalLines,
404
609
  coveredLines: totalLines,
@@ -462,6 +667,7 @@ export function reviewPythonSource(source, filePath = 'input.py', config) {
462
667
  inferred: [],
463
668
  templateMatches: [],
464
669
  findings,
670
+ ...(suppression.suppressed.length > 0 ? { suppressedFindings: sortAndDedup(suppression.suppressed) } : {}),
465
671
  stats: {
466
672
  totalLines,
467
673
  coveredLines: 0,
@@ -497,9 +703,13 @@ export function reviewGraph(entryFiles, config, graphOptions) {
497
703
  const graph = resolveImportGraph(entryFiles, graphOptions);
498
704
  const entrySet = new Set(graph.entryFiles);
499
705
  const reports = [];
706
+ // Graph-wide subsystem status — one entry per (subsystem, kind) across the whole run.
707
+ // Attached to every report on return so any single ReviewReport is self-describing.
708
+ const graphHealth = new ReviewHealthBuilder();
500
709
  // Build file context map — every file gets import chain awareness
501
710
  const fileContextMap = buildFileContextMap(graph);
502
- const graphConfig = { ...config, fileContextMap };
711
+ const graphFileMap = new Map(graph.files.map((gf) => [gf.path, gf]));
712
+ const graphConfig = { ...config, fileContextMap, graphFileMap };
503
713
  for (const gf of graph.files) {
504
714
  if (!existsSync(gf.path))
505
715
  continue;
@@ -518,8 +728,14 @@ export function reviewGraph(entryFiles, config, graphOptions) {
518
728
  }
519
729
  }
520
730
  }
731
+ for (const f of report.suppressedFindings ?? []) {
732
+ f.origin = isEntry ? 'changed' : 'upstream';
733
+ f.distance = gf.distance;
734
+ }
521
735
  // Re-sort after severity mutations
522
736
  sortFindings(report.findings);
737
+ if (report.suppressedFindings)
738
+ sortFindings(report.suppressedFindings);
523
739
  reports.push(report);
524
740
  }
525
741
  catch (err) {
@@ -535,7 +751,7 @@ export function reviewGraph(entryFiles, config, graphOptions) {
535
751
  for (const gf of graph.files) {
536
752
  graphImports.set(gf.path, gf.imports);
537
753
  }
538
- const crossFileResults = analyzeTaintCrossFile(inferredPerFile, graphImports);
754
+ const crossFileResults = analyzeTaintCrossFile(inferredPerFile, graphImports, graph);
539
755
  if (crossFileResults.length > 0) {
540
756
  const crossFileFindings = crossFileTaintToFindings(crossFileResults);
541
757
  // Add cross-file findings to the caller's report, then re-run suppression
@@ -555,6 +771,32 @@ export function reviewGraph(entryFiles, config, graphOptions) {
555
771
  }
556
772
  }
557
773
  }
774
+ // Cross-file confidence analysis for KERN IR nodes across the reviewed graph.
775
+ const confidenceFileMap = new Map();
776
+ for (const report of reports) {
777
+ if (!report.filePath.endsWith('.kern'))
778
+ continue;
779
+ const nodes = report.inferred.map((r) => r.node);
780
+ if (nodes.length > 0) {
781
+ confidenceFileMap.set(report.filePath, nodes);
782
+ }
783
+ }
784
+ if (confidenceFileMap.size > 1) {
785
+ const crossFileConfidenceFindings = lintMultiFileConfidenceGraph(confidenceFileMap);
786
+ for (const finding of crossFileConfidenceFindings) {
787
+ const targetReport = reports.find((r) => r.filePath === finding.primarySpan.file);
788
+ if (targetReport) {
789
+ targetReport.findings.push(finding);
790
+ }
791
+ }
792
+ }
793
+ const crossFileKernFindings = lintKernSourceCrossFile(reports);
794
+ for (const finding of crossFileKernFindings) {
795
+ const targetReport = reports.find((r) => r.filePath === finding.primarySpan.file);
796
+ if (targetReport) {
797
+ targetReport.findings.push(finding);
798
+ }
799
+ }
558
800
  // Cross-file concept analysis — re-run concept rules with full graph context
559
801
  // This fixes false positives where guards are in middleware files and effects in handlers
560
802
  const allConcepts = new Map();
@@ -568,8 +810,11 @@ export function reviewGraph(entryFiles, config, graphOptions) {
568
810
  allConcepts.set(filePath, extractTsConcepts(sf, filePath));
569
811
  }
570
812
  }
571
- catch {
572
- // Skip files that fail concept extraction
813
+ catch (err) {
814
+ // Per-file failure record once at graph level (builder dedupes), then move on.
815
+ graphHealth.noteKind('concept-extraction', 'fallback', 'One or more files failed concept extraction — boundary/effect rules may be incomplete', debugDetail(err));
816
+ if (process.env.KERN_DEBUG)
817
+ console.error(`concept extraction failed for ${filePath}:`, err.message);
573
818
  }
574
819
  }
575
820
  if (allConcepts.size > 0) {
@@ -602,11 +847,25 @@ export function reviewGraph(entryFiles, config, graphOptions) {
602
847
  // Use provided project, or build one with all graph files loaded
603
848
  let cgProject = graphOptions?.project;
604
849
  if (!cgProject) {
605
- const { Project } = require('ts-morph');
850
+ // Fall back to discovering from the first graph file when the caller didn't supply a tsconfig.
851
+ const cgTsConfig = graphOptions?.tsConfigFilePath ?? (graph.files[0] ? findTsConfig(dirname(graph.files[0].path)) : undefined);
606
852
  cgProject = new Project({
607
- compilerOptions: { strict: true, target: 99, module: 99, moduleResolution: 100, skipLibCheck: true },
608
- useInMemoryFileSystem: false,
853
+ tsConfigFilePath: cgTsConfig,
609
854
  skipAddingFilesFromTsConfig: true,
855
+ useInMemoryFileSystem: false,
856
+ compilerOptions: cgTsConfig
857
+ ? undefined
858
+ : {
859
+ strict: true,
860
+ target: 99,
861
+ module: 99,
862
+ moduleResolution: 100,
863
+ jsx: 4 /* Preserve */,
864
+ allowJs: true,
865
+ esModuleInterop: true,
866
+ allowSyntheticDefaultImports: true,
867
+ skipLibCheck: true,
868
+ },
610
869
  });
611
870
  for (const gf of graph.files) {
612
871
  try {
@@ -618,27 +877,49 @@ export function reviewGraph(entryFiles, config, graphOptions) {
618
877
  }
619
878
  }
620
879
  const callGraph = buildCallGraph(graph, cgProject);
880
+ // Build the public-API map once per run — package.json walk is the heavy bit.
881
+ // Then propagate through re-export chains so curated barrels (Agon-style:
882
+ // `export { foo } from './worker.js'`) carry public-API status upstream.
883
+ const basePublicApi = buildPublicApiMap(graph.files.map((gf) => gf.path), config?.publicApi);
884
+ const publicApi = expandPublicApiThroughReExports(basePublicApi, (path) => cgProject?.getSourceFile(path));
621
885
  for (const report of reports) {
622
- const deadExportFindings = deadExportRule(callGraph, report.filePath);
886
+ const deadExportFindings = deadExportRule(callGraph, report.filePath, publicApi);
623
887
  report.findings.push(...deadExportFindings);
624
888
  const asyncFindings = crossFileAsyncRule(callGraph, report.filePath);
625
889
  report.findings.push(...asyncFindings);
626
890
  }
627
891
  }
628
- catch {
629
- // Call graph build failure should not crash the review pipeline
892
+ catch (err) {
893
+ // Call graph build failure must not crash the review pipeline — surface the failure on
894
+ // health so dead-export / cross-file-async rules aren't silently missing from the report.
895
+ graphHealth.noteKind('call-graph', 'error', 'Call graph build failed — dead exports and cross-file async checks are unavailable', debugDetail(err));
896
+ if (process.env.KERN_DEBUG)
897
+ console.error('call graph build error:', err.message);
630
898
  }
631
899
  // Re-run suppression + dedup on all reports (cross-file findings were injected after initial suppression)
632
900
  for (const report of reports) {
633
901
  try {
634
902
  const source = readFileSync(report.filePath, 'utf-8');
635
- const suppression = applySuppression(report.findings, source, report.filePath, config, config?.strict ?? false);
903
+ const unsuppressedCandidates = [...report.findings, ...(report.suppressedFindings ?? [])];
904
+ const suppression = applySuppression(sortAndDedup(unsuppressedCandidates), source, report.filePath, config, config?.strict ?? false);
636
905
  report.findings = sortAndDedup(suppression.findings);
906
+ report.suppressedFindings = suppression.suppressed.length > 0 ? sortAndDedup(suppression.suppressed) : undefined;
637
907
  }
638
908
  catch {
639
909
  report.findings = sortAndDedup(report.findings);
640
910
  }
641
911
  }
912
+ // Merge graph-level health into every report. Each report may already carry per-file health
913
+ // (e.g. fs-project fallback); fold those entries into the graph builder so every report sees
914
+ // the complete, deduped picture before we emit.
915
+ for (const report of reports) {
916
+ const merged = new ReviewHealthBuilder();
917
+ for (const e of report.health?.entries ?? [])
918
+ merged.note(e);
919
+ for (const e of graphHealth.build()?.entries ?? [])
920
+ merged.note(e);
921
+ report.health = merged.build();
922
+ }
642
923
  return reports;
643
924
  }
644
925
  function collectReviewableFiles(dirPath, recursive) {
@@ -656,16 +937,17 @@ function collectReviewableFiles(dirPath, recursive) {
656
937
  entry !== 'venv') {
657
938
  files.push(...collectReviewableFiles(full, true));
658
939
  }
659
- else if ((entry.endsWith('.ts') || entry.endsWith('.tsx')) &&
940
+ else if (stat.isFile() &&
941
+ (entry.endsWith('.ts') || entry.endsWith('.tsx')) &&
660
942
  !entry.endsWith('.d.ts') &&
661
943
  !entry.endsWith('.test.ts') &&
662
944
  !entry.endsWith('.test.tsx')) {
663
945
  files.push(full);
664
946
  }
665
- else if (entry.endsWith('.py') && !entry.startsWith('test_') && !entry.endsWith('_test.py')) {
947
+ else if (stat.isFile() && entry.endsWith('.py') && !entry.startsWith('test_') && !entry.endsWith('_test.py')) {
666
948
  files.push(full);
667
949
  }
668
- else if (entry.endsWith('.kern')) {
950
+ else if (stat.isFile() && entry.endsWith('.kern')) {
669
951
  files.push(full);
670
952
  }
671
953
  }