@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.
- package/dist/cache.js +140 -3
- package/dist/cache.js.map +1 -1
- package/dist/call-graph.d.ts +4 -1
- package/dist/call-graph.js +290 -25
- package/dist/call-graph.js.map +1 -1
- package/dist/concept-rules/contract-drift.d.ts +21 -0
- package/dist/concept-rules/contract-drift.js +66 -0
- package/dist/concept-rules/contract-drift.js.map +1 -0
- package/dist/concept-rules/cross-stack-utils.d.ts +50 -0
- package/dist/concept-rules/cross-stack-utils.js +98 -0
- package/dist/concept-rules/cross-stack-utils.js.map +1 -0
- package/dist/concept-rules/index.js +12 -1
- package/dist/concept-rules/index.js.map +1 -1
- package/dist/concept-rules/tainted-across-wire.d.ts +33 -0
- package/dist/concept-rules/tainted-across-wire.js +98 -0
- package/dist/concept-rules/tainted-across-wire.js.map +1 -0
- package/dist/concept-rules/untyped-api-response.d.ts +30 -0
- package/dist/concept-rules/untyped-api-response.js +71 -0
- package/dist/concept-rules/untyped-api-response.js.map +1 -0
- package/dist/external-tools.d.ts +36 -4
- package/dist/external-tools.js +79 -12
- package/dist/external-tools.js.map +1 -1
- package/dist/graph.js +149 -39
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +29 -4
- package/dist/index.js +329 -47
- package/dist/index.js.map +1 -1
- package/dist/inferrer.d.ts +5 -0
- package/dist/inferrer.js +1 -1
- package/dist/inferrer.js.map +1 -1
- package/dist/llm-bridge.d.ts +26 -1
- package/dist/llm-bridge.js +42 -6
- package/dist/llm-bridge.js.map +1 -1
- package/dist/llm-review.js +29 -11
- package/dist/llm-review.js.map +1 -1
- package/dist/mappers/ts-concepts.js +278 -7
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/dist/public-api.d.ts +73 -0
- package/dist/public-api.js +351 -0
- package/dist/public-api.js.map +1 -0
- package/dist/reporter.d.ts +5 -0
- package/dist/reporter.js +119 -84
- package/dist/reporter.js.map +1 -1
- package/dist/review-health.d.ts +38 -0
- package/dist/review-health.js +60 -0
- package/dist/review-health.js.map +1 -0
- package/dist/rules/async.js +4 -16
- package/dist/rules/async.js.map +1 -1
- package/dist/rules/base.js +112 -87
- package/dist/rules/base.js.map +1 -1
- package/dist/rules/confidence.d.ts +2 -2
- package/dist/rules/confidence.js +32 -15
- package/dist/rules/confidence.js.map +1 -1
- package/dist/rules/dead-code.d.ts +2 -1
- package/dist/rules/dead-code.js +49 -3
- package/dist/rules/dead-code.js.map +1 -1
- package/dist/rules/index.js +131 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/kern-source-cross-file.d.ts +2 -0
- package/dist/rules/kern-source-cross-file.js +102 -0
- package/dist/rules/kern-source-cross-file.js.map +1 -0
- package/dist/rules/kern-source.js +86 -9
- package/dist/rules/kern-source.js.map +1 -1
- package/dist/rules/nextjs-app-router.js +936 -31
- package/dist/rules/nextjs-app-router.js.map +1 -1
- package/dist/rules/nextjs.js +193 -10
- package/dist/rules/nextjs.js.map +1 -1
- package/dist/rules/react-composition.js +442 -61
- package/dist/rules/react-composition.js.map +1 -1
- package/dist/rules/react-hooks.js +51 -2
- package/dist/rules/react-hooks.js.map +1 -1
- package/dist/rules/react.js +265 -49
- package/dist/rules/react.js.map +1 -1
- package/dist/rules/utils.d.ts +37 -2
- package/dist/rules/utils.js +113 -0
- package/dist/rules/utils.js.map +1 -1
- package/dist/semantic-diff.js +1 -1
- package/dist/semantic-diff.js.map +1 -1
- package/dist/taint-ast.js +228 -4
- package/dist/taint-ast.js.map +1 -1
- package/dist/taint-crossfile.d.ts +30 -2
- package/dist/taint-crossfile.js +280 -59
- package/dist/taint-crossfile.js.map +1 -1
- package/dist/taint-types.d.ts +2 -1
- package/dist/taint-types.js +32 -2
- package/dist/taint-types.js.map +1 -1
- package/dist/taint.d.ts +1 -1
- package/dist/taint.js +1 -1
- package/dist/taint.js.map +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/types.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
121
|
-
key = computeCacheKey(source,
|
|
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,
|
|
270
|
+
report = reviewKernSource(source, filePath, effectiveConfig);
|
|
129
271
|
}
|
|
130
272
|
else if (filePath.endsWith('.py')) {
|
|
131
|
-
report = reviewPythonSource(source, filePath,
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
}
|