codemetrics-ai 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -5
- package/dist/analyzer.js +4 -2
- package/dist/error-handling-scoring.d.ts +1 -0
- package/dist/error-handling-scoring.js +15 -0
- package/dist/error-handling.d.ts +4 -1
- package/dist/error-handling.js +50 -17
- package/dist/failure-contracts.d.ts +19 -0
- package/dist/failure-contracts.js +261 -0
- package/dist/handler-disposition.d.ts +9 -0
- package/dist/handler-disposition.js +156 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# codemetrics-ai
|
|
2
2
|
|
|
3
|
-
Deterministic source analysis for JavaScript, TypeScript, JSX and TSX. Version 0.
|
|
3
|
+
Deterministic source analysis for JavaScript, TypeScript, JSX and TSX. Version 0.5.0 implements source metrics and React hook/effect checks and emits schema-v3 evidence, including executed scoring decisions. Scores are **uncalibrated across ecosystems**; compare them only with compatible runs of this analyzer.
|
|
4
4
|
|
|
5
5
|
Each scored dimension includes `scoringDecision`: policy inputs, executed steps, rounding and finding attribution. These are aggregate policy decisions, not independent finding deductions. See [the decision contract](../../shared/scorecard-schema/scoring-decisions.md).
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Version 0.5.0 uses `javascript-typescript-2026-10-09-handler-contracts` and adds
|
|
8
|
+
contextual Error Handling scoring. Adopting it requires a fresh baseline. See the
|
|
9
|
+
[0.5.0 release notes](../../docs/releases/javascript-typescript-0.5.0.md).
|
|
10
|
+
|
|
11
|
+
The earlier 0.4.0 ruleset `javascript-typescript-2026-10-04-local-handlers` replaces the published 0.3.0 maximum-CC/median-MI ladders with owned-function population scores and extends React-only checks with bounded promise-usage analysis. It also corrects erased TypeScript syntax in owned-body measurements, scores async usage by affected eligible owners, and adds unscored module dependency, decomposition, and error-handler evidence. Generate new baselines when upgrading from 0.3.0. See the [0.4.0 release and migration notes](https://github.com/sean-m-cooper/CodeMetrics.AI/blob/master/docs/releases/javascript-typescript-0.4.0.md).
|
|
8
12
|
|
|
9
13
|
```sh
|
|
10
14
|
npx codemetrics-ai --project package.json
|
|
@@ -22,7 +26,7 @@ Default output paths are `.scorecard/javascript-typescript/metrics.csv` and `.sc
|
|
|
22
26
|
| maintainability | 40% mean weakest ceil(N/5) function scores + 60% mean remaining |
|
|
23
27
|
| performanceAsync (Async/Blocking Usage) | React hook/effect checks, standard Promise async executors, and informational async Array.forEach callbacks |
|
|
24
28
|
| architecture | Module dependencies, explicit type-only separation, internal cycle groups and fan-in/out rankings; score remains unmeasured |
|
|
25
|
-
| errorHandling |
|
|
29
|
+
| errorHandling | Contextual failure dispositions for distinct catch/Promise handler bodies; scores unexplained swallowing only with a fully assessed population |
|
|
26
30
|
| Other dimensions | Explicitly skipped |
|
|
27
31
|
|
|
28
32
|
React checks run only when a source file imports React. Aliased named imports and namespace imports are recognized; local functions shadowing imports are excluded. Missing dependency arrays are advisory because running after every render can be intentional. The implementation does not claim exhaustive Rules of Hooks, dependency-array correctness, or general concurrency analysis. Source syntax errors make the run incomplete and source dimensions unscored. Semantic type errors are outside this AST-focused version's completeness check.
|
|
@@ -44,7 +48,9 @@ This is bounded source analysis, not promise-flow verification. Referenced execu
|
|
|
44
48
|
|
|
45
49
|
`codeQuality.componentDetails.decomposition` version 2 uses owned executable statements as its primary measure. It ranks functions/modules by statements and reports statement totals, median/p90, and module concentration. Lines and initializer/literal/declaration/re-export observations remain context. Nested bodies are counted separately. Size is not responsibility: data tables, barrels, and orchestration roles receive no inferred defect or deduction. Code Quality remains a method-complexity score.
|
|
46
50
|
|
|
47
|
-
`errorHandling.handlerEvidence`
|
|
51
|
+
`errorHandling.handlerEvidence` preserves empty/documented/nonempty syntax measurements and links each distinct implementation to its uses. Referenced Promise callbacks resolve through function declarations, const aliases, and imports within selected source. The new `dispositionEvidence` separates explicit outcomes, recognized fallback, documented intent, unexplained swallowing, and unknown paths. Nonempty code alone does not establish handling. Attached intent is honored without judging the business rationale.
|
|
52
|
+
|
|
53
|
+
When every observed body is assessed and no callback uses are unresolved, Error Handling scores `10 - 10 * unexplainedSwallowing / assessedHandlers`, capped at 9 if any issue exists, rounded half up to one decimal. Shared bodies count once. Unknown dispositions, unresolved callbacks, and absent populations yield no score. Scores describe bounded handling signals, not proven recovery or bugs. See the [policy and recognition limits](../../shared/scorecard-schema/javascript-typescript-error-handling-policy.md).
|
|
48
54
|
|
|
49
55
|
## Module architecture evidence
|
|
50
56
|
|
|
@@ -120,7 +126,7 @@ evidence. Overall dependencies and cycle findings remain intact, including mixed
|
|
|
120
126
|
files and cycles crossing the views. Architecture is still unscored. See the
|
|
121
127
|
[dependency view contract](../../shared/scorecard-schema/javascript-typescript-module-graph.md#implementation-reference-and-public-re-export-views).
|
|
122
128
|
|
|
123
|
-
`metrics.ts` owns source traversal, raw member metrics and finding identities. `function-measurements.ts` collects the separate owned-body measurements; `function-scoring.ts` applies the two population policies with exact interpolation/rounding from `score-arithmetic.ts`. `react-probe.ts` checks calls owned by each function for React hook/effect observations. `async-probe.ts` checks each visited node once for bounded standard-library promise patterns, including module-level operations. Neither probe traverses nested bodies or calculates metrics. `async-population.ts` assigns distinct eligible owners; `async-scoring.ts` applies the population ladder. `decomposition-evidence.ts` records function size and module distribution without deductions. `handler-resolution.ts` performs bounded declaration lookup; `error-handling.ts`
|
|
129
|
+
`metrics.ts` owns source traversal, raw member metrics and finding identities. `function-measurements.ts` collects the separate owned-body measurements; `function-scoring.ts` applies the two population policies with exact interpolation/rounding from `score-arithmetic.ts`. `react-probe.ts` checks calls owned by each function for React hook/effect observations. `async-probe.ts` checks each visited node once for bounded standard-library promise patterns, including module-level operations. Neither probe traverses nested bodies or calculates metrics. `async-population.ts` assigns distinct eligible owners; `async-scoring.ts` applies the population ladder. `decomposition-evidence.ts` records function size and module distribution without deductions. `handler-resolution.ts` performs bounded declaration lookup; `handler-disposition.ts` classifies bounded failure paths; `failure-contracts.ts` resolves local logging, callback, and guarded-result contracts; `error-handling.ts` aggregates bodies, uses, context, and findings; `error-handling-scoring.ts` applies the population formula. Regression fixtures preserve raw metrics and source identities; the handler policy and ruleset change is intentional.
|
|
124
130
|
|
|
125
131
|
```sh
|
|
126
132
|
npm ci
|
|
@@ -160,3 +166,13 @@ records results and limitations. Executable statements are the accepted primary
|
|
|
160
166
|
and weights remain experimental; Code Quality continues to score method complexity
|
|
161
167
|
only. The [statement calibration report](../../shared/scorecard-schema/calibration-runs/javascript-typescript-statements-2026-10-04.md)
|
|
162
168
|
records the updated preview and counting contract.
|
|
169
|
+
|
|
170
|
+
The [completed context calibration](../../shared/scorecard-schema/javascript-typescript-context-policy-decision.md)
|
|
171
|
+
retained decomposition and error handling as unscored evidence at that stage. Across 4,062 functions,
|
|
172
|
+
the large-statement candidates overlapped existing CC/MI review groups; source review
|
|
173
|
+
also found explicit fallback and declared-intent counterexamples to body-only handler
|
|
174
|
+
deductions. The released workflow was verified on all six selections and a compatible
|
|
175
|
+
Uptime Kuma rerun. Numerical experiments remain outside the analyzer and are not
|
|
176
|
+
consumer scoring policies. The subsequent [contextual handler policy](../../shared/scorecard-schema/javascript-typescript-error-handling-policy.md)
|
|
177
|
+
enables the agreed population formula only where bounded classification establishes
|
|
178
|
+
an assessable population. Decomposition remains unscored.
|
package/dist/analyzer.js
CHANGED
|
@@ -11,6 +11,7 @@ import { asyncPopulation } from "./async-population.js";
|
|
|
11
11
|
import { decompositionModule, decompositionEvidence } from "./decomposition-evidence.js";
|
|
12
12
|
import { HandlerResolver } from "./handler-resolution.js";
|
|
13
13
|
import { inspectErrorHandling, errorHandlingEvidence } from "./error-handling.js";
|
|
14
|
+
import { FailureContracts } from "./failure-contracts.js";
|
|
14
15
|
export function analyze(options, version) {
|
|
15
16
|
const identity = invocationIds(options.runId, options.auditId);
|
|
16
17
|
const discovery = discover(options.project, options.tsconfig);
|
|
@@ -30,6 +31,7 @@ export function analyze(options, version) {
|
|
|
30
31
|
const handlerSources = pkg.files.map(filename => program.getSourceFile(filename))
|
|
31
32
|
.filter((source) => !!source && !program.getSyntacticDiagnostics(source).length);
|
|
32
33
|
const handlerResolver = new HandlerResolver(checker, handlerSources, discovery.repositoryRoot, pkg.name);
|
|
34
|
+
const failureContracts = new FailureContracts(checker, program.getSourceFiles(), handlerSources, discovery.repositoryRoot);
|
|
33
35
|
const collectModules = moduleGraph.forPackage(pkg, program);
|
|
34
36
|
for (const filename of pkg.files) {
|
|
35
37
|
const source = program.getSourceFile(filename);
|
|
@@ -48,7 +50,7 @@ export function analyze(options, version) {
|
|
|
48
50
|
const file = path.relative(discovery.repositoryRoot, filename).replaceAll("\\", "/");
|
|
49
51
|
asyncOwners.push(...asyncPopulation(source, checker, file, pkg.name, result.functions, result.findings.filter(item => item.dimension === "performanceAsync").map(item => item.finding)));
|
|
50
52
|
decompositionModules.push(decompositionModule(source, file, pkg.name, result.functions));
|
|
51
|
-
errorHandling.push(inspectErrorHandling(source, checker, file, pkg.name, handlerResolver));
|
|
53
|
+
errorHandling.push(inspectErrorHandling(source, checker, file, pkg.name, handlerResolver, failureContracts));
|
|
52
54
|
metrics.push(...result.metrics);
|
|
53
55
|
collected.push(...result.findings);
|
|
54
56
|
functions.push(...result.functions);
|
|
@@ -75,7 +77,7 @@ export function analyze(options, version) {
|
|
|
75
77
|
filters: { totalUnits: discovery.packages.reduce((sum, pkg) => sum + pkg.files.length, 0) + discovery.skipped.length,
|
|
76
78
|
analyzedUnits: analyzedFiles, skipped: discovery.skipped },
|
|
77
79
|
population: { types: new Set(metrics.map(metric => `${metric.project}|${metric.file}|${metric.type}`)).size, members: metrics.length },
|
|
78
|
-
dimensions, analysis: { ...identity, status: diagnostics.length ? "incomplete" : "complete", ruleset: "javascript-typescript-2026-10-
|
|
80
|
+
dimensions, analysis: { ...identity, status: diagnostics.length ? "incomplete" : "complete", ruleset: "javascript-typescript-2026-10-09-handler-contracts",
|
|
79
81
|
calibration: "uncalibrated", configurationFingerprint: hash(JSON.stringify(canonicalConfiguration(discovery.packages.map(pkg => ({ name: pkg.name,
|
|
80
82
|
options: pkg.options, selection: pkg.selection })), discovery.repositoryRoot))), diagnostics, suppressions: [] }
|
|
81
83
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function errorHandlingDecision(total: number, unexplained: number, unknown: number, unresolvedUses: number): import("./scoring-decision.js").ScoringDecision | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { firstMatch } from "./scoring-decision.js";
|
|
2
|
+
import { fraction, roundScore } from "./score-arithmetic.js";
|
|
3
|
+
export function errorHandlingDecision(total, unexplained, unknown, unresolvedUses) {
|
|
4
|
+
if (![total, unexplained, unknown, unresolvedUses].every(value => Number.isSafeInteger(value) && value >= 0) || unexplained + unknown > total)
|
|
5
|
+
throw new Error("Invalid handler population.");
|
|
6
|
+
if (!total || unknown || unresolvedUses)
|
|
7
|
+
return undefined;
|
|
8
|
+
const populationScore = roundScore(fraction(10n * BigInt(total - unexplained), BigInt(total)));
|
|
9
|
+
const cap = unexplained ? 9 : 10;
|
|
10
|
+
return firstMatch("javascript-typescript/errorHandling/failure-disposition-v2", { countingUnit: "distinctHandlerBody", totalHandlers: total, assessedHandlers: total, unexplainedSwallowing: unexplained,
|
|
11
|
+
unknownHandlers: unknown, unresolvedCallbackUses: unresolvedUses, populationScore, cap,
|
|
12
|
+
formula: "min(10 - 10 * unexplainedSwallowing / assessedHandlers, unexplainedSwallowing > 0 ? 9 : 10)", rounding: "half-up-one-decimal" }, [{ id: "issueCap", condition: "unexplainedSwallowing > 0 and populationScore > 9", matched: unexplained > 0 && populationScore > 9,
|
|
13
|
+
score: 9, categories: ["unexplainedSwallowing"] },
|
|
14
|
+
{ id: "population", condition: "otherwise", matched: true, score: populationScore, categories: ["unexplainedSwallowing"] }]);
|
|
15
|
+
}
|
package/dist/error-handling.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { type Dimension } from "./evidence.js";
|
|
3
3
|
import { HandlerResolver } from "./handler-resolution.js";
|
|
4
|
+
import { type HandlerDisposition } from "./handler-disposition.js";
|
|
5
|
+
import type { FailureContracts } from "./failure-contracts.js";
|
|
4
6
|
type UsageKind = "catchClause" | "promiseCatch" | "promiseThenRejection";
|
|
5
7
|
interface Handler {
|
|
6
8
|
id: string;
|
|
@@ -10,6 +12,7 @@ interface Handler {
|
|
|
10
12
|
kind: "catchClause" | "promiseRejection";
|
|
11
13
|
classification: "unexplainedEmpty" | "documentedEmpty" | "containsCode";
|
|
12
14
|
comments: string[];
|
|
15
|
+
disposition: HandlerDisposition;
|
|
13
16
|
uses: {
|
|
14
17
|
file: string;
|
|
15
18
|
line: number;
|
|
@@ -17,7 +20,7 @@ interface Handler {
|
|
|
17
20
|
resolution: "inline" | "reference";
|
|
18
21
|
}[];
|
|
19
22
|
}
|
|
20
|
-
export declare function inspectErrorHandling(source: ts.SourceFile, checker: ts.TypeChecker, file: string, project: string, resolver: HandlerResolver): {
|
|
23
|
+
export declare function inspectErrorHandling(source: ts.SourceFile, checker: ts.TypeChecker, file: string, project: string, resolver: HandlerResolver, contracts: FailureContracts): {
|
|
21
24
|
handlers: Handler[];
|
|
22
25
|
uninspectedCallbacks: {
|
|
23
26
|
file: string;
|
package/dist/error-handling.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
-
import { hash } from "./evidence.js";
|
|
2
|
+
import { hash, scored } from "./evidence.js";
|
|
3
3
|
import { unwrapHandler } from "./handler-resolution.js";
|
|
4
|
+
import { classifyDisposition } from "./handler-disposition.js";
|
|
5
|
+
import { errorHandlingDecision } from "./error-handling-scoring.js";
|
|
4
6
|
function undefinedValue(node, checker) {
|
|
5
7
|
node = unwrapHandler(node);
|
|
6
8
|
if (ts.isVoidExpression(node))
|
|
@@ -17,11 +19,22 @@ function emptyBody(body, checker) {
|
|
|
17
19
|
(ts.isReturnStatement(statement) && (!statement.expression || undefinedValue(statement.expression, checker))));
|
|
18
20
|
}
|
|
19
21
|
function bodyComments(body, source) {
|
|
22
|
+
const nested = [];
|
|
23
|
+
function visit(node) {
|
|
24
|
+
if (node !== body && (ts.isFunctionLike(node) || ts.isLiteralExpression(node) || ts.isTemplateExpression(node))) {
|
|
25
|
+
nested.push({ start: ts.isFunctionLike(node) ? node.pos : node.getStart(source), end: node.end });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
ts.forEachChild(node, visit);
|
|
29
|
+
}
|
|
30
|
+
visit(body);
|
|
20
31
|
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, source.languageVariant, source.text.slice(body.pos, body.end));
|
|
21
32
|
const comments = [];
|
|
22
33
|
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
23
34
|
if (token !== ts.SyntaxKind.SingleLineCommentTrivia && token !== ts.SyntaxKind.MultiLineCommentTrivia)
|
|
24
35
|
continue;
|
|
36
|
+
if (nested.some(range => body.pos + scanner.getTokenPos() >= range.start && body.pos + scanner.getTokenPos() < range.end))
|
|
37
|
+
continue;
|
|
25
38
|
const text = scanner.getTokenText().replace(/^\/\/?\*?|\*\/$/g, "").trim();
|
|
26
39
|
if (/[\p{L}\p{N}]/u.test(text))
|
|
27
40
|
comments.push(text);
|
|
@@ -41,20 +54,24 @@ function rejectionArgument(call, checker) {
|
|
|
41
54
|
if (declaration.name.text === "then" && call.arguments.length > 1)
|
|
42
55
|
return { index: 1, kind: "promiseThenRejection" };
|
|
43
56
|
}
|
|
44
|
-
export function inspectErrorHandling(source, checker, file, project, resolver) {
|
|
57
|
+
export function inspectErrorHandling(source, checker, file, project, resolver, contracts) {
|
|
45
58
|
const handlers = new Map();
|
|
46
59
|
const uninspectedCallbacks = [];
|
|
47
60
|
function add(body, kind, at, resolution) {
|
|
48
61
|
const identity = resolver.identity(body);
|
|
49
62
|
const existing = handlers.get(identity.id);
|
|
50
63
|
const use = { file, line: source.getLineAndCharacterOfPosition(at.getStart(source)).line + 1, kind, resolution };
|
|
64
|
+
const empty = emptyBody(body, checker), comments = bodyComments(body, body.getSourceFile());
|
|
65
|
+
const disposition = classifyDisposition(body, checker, empty, comments, at, contracts);
|
|
51
66
|
if (existing) {
|
|
52
67
|
existing.uses.push(use);
|
|
68
|
+
if (existing.disposition.classification !== disposition.classification)
|
|
69
|
+
existing.disposition = { classification: "unknown", reason: "Uses of the shared handler have different failure contexts." };
|
|
53
70
|
return;
|
|
54
71
|
}
|
|
55
|
-
const empty = emptyBody(body, checker), comments = empty ? bodyComments(body, body.getSourceFile()) : [];
|
|
56
72
|
const classification = !empty ? "containsCode" : comments.length ? "documentedEmpty" : "unexplainedEmpty";
|
|
57
|
-
handlers.set(identity.id, { ...identity, kind: kind === "catchClause" ? "catchClause" : "promiseRejection", classification, comments,
|
|
73
|
+
handlers.set(identity.id, { ...identity, kind: kind === "catchClause" ? "catchClause" : "promiseRejection", classification, comments,
|
|
74
|
+
disposition, uses: [use] });
|
|
58
75
|
}
|
|
59
76
|
function visit(node) {
|
|
60
77
|
if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node))
|
|
@@ -77,19 +94,25 @@ export function inspectErrorHandling(source, checker, file, project, resolver) {
|
|
|
77
94
|
visit(source);
|
|
78
95
|
return { handlers: [...handlers.values()], uninspectedCallbacks };
|
|
79
96
|
}
|
|
80
|
-
function handlerFinding(handler) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
function handlerFinding(handler, scoreAvailable) {
|
|
98
|
+
const actionable = handler.disposition.classification === "unexplainedSwallowing";
|
|
99
|
+
const category = actionable ? "unexplainedSwallowing" : "unknownHandlerDisposition";
|
|
100
|
+
return { category, ruleId: `javascript-typescript/errorHandling/${category}`,
|
|
101
|
+
fingerprint: hash(`${handler.id}|${category}`), file: handler.file, project: handler.project, line: handler.line,
|
|
102
|
+
severity: actionable && scoreAvailable ? "warning" : "info", confidence: actionable ? "high" : "low",
|
|
103
|
+
message: handler.disposition.reason,
|
|
104
|
+
observations: { handlerId: handler.id, kind: handler.kind, uses: handler.uses, classification: handler.disposition.classification,
|
|
105
|
+
scoreDisposition: !actionable ? "excludedUnknown" : scoreAvailable ? "policyInput" : "excludedUnavailablePopulation" } };
|
|
86
106
|
}
|
|
87
107
|
export function errorHandlingEvidence(results, incomplete) {
|
|
88
108
|
const unique = new Map();
|
|
89
109
|
for (const row of results.flatMap(result => result.handlers)) {
|
|
90
110
|
const existing = unique.get(row.id);
|
|
91
|
-
if (existing)
|
|
111
|
+
if (existing) {
|
|
92
112
|
existing.uses.push(...row.uses);
|
|
113
|
+
if (existing.disposition.classification !== row.disposition.classification)
|
|
114
|
+
existing.disposition = { classification: "unknown", reason: "Selected project contexts disagree about the shared handler disposition." };
|
|
115
|
+
}
|
|
93
116
|
else
|
|
94
117
|
unique.set(row.id, { ...row, uses: [...row.uses] });
|
|
95
118
|
}
|
|
@@ -100,12 +123,22 @@ export function errorHandlingEvidence(results, incomplete) {
|
|
|
100
123
|
.sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line);
|
|
101
124
|
const count = (classification) => handlers.filter(handler => handler.classification === classification).length;
|
|
102
125
|
const unexplained = count("unexplainedEmpty");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
const dispositions = Object.fromEntries(["propagated", "failureResult", "reported", "fallback", "documented", "unexplainedSwallowing", "unknown"]
|
|
127
|
+
.map(kind => [kind, handlers.filter(handler => handler.disposition.classification === kind).length]));
|
|
128
|
+
const decision = incomplete ? undefined : errorHandlingDecision(handlers.length, dispositions.unexplainedSwallowing, dispositions.unknown, uninspectedCallbacks.length);
|
|
129
|
+
const findings = handlers.filter(handler => ["unexplainedSwallowing", "unknown"].includes(handler.disposition.classification))
|
|
130
|
+
.map(handler => handlerFinding(handler, !!decision));
|
|
131
|
+
const scope = { id: "javascript-typescript/errorHandling/failure-disposition-v2", coverage: "partial",
|
|
132
|
+
includes: ["catch-clauses", "inline-standard-promise-rejection-handlers", "attached-intent", "selected-source-referenced-handlers", "bounded-failure-continuations"],
|
|
133
|
+
excludes: ["general-exception-flow", "arbitrary-callback-value-flow", "correctness-of-recovery", "semantic-rationale-judgment"] };
|
|
134
|
+
const dispositionEvidence = { version: 1, countingUnit: "distinctHandlerBody", totalHandlers: handlers.length,
|
|
135
|
+
assessedHandlers: handlers.length - dispositions.unknown, unknownHandlers: dispositions.unknown, counts: dispositions,
|
|
136
|
+
assessedPercent: handlers.length ? 100 * (handlers.length - dispositions.unknown) / handlers.length : null,
|
|
137
|
+
unresolvedCallbackUses: uninspectedCallbacks.length, populationComplete: !incomplete && !dispositions.unknown && !uninspectedCallbacks.length };
|
|
138
|
+
const dimension = decision ? scored(decision, "Partial Error Handling: unexplained swallowing among distinct assessed handler bodies. Documented intent is honored; failure outcomes are signals, not proof of correct recovery.", findings, dispositionEvidence) : { status: incomplete ? "failed" : "skipped", findings,
|
|
139
|
+
basis: incomplete ? "Incomplete source; handler observations are diagnostic only." : !handlers.length ? "No assessed handler population; error handling is unavailable." :
|
|
140
|
+
"Unknown handler dispositions or unresolved callback uses prevent a reliable population score." };
|
|
141
|
+
return { ...dimension, scope, dispositionEvidence, handlerEvidence: { version: 2, countingUnit: "distinctHandlerBody",
|
|
109
142
|
totalHandlers: handlers.length, unexplainedEmptyHandlers: unexplained, documentedEmptyHandlers: count("documentedEmpty"),
|
|
110
143
|
handlersContainingCode: count("containsCode"), unexplainedEmptyPercent: handlers.length ? 100 * unexplained / handlers.length : null,
|
|
111
144
|
handlerUseSites: handlers.reduce((sum, handler) => sum + handler.uses.length, 0),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export declare function standardConsole(call: ts.CallExpression, checker: ts.TypeChecker): boolean;
|
|
3
|
+
/** Source-backed contracts, not name-based exemptions or a runtime data-flow proof. */
|
|
4
|
+
export declare class FailureContracts {
|
|
5
|
+
private readonly checker;
|
|
6
|
+
private readonly selected;
|
|
7
|
+
private readonly references;
|
|
8
|
+
private readonly writes;
|
|
9
|
+
private readonly local;
|
|
10
|
+
constructor(checker: ts.TypeChecker, sources: readonly ts.SourceFile[], selected: readonly ts.SourceFile[], root: string);
|
|
11
|
+
forwardsCaughtError(call: ts.CallExpression): boolean;
|
|
12
|
+
explicitResult(expression: ts.Expression | undefined, depth?: number): boolean;
|
|
13
|
+
reports(call: ts.CallExpression): boolean;
|
|
14
|
+
private reachesConsole;
|
|
15
|
+
guardedUndefinedResult(body: ts.ConciseBody): boolean;
|
|
16
|
+
private commonJsFile;
|
|
17
|
+
private guardedCallResult;
|
|
18
|
+
private truthyGuard;
|
|
19
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exclusion } from "./discovery.js";
|
|
4
|
+
import { unwrapHandler } from "./handler-resolution.js";
|
|
5
|
+
export function standardConsole(call, checker) {
|
|
6
|
+
const member = call.expression;
|
|
7
|
+
if (!ts.isPropertyAccessExpression(member) || !["error", "warn", "info", "log", "debug", "trace"].includes(member.name.text) ||
|
|
8
|
+
!ts.isIdentifier(member.expression) || member.expression.text !== "console" || !call.arguments.length || call.questionDotToken || member.questionDotToken)
|
|
9
|
+
return false;
|
|
10
|
+
const declarations = checker.getSymbolAtLocation(member.expression)?.declarations ?? [];
|
|
11
|
+
return declarations.length > 0 && declarations.every(declaration => declaration.getSourceFile().isDeclarationFile &&
|
|
12
|
+
/(?:lib\.dom\.d\.ts|[/\\]@types[/\\]node[/\\](?:web-globals[/\\])?(?:console|globals)\.d\.ts)$/.test(declaration.getSourceFile().fileName));
|
|
13
|
+
}
|
|
14
|
+
const descendants = (node, match) => match(node) || (ts.forEachChild(node, child => descendants(child, match)) ?? false);
|
|
15
|
+
function enclosingFunction(node) {
|
|
16
|
+
for (let at = node.parent; at; at = at.parent)
|
|
17
|
+
if (ts.isFunctionLike(at))
|
|
18
|
+
return at;
|
|
19
|
+
}
|
|
20
|
+
function documentation(node) {
|
|
21
|
+
return (ts.getLeadingCommentRanges(node.getSourceFile().text, node.getFullStart()) ?? [])
|
|
22
|
+
.map(range => node.getSourceFile().text.slice(range.pos, range.end)).join("\n");
|
|
23
|
+
}
|
|
24
|
+
/** Source-backed contracts, not name-based exemptions or a runtime data-flow proof. */
|
|
25
|
+
export class FailureContracts {
|
|
26
|
+
checker;
|
|
27
|
+
selected;
|
|
28
|
+
references = new Map();
|
|
29
|
+
writes = new Set();
|
|
30
|
+
local = new Set();
|
|
31
|
+
constructor(checker, sources, selected, root) {
|
|
32
|
+
this.checker = checker;
|
|
33
|
+
this.selected = selected;
|
|
34
|
+
for (const source of sources) {
|
|
35
|
+
const relative = path.relative(root, source.fileName).replaceAll("\\", "/");
|
|
36
|
+
if (source.isDeclarationFile || relative.startsWith("../") || exclusion(relative))
|
|
37
|
+
continue;
|
|
38
|
+
this.local.add(source);
|
|
39
|
+
const visit = (node) => {
|
|
40
|
+
if (ts.isIdentifier(node)) {
|
|
41
|
+
const symbol = ts.isShorthandPropertyAssignment(node.parent)
|
|
42
|
+
? checker.getShorthandAssignmentValueSymbol(node.parent) : checker.getSymbolAtLocation(node);
|
|
43
|
+
if (symbol) {
|
|
44
|
+
const refs = this.references.get(symbol) ?? [];
|
|
45
|
+
refs.push(node);
|
|
46
|
+
this.references.set(symbol, refs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
|
50
|
+
const record = (target) => {
|
|
51
|
+
if (ts.isIdentifier(target) || ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) {
|
|
52
|
+
const symbol = checker.getSymbolAtLocation(target);
|
|
53
|
+
if (symbol)
|
|
54
|
+
this.writes.add(symbol);
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
ts.forEachChild(target, record);
|
|
58
|
+
};
|
|
59
|
+
record(node.left);
|
|
60
|
+
}
|
|
61
|
+
if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
62
|
+
[ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)) {
|
|
63
|
+
const symbol = checker.getSymbolAtLocation(node.operand);
|
|
64
|
+
if (symbol)
|
|
65
|
+
this.writes.add(symbol);
|
|
66
|
+
}
|
|
67
|
+
ts.forEachChild(node, visit);
|
|
68
|
+
};
|
|
69
|
+
visit(source);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
forwardsCaughtError(call) {
|
|
73
|
+
if (call.questionDotToken || !ts.isIdentifier(call.expression) || !call.arguments.length)
|
|
74
|
+
return false;
|
|
75
|
+
const target = this.checker.getSymbolAtLocation(call.expression), error = unwrapHandler(call.arguments[0]);
|
|
76
|
+
const declaration = target?.declarations?.[0];
|
|
77
|
+
if (!target || this.writes.has(target) || !declaration || !ts.isParameter(declaration) || declaration.initializer ||
|
|
78
|
+
declaration.getSourceFile() !== call.getSourceFile() || call.pos < declaration.parent.pos || call.end > declaration.parent.end)
|
|
79
|
+
return false;
|
|
80
|
+
const caughtSymbol = (symbol) => {
|
|
81
|
+
const binding = symbol?.declarations?.[0];
|
|
82
|
+
return !!symbol && !!binding && ts.isVariableDeclaration(binding) && ts.isCatchClause(binding.parent) &&
|
|
83
|
+
!this.writes.has(symbol) && call.pos >= binding.parent.block.pos && call.end <= binding.parent.block.end;
|
|
84
|
+
};
|
|
85
|
+
// Only plain payload expressions, never an unrelated call taking the error.
|
|
86
|
+
const payload = (node) => {
|
|
87
|
+
if (ts.isIdentifier(node) && caughtSymbol(this.checker.getSymbolAtLocation(node)))
|
|
88
|
+
return true;
|
|
89
|
+
if (ts.isPropertyAccessExpression(node))
|
|
90
|
+
return payload(node.expression);
|
|
91
|
+
if (ts.isObjectLiteralExpression(node))
|
|
92
|
+
return node.properties.some(property => ts.isPropertyAssignment(property) ? payload(property.initializer) : ts.isShorthandPropertyAssignment(property) &&
|
|
93
|
+
caughtSymbol(this.checker.getShorthandAssignmentValueSymbol(property)));
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
return payload(error);
|
|
97
|
+
}
|
|
98
|
+
explicitResult(expression, depth = 0) {
|
|
99
|
+
if (!expression || depth > 4)
|
|
100
|
+
return false;
|
|
101
|
+
expression = unwrapHandler(expression);
|
|
102
|
+
if (ts.isObjectLiteralExpression(expression) || ts.isArrayLiteralExpression(expression) || ts.isNewExpression(expression))
|
|
103
|
+
return true;
|
|
104
|
+
if (!ts.isIdentifier(expression))
|
|
105
|
+
return false;
|
|
106
|
+
const symbol = this.checker.getSymbolAtLocation(expression), declarations = symbol?.declarations ?? [];
|
|
107
|
+
if (!symbol || this.writes.has(symbol) || declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]))
|
|
108
|
+
return false;
|
|
109
|
+
const declaration = declarations[0];
|
|
110
|
+
if (!ts.isVariableDeclarationList(declaration.parent) || !(declaration.parent.flags & ts.NodeFlags.Const) || !declaration.initializer)
|
|
111
|
+
return false;
|
|
112
|
+
const value = unwrapHandler(declaration.initializer);
|
|
113
|
+
return ts.isLiteralExpression(value) || [ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(value.kind) || this.explicitResult(value, depth + 1);
|
|
114
|
+
}
|
|
115
|
+
reports(call) {
|
|
116
|
+
return this.reachesConsole(call, new Set(), 0);
|
|
117
|
+
}
|
|
118
|
+
reachesConsole(call, seen, depth, payloadPositions = new Set(call.arguments.map((_, index) => index))) {
|
|
119
|
+
if (call.questionDotToken || (ts.isPropertyAccessExpression(call.expression) && call.expression.questionDotToken))
|
|
120
|
+
return false;
|
|
121
|
+
if (standardConsole(call, this.checker))
|
|
122
|
+
return true;
|
|
123
|
+
const symbol = this.checker.getSymbolAtLocation(call.expression);
|
|
124
|
+
if (symbol && this.writes.has(symbol))
|
|
125
|
+
return false;
|
|
126
|
+
if (ts.isPropertyAccessExpression(call.expression) && ts.isIdentifier(call.expression.expression)) {
|
|
127
|
+
const receiver = this.checker.getSymbolAtLocation(call.expression.expression);
|
|
128
|
+
if (receiver && this.writes.has(receiver))
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const declaration = this.checker.getResolvedSignature(call)?.declaration;
|
|
132
|
+
if (!declaration || depth >= 3 || seen.has(declaration) || !this.local.has(declaration.getSourceFile()) ||
|
|
133
|
+
!(ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration) || ts.isFunctionExpression(declaration) || ts.isArrowFunction(declaration)) || !declaration.body)
|
|
134
|
+
return false;
|
|
135
|
+
seen = new Set(seen).add(declaration);
|
|
136
|
+
const parameters = new Set(declaration.parameters.filter((parameter, index) => payloadPositions.has(index) || parameter.dotDotDotToken && [...payloadPositions].some(position => position >= index))
|
|
137
|
+
.map(parameter => this.checker.getSymbolAtLocation(parameter.name)).filter(symbol => symbol && !this.writes.has(symbol)));
|
|
138
|
+
const declaredLogger = /\b(?:log|write)\b[^\n]*\b(?:message|exception|error|warn)\b/i.test(documentation(declaration));
|
|
139
|
+
// Ordinary wrappers require an unconditional forwarding call. A documented
|
|
140
|
+
// logger may have runtime level filters; prove its payload reaches a console sink.
|
|
141
|
+
const candidates = [];
|
|
142
|
+
const visit = (node) => {
|
|
143
|
+
if (node !== declaration.body && ts.isFunctionLike(node))
|
|
144
|
+
return;
|
|
145
|
+
if (ts.isCallExpression(node))
|
|
146
|
+
candidates.push(node);
|
|
147
|
+
ts.forEachChild(node, visit);
|
|
148
|
+
};
|
|
149
|
+
if (declaredLogger)
|
|
150
|
+
visit(declaration.body);
|
|
151
|
+
else if (ts.isBlock(declaration.body)) {
|
|
152
|
+
for (const statement of declaration.body.statements) {
|
|
153
|
+
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression))
|
|
154
|
+
candidates.push(statement.expression);
|
|
155
|
+
else if (ts.isReturnStatement(statement) && statement.expression && ts.isCallExpression(statement.expression)) {
|
|
156
|
+
candidates.push(statement.expression);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
else if (!ts.isVariableStatement(statement) && !ts.isEmptyStatement(statement))
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else if (ts.isCallExpression(declaration.body))
|
|
164
|
+
candidates.push(declaration.body);
|
|
165
|
+
return candidates.some(candidate => {
|
|
166
|
+
const forwarded = new Set(candidate.arguments.flatMap((arg, index) => descendants(arg, node => ts.isIdentifier(node) && parameters.has(this.checker.getSymbolAtLocation(node))) ? [index] : []));
|
|
167
|
+
return forwarded.size > 0 && this.reachesConsole(candidate, seen, depth + 1, forwarded);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
guardedUndefinedResult(body) {
|
|
171
|
+
const fn = enclosingFunction(body);
|
|
172
|
+
if (!fn || !ts.isFunctionDeclaration(fn) || !fn.name || ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Export)
|
|
173
|
+
return false;
|
|
174
|
+
const symbol = this.checker.getSymbolAtLocation(fn.name);
|
|
175
|
+
if (!symbol || this.writes.has(symbol))
|
|
176
|
+
return false;
|
|
177
|
+
const module = this.checker.getSymbolAtLocation(fn.getSourceFile());
|
|
178
|
+
if (!module && !this.commonJsFile(fn.getSourceFile()) && !enclosingFunction(fn))
|
|
179
|
+
return false;
|
|
180
|
+
if (module && this.checker.getExportsOfModule(module).some(exported => (exported.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(exported) : exported) === symbol))
|
|
181
|
+
return false;
|
|
182
|
+
const uses = (this.references.get(symbol) ?? []).filter(node => node !== fn.name);
|
|
183
|
+
return uses.length > 0 && uses.every(node => this.selected.includes(node.getSourceFile()) &&
|
|
184
|
+
ts.isCallExpression(node.parent) && node.parent.expression === node && this.guardedCallResult(node.parent));
|
|
185
|
+
}
|
|
186
|
+
commonJsFile(source) {
|
|
187
|
+
const exportRoot = (node) => {
|
|
188
|
+
if (!ts.isPropertyAccessExpression(node))
|
|
189
|
+
return;
|
|
190
|
+
if (ts.isIdentifier(node.expression)) {
|
|
191
|
+
const base = node.expression;
|
|
192
|
+
return base.text === "exports" || base.text === "module" && node.name.text === "exports" ? base : undefined;
|
|
193
|
+
}
|
|
194
|
+
return exportRoot(node.expression);
|
|
195
|
+
};
|
|
196
|
+
const inferredExport = (node) => {
|
|
197
|
+
// Depending on the assignments present, the JS binder uses either the
|
|
198
|
+
// assignment or its root identifier as the inferred module declaration.
|
|
199
|
+
while (ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node)
|
|
200
|
+
node = node.parent;
|
|
201
|
+
if (ts.isBinaryExpression(node.parent) && node.parent.left === node)
|
|
202
|
+
node = node.parent;
|
|
203
|
+
return ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && !!exportRoot(node.left);
|
|
204
|
+
};
|
|
205
|
+
return source.statements.some(statement => {
|
|
206
|
+
if (!ts.isExpressionStatement(statement) || !ts.isBinaryExpression(statement.expression) ||
|
|
207
|
+
statement.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
|
|
208
|
+
return false;
|
|
209
|
+
const base = exportRoot(statement.expression.left);
|
|
210
|
+
if (!base)
|
|
211
|
+
return false;
|
|
212
|
+
const declarations = this.checker.getSymbolAtLocation(base)?.declarations ?? [];
|
|
213
|
+
// TypeScript binds bare CommonJS `module` to its export assignment when
|
|
214
|
+
// Node declarations are absent; that inferred declaration is not shadowing.
|
|
215
|
+
return declarations.every(declaration => inferredExport(declaration) ||
|
|
216
|
+
declaration.getSourceFile().isDeclarationFile && /[/\\]@types[/\\]node[/\\]/.test(declaration.getSourceFile().fileName));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
guardedCallResult(call) {
|
|
220
|
+
const parent = call.parent;
|
|
221
|
+
let identifier, statement;
|
|
222
|
+
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
223
|
+
identifier = parent.name;
|
|
224
|
+
statement = parent.parent.parent;
|
|
225
|
+
}
|
|
226
|
+
else if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(parent.left)) {
|
|
227
|
+
identifier = parent.left;
|
|
228
|
+
statement = parent.parent;
|
|
229
|
+
}
|
|
230
|
+
else
|
|
231
|
+
return false;
|
|
232
|
+
if (!ts.isBlock(statement.parent))
|
|
233
|
+
return false;
|
|
234
|
+
const next = statement.parent.statements[statement.parent.statements.indexOf(statement) + 1];
|
|
235
|
+
const symbol = this.checker.getSymbolAtLocation(identifier);
|
|
236
|
+
if (!symbol || !next || !ts.isIfStatement(next) || !this.truthyGuard(next.expression, symbol))
|
|
237
|
+
return false;
|
|
238
|
+
return (this.references.get(symbol) ?? []).every(node => {
|
|
239
|
+
if (ts.isVariableDeclaration(node.parent) && node.parent.name === node)
|
|
240
|
+
return true;
|
|
241
|
+
if (ts.isBinaryExpression(node.parent) && node.parent.left === node && node.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
242
|
+
return ts.isCallExpression(node.parent.right) && this.checker.getSymbolAtLocation(node.parent.right.expression) === this.checker.getSymbolAtLocation(call.expression) &&
|
|
243
|
+
node.parent.parent.parent === statement.parent;
|
|
244
|
+
if (ts.isPrefixUnaryExpression(node.parent) || ts.isPostfixUnaryExpression(node.parent))
|
|
245
|
+
return false;
|
|
246
|
+
for (let at = node; at.parent && !ts.isFunctionLike(at.parent); at = at.parent) {
|
|
247
|
+
const parent = at.parent;
|
|
248
|
+
if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && this.truthyGuard(parent.left, symbol))
|
|
249
|
+
return true;
|
|
250
|
+
if (ts.isIfStatement(parent) && (parent.expression === at || parent.thenStatement === at) && this.truthyGuard(parent.expression, symbol))
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
truthyGuard(expression, symbol) {
|
|
257
|
+
expression = unwrapHandler(expression);
|
|
258
|
+
return ts.isIdentifier(expression) ? this.checker.getSymbolAtLocation(expression) === symbol :
|
|
259
|
+
ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && this.truthyGuard(expression.left, symbol);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { FailureContracts } from "./failure-contracts.js";
|
|
3
|
+
export type DispositionKind = "propagated" | "failureResult" | "reported" | "fallback" | "documented" | "unexplainedSwallowing" | "unknown";
|
|
4
|
+
export interface HandlerDisposition {
|
|
5
|
+
classification: DispositionKind;
|
|
6
|
+
reason: string;
|
|
7
|
+
comments?: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function classifyDisposition(body: ts.ConciseBody, checker: ts.TypeChecker, empty: boolean, comments: string[], use: ts.Node, contracts: FailureContracts): HandlerDisposition;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { unwrapHandler } from "./handler-resolution.js";
|
|
3
|
+
const result = (classification, reason) => ({ classification, reason });
|
|
4
|
+
// These are declarations of intent, not a judgment about whether the rationale is good.
|
|
5
|
+
const intentional = /\b(?:ignor(?:e|ed|ing)|swallow(?:ed|ing)?|discard(?:ed|ing)?|optional|best[- ]effort|does nothing|no[- ]op|intention(?:al|ally))\b/i;
|
|
6
|
+
function leadingComments(node) {
|
|
7
|
+
const source = node.getSourceFile();
|
|
8
|
+
return (ts.getLeadingCommentRanges(source.text, node.getFullStart()) ?? [])
|
|
9
|
+
.map(range => source.text.slice(range.pos, range.end)).filter(text => intentional.test(text));
|
|
10
|
+
}
|
|
11
|
+
function declaredIntent(body, checker) {
|
|
12
|
+
const owner = body.parent;
|
|
13
|
+
if (ts.isCatchClause(owner))
|
|
14
|
+
return [...leadingComments(owner), ...leadingComments(owner.parent)];
|
|
15
|
+
if (ts.isFunctionDeclaration(owner)) {
|
|
16
|
+
const declarations = owner.name ? checker.getSymbolAtLocation(owner.name)?.declarations ?? [owner] : [owner];
|
|
17
|
+
return declarations.flatMap(leadingComments);
|
|
18
|
+
}
|
|
19
|
+
if ((ts.isArrowFunction(owner) || ts.isFunctionExpression(owner)) && ts.isVariableDeclaration(owner.parent)) {
|
|
20
|
+
const list = owner.parent.parent;
|
|
21
|
+
// A comment on a multi-binding declaration cannot identify one handler's intent.
|
|
22
|
+
if (ts.isVariableDeclarationList(list) && list.declarations.length === 1)
|
|
23
|
+
return leadingComments(list.parent);
|
|
24
|
+
}
|
|
25
|
+
return leadingComments(owner);
|
|
26
|
+
}
|
|
27
|
+
function explicitValue(expression) {
|
|
28
|
+
if (!expression)
|
|
29
|
+
return false;
|
|
30
|
+
const node = unwrapHandler(expression);
|
|
31
|
+
// No names, calls, spreads or property reads: their value/side effects need more flow analysis.
|
|
32
|
+
if (ts.isLiteralExpression(node) || [ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(node.kind))
|
|
33
|
+
return true;
|
|
34
|
+
if (ts.isPrefixUnaryExpression(node))
|
|
35
|
+
return ts.isNumericLiteral(node.operand);
|
|
36
|
+
if (ts.isArrayLiteralExpression(node))
|
|
37
|
+
return node.elements.every(element => explicitValue(element));
|
|
38
|
+
return ts.isObjectLiteralExpression(node) && node.properties.every(property => ts.isPropertyAssignment(property) &&
|
|
39
|
+
!ts.isComputedPropertyName(property.name) && explicitValue(property.initializer));
|
|
40
|
+
}
|
|
41
|
+
function statementOutcome(statement, checker, contracts) {
|
|
42
|
+
if (ts.isThrowStatement(statement))
|
|
43
|
+
return result("propagated", "Unconditional throw on the failure path.");
|
|
44
|
+
if (ts.isReturnStatement(statement))
|
|
45
|
+
return explicitValue(statement.expression) || contracts.explicitResult(statement.expression)
|
|
46
|
+
? result("failureResult", "Returns an explicit value/container on the failure path; suitability and caller correctness are not judged.")
|
|
47
|
+
: result("unknown", "Return value or caller contract requires additional analysis.");
|
|
48
|
+
if (ts.isBlock(statement))
|
|
49
|
+
return blockOutcome(statement.statements, checker, contracts);
|
|
50
|
+
if (ts.isIfStatement(statement)) {
|
|
51
|
+
const yes = statementOutcome(statement.thenStatement, checker, contracts);
|
|
52
|
+
const no = statement.elseStatement && statementOutcome(statement.elseStatement, checker, contracts);
|
|
53
|
+
if (yes && no && ![yes.classification, no.classification].includes("unknown"))
|
|
54
|
+
return result(yes.classification === no.classification ? yes.classification : "failureResult", "Both branches have an explicit failure disposition.");
|
|
55
|
+
return result("unknown", "Conditional failure path is not fully resolved.");
|
|
56
|
+
}
|
|
57
|
+
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression)) {
|
|
58
|
+
if (contracts.forwardsCaughtError(statement.expression))
|
|
59
|
+
return result("propagated", "Passes the caught error to a caller-supplied callback; consumer correctness is outside scope.");
|
|
60
|
+
if (contracts.reports(statement.expression))
|
|
61
|
+
return result("reported", "Standard console output or a resolved source-backed logging wrapper; runtime filtering is outside scope.");
|
|
62
|
+
}
|
|
63
|
+
if (ts.isExpressionStatement(statement) || ts.isVariableStatement(statement) || ts.isEmptyStatement(statement) ||
|
|
64
|
+
ts.isFunctionDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement))
|
|
65
|
+
return;
|
|
66
|
+
return result("unknown", "Control flow is outside the bounded handler classifier.");
|
|
67
|
+
}
|
|
68
|
+
function blockOutcome(statements, checker, contracts) {
|
|
69
|
+
for (const statement of statements) {
|
|
70
|
+
const outcome = statementOutcome(statement, checker, contracts);
|
|
71
|
+
if (outcome)
|
|
72
|
+
return outcome;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function containsReturn(node) {
|
|
76
|
+
if (ts.isReturnStatement(node))
|
|
77
|
+
return true;
|
|
78
|
+
if (ts.isFunctionLike(node))
|
|
79
|
+
return false;
|
|
80
|
+
return ts.forEachChild(node, containsReturn) ?? false;
|
|
81
|
+
}
|
|
82
|
+
function guardedFallback(statement, next, checker, contracts) {
|
|
83
|
+
if (!next || !ts.isIfStatement(next) || statement.tryBlock.statements.length !== 1)
|
|
84
|
+
return false;
|
|
85
|
+
const assignment = statement.tryBlock.statements[0];
|
|
86
|
+
if (!ts.isExpressionStatement(assignment) || !ts.isBinaryExpression(assignment.expression) ||
|
|
87
|
+
assignment.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isIdentifier(assignment.expression.left))
|
|
88
|
+
return false;
|
|
89
|
+
const symbol = checker.getSymbolAtLocation(assignment.expression.left);
|
|
90
|
+
const declarations = symbol?.declarations ?? [];
|
|
91
|
+
if (declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]) || declarations[0].initializer)
|
|
92
|
+
return false;
|
|
93
|
+
const refersToBinding = (node) => (ts.isIdentifier(node) && checker.getSymbolAtLocation(node) === symbol) ||
|
|
94
|
+
(ts.forEachChild(node, refersToBinding) ?? false);
|
|
95
|
+
if (refersToBinding(assignment.expression.right))
|
|
96
|
+
return false;
|
|
97
|
+
// Require the uninitialized binding immediately before the try: no earlier assigned state.
|
|
98
|
+
const parent = statement.parent;
|
|
99
|
+
if (!ts.isBlock(parent) && !ts.isSourceFile(parent))
|
|
100
|
+
return false;
|
|
101
|
+
const previous = parent.statements[parent.statements.indexOf(statement) - 1];
|
|
102
|
+
if (!previous || !ts.isVariableStatement(previous) || previous.declarationList.declarations.length !== 1 ||
|
|
103
|
+
!(previous.declarationList.flags & ts.NodeFlags.Let) || previous.declarationList.declarations[0] !== declarations[0])
|
|
104
|
+
return false;
|
|
105
|
+
const condition = unwrapHandler(next.expression);
|
|
106
|
+
if (!ts.isBinaryExpression(condition) || condition.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken)
|
|
107
|
+
return false;
|
|
108
|
+
const left = unwrapHandler(condition.left), right = unwrapHandler(condition.right);
|
|
109
|
+
if (!ts.isIdentifier(left) || checker.getSymbolAtLocation(left) !== symbol || !ts.isIdentifier(right) || right.text !== "undefined")
|
|
110
|
+
return false;
|
|
111
|
+
const undefinedDeclarations = checker.getSymbolAtLocation(right)?.declarations ?? [];
|
|
112
|
+
if (undefinedDeclarations.some(declaration => !declaration.getSourceFile().hasNoDefaultLib))
|
|
113
|
+
return false;
|
|
114
|
+
const outcome = statementOutcome(next.thenStatement, checker, contracts);
|
|
115
|
+
return !!outcome && ["failureResult", "propagated", "reported"].includes(outcome.classification);
|
|
116
|
+
}
|
|
117
|
+
export function classifyDisposition(body, checker, empty, comments, use, contracts) {
|
|
118
|
+
const owner = body.parent;
|
|
119
|
+
// Finally may replace a throw/return, even when the catch itself is explicit.
|
|
120
|
+
if (ts.isCatchClause(owner)) {
|
|
121
|
+
for (let ancestor = owner.parent; ancestor && !ts.isFunctionLike(ancestor); ancestor = ancestor.parent)
|
|
122
|
+
if (ts.isTryStatement(ancestor) && ancestor.finallyBlock)
|
|
123
|
+
return result("unknown", "Finally may change the failure disposition.");
|
|
124
|
+
}
|
|
125
|
+
const intent = [...(empty ? comments : comments.filter(comment => intentional.test(comment))), ...declaredIntent(body, checker)];
|
|
126
|
+
if (intent.length)
|
|
127
|
+
return { ...result("documented", "Attached developer intent is honored without judging the business decision."), comments: intent };
|
|
128
|
+
if (!empty) {
|
|
129
|
+
const outcome = ts.isBlock(body) ? blockOutcome(body.statements, checker, contracts) : explicitValue(body) || contracts.explicitResult(body) ? result("failureResult", "Explicit value/container from rejection handler.") : undefined;
|
|
130
|
+
return outcome ?? result("unknown", "Code presence alone does not establish a failure disposition.");
|
|
131
|
+
}
|
|
132
|
+
if (!ts.isCatchClause(owner)) {
|
|
133
|
+
if (!ts.isExpressionStatement(use.parent))
|
|
134
|
+
return result("unknown", "The rejection handler's result is used by an enclosing expression or caller; its failure contract needs context.");
|
|
135
|
+
return result("unexplainedSwallowing", "Empty rejection handler resolves without a value or declared intent; its result is discarded.");
|
|
136
|
+
}
|
|
137
|
+
const statement = owner.parent, parent = statement.parent;
|
|
138
|
+
if (!ts.isBlock(parent) && !ts.isSourceFile(parent))
|
|
139
|
+
return result("unknown", "Enclosing control flow requires context.");
|
|
140
|
+
const following = parent.statements.slice(parent.statements.indexOf(statement) + 1);
|
|
141
|
+
if (guardedFallback(statement, following[0], checker, contracts))
|
|
142
|
+
return result("fallback", "Failed assignment is followed by an explicit undefined guard and failure outcome.");
|
|
143
|
+
if (following.length) {
|
|
144
|
+
const outcome = blockOutcome(following, checker, contracts);
|
|
145
|
+
if (outcome && outcome.classification !== "unknown")
|
|
146
|
+
return result("fallback", outcome.reason);
|
|
147
|
+
return result("unknown", "Continuation after the empty catch needs a failure-contract review.");
|
|
148
|
+
}
|
|
149
|
+
if (containsReturn(statement.tryBlock) && contracts.guardedUndefinedResult(body))
|
|
150
|
+
return result("fallback", "Every selected-source call tests the optional result before using it; no escaping function reference was found.");
|
|
151
|
+
if (containsReturn(statement.tryBlock))
|
|
152
|
+
return result("unknown", "Success returns a value; the implicit/undefined failure result may be a caller contract.");
|
|
153
|
+
if (!ts.isSourceFile(parent) && !ts.isFunctionLike(parent.parent))
|
|
154
|
+
return result("unknown", "Outer continuation or control flow requires context.");
|
|
155
|
+
return result("unexplainedSwallowing", "Terminal empty catch discards failure without an explicit result or attached intent.");
|
|
156
|
+
}
|