falsegreen-js 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -1
- package/README.md +131 -14
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +120 -0
- package/dist/cases.d.ts +61 -4
- package/dist/cases.js +138 -31
- package/dist/cfg.d.ts +32 -0
- package/dist/cfg.js +237 -0
- package/dist/cli.d.ts +38 -1
- package/dist/cli.js +210 -23
- package/dist/level.d.ts +10 -0
- package/dist/level.js +75 -0
- package/dist/oracles.d.ts +52 -0
- package/dist/oracles.js +87 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.js +177 -0
- package/dist/rules.js +472 -68
- package/dist/scan.js +0 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.js +3 -2
- package/package.json +34 -9
package/dist/cases.js
CHANGED
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
* the same concept (shared C-codes, so cross-language paper comparison lines up),
|
|
4
4
|
* plus JS/TS-specific codes (JS-prefix) for ecosystem-only patterns.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Each code carries four independent axes (none derived from another or from the
|
|
7
|
+
* code prefix):
|
|
8
|
+
* group conceptual failure mode (RiskGroup, closed taxonomy).
|
|
9
|
+
* severity how serious the finding is when it fires ("high" | "low").
|
|
10
|
+
* defaultOn whether the default scan emits it (false for the opt-in
|
|
11
|
+
* diagnostic group, surfaced only via --diagnostics).
|
|
12
|
+
* judgment which semantic question (J1-J6, see falsegreen-skill) it answers.
|
|
13
|
+
*
|
|
14
|
+
* The effective "confidence" used downstream (high/low/off) is derived from
|
|
15
|
+
* severity + defaultOn by baseConfidence(); the exit code is derived from the
|
|
16
|
+
* severity of the findings that are actually emitted. Keeping the axes apart is
|
|
17
|
+
* the point: a finding's taxonomy must not depend on whether it blocks.
|
|
8
18
|
*/
|
|
9
19
|
export const JUDGMENTS = {
|
|
10
20
|
J1: "does the assertion actually run?",
|
|
@@ -16,46 +26,143 @@ export const JUDGMENTS = {
|
|
|
16
26
|
};
|
|
17
27
|
export const CASES = {
|
|
18
28
|
// --- shared concept with falsegreen (same code id) -----------------------
|
|
19
|
-
C2: { title: "test with no check at all (empty body)",
|
|
20
|
-
C2b: { title: "test calls things but checks nothing",
|
|
21
|
-
C5: { title: "always-true check (expect(true).toBe(true), assert(1))",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
C2: { title: "test with no check at all (empty body)", group: "effectiveness", severity: "high", defaultOn: true, judgment: "J1" },
|
|
30
|
+
C2b: { title: "test calls things but checks nothing", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J1" },
|
|
31
|
+
C5: { title: "always-true check (expect(true).toBe(true), assert(1))", group: "effectiveness", severity: "high", defaultOn: true, judgment: "J2" },
|
|
32
|
+
C6: { title: "weak check — only verifies something came back (toBeTruthy/toBeDefined, length > 0)", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
33
|
+
C7: { title: "compares a thing to itself (expect(x).toBe(x))", group: "effectiveness", severity: "high", defaultOn: true, judgment: "J2" },
|
|
34
|
+
C44: { title: "numeric tautology — a length compared so the result is always true (length >= 0)", group: "effectiveness", severity: "high", defaultOn: true, judgment: "J2" },
|
|
35
|
+
C20: { title: "assertion in dead code after a return/throw — it never runs", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
36
|
+
C23: { title: "reads a real file at a literal path or hits a hard-coded URL (mystery guest)", group: "dependency", severity: "low", defaultOn: true, judgment: "J6" },
|
|
37
|
+
C8: { title: "exact equality on a float (fails on rounding, not bugs)", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
38
|
+
C16: { title: "result depends on time, randomness or a fixed timer", group: "nondeterminism", severity: "low", defaultOn: true, judgment: "J1" },
|
|
39
|
+
C18: { title: "compares String()/JSON.stringify()/`${x}` of a value to a literal (checks formatting, not the value)", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J2" },
|
|
40
|
+
C21: { title: "every assertion is conditional — none runs unconditionally", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
41
|
+
C9: { title: "expect(...).toThrow() with no error type or message — accepts any error", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
42
|
+
C37: { title: "duplicate case in it.each/test.each — the same scenario runs twice", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
43
|
+
CC: { title: "commented-out assertion (check switched off)", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
44
|
+
C48: { title: "dark patch — the test flips a test-mode flag (process.env.NODE_ENV=\"test\", process.env.TESTING, a TESTING flag) then asserts, exercising the product's test-only branch", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
30
45
|
// --- JS/TS ecosystem-specific --------------------------------------------
|
|
31
|
-
JS1: { title: "focused test (it.only / fit / describe.only) silently skips the rest of the suite",
|
|
32
|
-
JS2: { title: "expect(x) with no matcher — the assertion is never executed",
|
|
33
|
-
JS3: { title: "snapshot is the only assertion (toMatchSnapshot generated from the output itself)",
|
|
34
|
-
JS4: { title: "skipped test (it.skip / xit / xdescribe / it.todo) never runs",
|
|
35
|
-
JS5: { title: "async query/event not awaited (findBy* / waitFor / user-event) — the assertion may never settle",
|
|
36
|
-
JS6: { title: "empty describe/suite block — the suite reports green but runs nothing",
|
|
37
|
-
JS7: { title: "assertion inside a non-awaited setTimeout/setInterval/then callback — it may run after the test ends",
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
JS1: { title: "focused test (it.only / fit / describe.only) silently skips the rest of the suite", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
47
|
+
JS2: { title: "expect(x) with no matcher — the assertion is never executed", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
48
|
+
JS3: { title: "snapshot is the only assertion (toMatchSnapshot generated from the output itself)", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J2" },
|
|
49
|
+
JS4: { title: "skipped test (it.skip / xit / xdescribe / it.todo) never runs", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
50
|
+
JS5: { title: "async query/event not awaited (findBy* / waitFor / user-event) — the assertion may never settle", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
51
|
+
JS6: { title: "empty describe/suite block — the suite reports green but runs nothing", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
52
|
+
JS7: { title: "assertion inside a non-awaited setTimeout/setInterval/then callback — it may run after the test ends", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
53
|
+
JS8: { title: "mocks the unit under test (jest.mock/vi.mock of an imported module asserted directly) — tests the mock, not the code", group: "dependency", severity: "low", defaultOn: true, judgment: "J3" },
|
|
54
|
+
JS9: { title: "assertion in a dead branch (if(false) / if(true){}else) — it never runs", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
55
|
+
JS11: { title: "try/catch swallows the assertion — a failing expect is caught and the test stays green", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
56
|
+
JS13: { title: "query (getBy*/queryBy*/wrapper.find) as a loose statement — its result is never asserted", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
57
|
+
JS15: { title: "inappropriate assertion — the comparison is wrapped in a boolean (expect(a===b).toBe(true)), so the failure message is blind", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
58
|
+
JS17: { title: "commented-out test block (// it(...) / // test(...)) — a disabled test that no longer runs", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
59
|
+
JS18: { title: "test takes a done callback instead of async/await — a done called too early (or in a floating promise) passes before the assertions run", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
60
|
+
JS21: { title: "matcher referenced but never called (expect(x).toBe with no ()) — the assertion never executes", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
61
|
+
JS22: { title: "empty it.each/test.each table — the test is generated with zero cases and never runs", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
42
62
|
// --- diagnostic group (maintainability; default off, opt-in via --diagnostics
|
|
43
63
|
// or config severity). These are NOT false-green: the test still protects. They
|
|
44
64
|
// are a "plus" for test-code health, mirroring falsegreen's D/M group. -------
|
|
45
|
-
D1: { title: "assertion roulette — many assertions in one test; a failure does not say which",
|
|
46
|
-
D3: { title: "duplicate assert — the same assertion appears more than once in a test",
|
|
47
|
-
D4: { title: "it.each/test.each without titled cases — a failing case is identified only by its index",
|
|
48
|
-
D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle",
|
|
49
|
-
D7: { title: "anonymous test — empty or missing description",
|
|
50
|
-
D8: { title: "magic number in an assertion — a bare numeric literal instead of a named constant",
|
|
51
|
-
M2: { title: "test body exceeds the line-count threshold — hard to read and maintain",
|
|
65
|
+
D1: { title: "assertion roulette — many assertions in one test; a failure does not say which", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
66
|
+
D3: { title: "duplicate assert — the same assertion appears more than once in a test", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
67
|
+
D4: { title: "it.each/test.each without titled cases — a failing case is identified only by its index", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
68
|
+
D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
69
|
+
D7: { title: "anonymous test — empty or missing description", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
70
|
+
D8: { title: "magic number in an assertion — a bare numeric literal instead of a named constant", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
71
|
+
M2: { title: "test body exceeds the line-count threshold — hard to read and maintain", group: "structure", severity: "low", defaultOn: false, judgment: "J5" },
|
|
72
|
+
// --- project layer (config-audit only; emitted by --config-audit, never by
|
|
73
|
+
// the per-file scan). The suite goes green by configuration, not by a smell
|
|
74
|
+
// inside any one test file. ------------------------------------------------
|
|
75
|
+
PL7: { title: "no coverage gate (coverageThreshold / coverage.thresholds) - coverage can fall to zero and the suite still passes", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J5" },
|
|
76
|
+
PL8: { title: "bail stops the run early (bail) - the reported test count is incomplete", group: "execution", severity: "low", defaultOn: true, judgment: "J5" },
|
|
77
|
+
PL10: { title: "passWithNoTests lets an empty or fully-filtered suite report green", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
52
78
|
};
|
|
53
79
|
/** Default thresholds for the diagnostic group (overridable later via config). */
|
|
54
80
|
export const DIAGNOSTIC_THRESHOLDS = { assertionRoulette: 5, longTest: 50 };
|
|
81
|
+
/**
|
|
82
|
+
* Effective default state of a code as a single value: its severity when the
|
|
83
|
+
* default scan emits it, or "off" when it is opt-in. Derives the legacy
|
|
84
|
+
* three-valued "confidence" from the independent severity + defaultOn axes, so
|
|
85
|
+
* the rest of the pipeline (makeFinding, effectiveConf, exit code) keeps working
|
|
86
|
+
* unchanged while the taxonomy stays separate from the blocking decision.
|
|
87
|
+
*/
|
|
88
|
+
export function baseConfidence(code) {
|
|
89
|
+
const c = CASES[code];
|
|
90
|
+
if (!c)
|
|
91
|
+
throw new Error(`falsegreen-js: unknown code "${code}" — not in the case catalog`);
|
|
92
|
+
return c.defaultOn ? c.severity : "off";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Primary taxonomy: the conceptual failure mode, read from the closed per-code
|
|
96
|
+
* table. Rejects an unknown code instead of defaulting, so a typo or a code that
|
|
97
|
+
* was added to the rules but never classified fails loudly.
|
|
98
|
+
*/
|
|
99
|
+
export function riskGroupOf(code) {
|
|
100
|
+
const c = CASES[code];
|
|
101
|
+
if (!c)
|
|
102
|
+
throw new Error(`falsegreen-js: unknown code "${code}" — not in the case catalog`);
|
|
103
|
+
return c.group;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Legacy product grouping (false-positive / diagnostic / coupling / project),
|
|
107
|
+
* kept only as a transition-compat field in the JSON report. New consumers
|
|
108
|
+
* should read `riskGroup` (riskGroupOf). Prefix-based by design: it mirrors the
|
|
109
|
+
* pre-0.3 output exactly so downstream filters do not break across the upgrade.
|
|
110
|
+
*/
|
|
55
111
|
export function groupOf(code) {
|
|
112
|
+
if (code.startsWith("PL"))
|
|
113
|
+
return "project";
|
|
56
114
|
if (code.startsWith("D"))
|
|
57
115
|
return "diagnostic";
|
|
58
116
|
if (code.startsWith("M"))
|
|
59
117
|
return "coupling";
|
|
60
118
|
return "false-positive";
|
|
61
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* One-line remediation per case: what to change so the test protects something.
|
|
122
|
+
* Short, imperative, no trailing period. Surfaced in the status report (text +
|
|
123
|
+
* JSON `fix` field). A code missing here renders no fix line, never throws.
|
|
124
|
+
*/
|
|
125
|
+
export const FIX_HINTS = {
|
|
126
|
+
C2: "add an assertion that checks the behaviour under test",
|
|
127
|
+
C2b: "assert the result of the call, not just that it ran",
|
|
128
|
+
C5: "assert the real behaviour, not a constant or tautology",
|
|
129
|
+
C6: "assert the actual value, not just that something came back",
|
|
130
|
+
C7: "compare against an independent expected value, not the subject itself",
|
|
131
|
+
C44: "assert the actual length, not that it is at least zero (always true)",
|
|
132
|
+
C20: "move the assertion before the return/throw so it runs",
|
|
133
|
+
C23: "use a fixture or temp file instead of a real path or hard-coded URL",
|
|
134
|
+
C8: "use toBeCloseTo() or a tolerance instead of exact float equality",
|
|
135
|
+
C16: "freeze time and seed randomness so the result is deterministic",
|
|
136
|
+
C18: "assert the value, not its String()/JSON.stringify() form",
|
|
137
|
+
C21: "add at least one assertion that runs unconditionally",
|
|
138
|
+
C9: "pass an error type or message to toThrow()",
|
|
139
|
+
C37: "remove the duplicate it.each/test.each case",
|
|
140
|
+
CC: "restore the commented-out assertion, or delete it",
|
|
141
|
+
C48: "assert the behaviour a real user hits; don't force the product's test-mode branch from the test",
|
|
142
|
+
JS1: "remove .only (it.only/fit/describe.only) so the whole suite runs",
|
|
143
|
+
JS2: "add a matcher (expect(x).toBe(...)) so the assertion runs",
|
|
144
|
+
JS3: "add a real assertion; don't rely only on a self-generated snapshot",
|
|
145
|
+
JS4: "remove .skip/xit/todo, or implement the test",
|
|
146
|
+
JS5: "await the async query/event before asserting",
|
|
147
|
+
JS6: "add tests to the describe block, or remove it",
|
|
148
|
+
JS7: "await the promise, or use/flush fake timers, or assert synchronously",
|
|
149
|
+
JS8: "unmock the unit under test; mock only its collaborators",
|
|
150
|
+
JS9: "remove the dead branch so the assertion runs",
|
|
151
|
+
JS11: "let the assertion error propagate; don't catch it",
|
|
152
|
+
JS13: "assert on the query result, not just query it",
|
|
153
|
+
JS15: "expect the value directly (expect(a).toBe(b)), not a boolean",
|
|
154
|
+
JS17: "restore the commented-out test, or delete it",
|
|
155
|
+
JS18: "use async/await instead of the done callback",
|
|
156
|
+
JS21: "call the matcher (add ()) so the assertion executes",
|
|
157
|
+
JS22: "add at least one row to the it.each/test.each table",
|
|
158
|
+
D1: "give each assertion a message, or split the test",
|
|
159
|
+
D3: "remove the duplicate assertion",
|
|
160
|
+
D4: "add titled cases to it.each/test.each",
|
|
161
|
+
D6: "remove console.* or replace it with an assertion",
|
|
162
|
+
D7: "give the test a description",
|
|
163
|
+
D8: "name the magic number with a constant",
|
|
164
|
+
M2: "split the long test into focused cases",
|
|
165
|
+
PL7: "set coverageThreshold (Jest) or coverage.thresholds (Vitest) to gate coverage",
|
|
166
|
+
PL8: "remove bail so the whole suite runs and the count is complete",
|
|
167
|
+
PL10: "drop passWithNoTests so an empty or filtered-to-nothing run fails",
|
|
168
|
+
};
|
package/dist/cfg.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intra-test structured reachability, backing C20 (dead code) and C21 (no
|
|
3
|
+
* unconditional assertion). JS test bodies are structured (no goto), so this is a
|
|
4
|
+
* recursive walk over the statement tree, not a full control-flow graph.
|
|
5
|
+
*
|
|
6
|
+
* Two questions, both FP-averse (a false positive is worse than a miss):
|
|
7
|
+
* assertionsInDeadCode - assertions at a position control can never reach.
|
|
8
|
+
* hasUnconditionalAssertion - is at least one assertion guaranteed to run?
|
|
9
|
+
*
|
|
10
|
+
* The assertion predicate and literal-truthiness helper are passed in (they live in
|
|
11
|
+
* rules.ts) so this module imports nothing from there and there is no cycle.
|
|
12
|
+
*/
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
type IsAssertion = (n: ts.Node) => boolean;
|
|
15
|
+
type LitTruth = (e: ts.Expression | undefined) => boolean | null;
|
|
16
|
+
/**
|
|
17
|
+
* Assertions sitting at a position control can never reach (C20). Walk each
|
|
18
|
+
* statement list keeping `reachable` (true at the head); once a statement does not
|
|
19
|
+
* fall through, the rest of the list is dead. Recurse into nested lists of a
|
|
20
|
+
* reachable statement with a fresh reachable=true. Stop at nested functions.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertionsInDeadCode(body: ts.Block, isAssertion: IsAssertion): ts.Node[];
|
|
23
|
+
/**
|
|
24
|
+
* Is at least one assertion guaranteed to run (so C21 must NOT fire)? Walks the
|
|
25
|
+
* "spine" of always-executed positions: top-level statements, blocks on the spine,
|
|
26
|
+
* the taken branch of an if/?: with a literal-constant condition, finally blocks,
|
|
27
|
+
* and - conservatively - a try block. A non-const if, any loop, switch, catch, or
|
|
28
|
+
* short-circuit is not guaranteed. Anything unmodeled is treated as guaranteed
|
|
29
|
+
* (suppress C21) to stay false-positive-averse.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hasUnconditionalAssertion(body: ts.Block, isAssertion: IsAssertion, litTruth: LitTruth, deadAsserts?: ReadonlySet<ts.Node>): boolean;
|
|
32
|
+
export {};
|
package/dist/cfg.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intra-test structured reachability, backing C20 (dead code) and C21 (no
|
|
3
|
+
* unconditional assertion). JS test bodies are structured (no goto), so this is a
|
|
4
|
+
* recursive walk over the statement tree, not a full control-flow graph.
|
|
5
|
+
*
|
|
6
|
+
* Two questions, both FP-averse (a false positive is worse than a miss):
|
|
7
|
+
* assertionsInDeadCode - assertions at a position control can never reach.
|
|
8
|
+
* hasUnconditionalAssertion - is at least one assertion guaranteed to run?
|
|
9
|
+
*
|
|
10
|
+
* The assertion predicate and literal-truthiness helper are passed in (they live in
|
|
11
|
+
* rules.ts) so this module imports nothing from there and there is no cycle.
|
|
12
|
+
*/
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
/** A call to process.exit(...) — control leaves the process, so it terminates. */
|
|
15
|
+
function isProcessExit(e) {
|
|
16
|
+
return (ts.isCallExpression(e) &&
|
|
17
|
+
ts.isPropertyAccessExpression(e.expression) &&
|
|
18
|
+
e.expression.name.text === "exit" &&
|
|
19
|
+
ts.isIdentifier(e.expression.expression) &&
|
|
20
|
+
e.expression.expression.text === "process");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A statement does not fall through to the next statement in its list iff control
|
|
24
|
+
* cannot continue past it. `breakStops` controls whether break/continue count as
|
|
25
|
+
* not-falling-through: true within a normal statement list (the sibling after a
|
|
26
|
+
* break is unreachable), false when asking whether a switch escapes its own exit
|
|
27
|
+
* (a case ending in `break` continues after the switch, so it does fall through).
|
|
28
|
+
*/
|
|
29
|
+
function stmtNoFallThrough(stmt, breakStops) {
|
|
30
|
+
if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt))
|
|
31
|
+
return true;
|
|
32
|
+
if (ts.isBreakStatement(stmt) || ts.isContinueStatement(stmt))
|
|
33
|
+
return breakStops;
|
|
34
|
+
if (ts.isExpressionStatement(stmt) && isProcessExit(stmt.expression))
|
|
35
|
+
return true;
|
|
36
|
+
if (ts.isBlock(stmt))
|
|
37
|
+
return listNoFallThrough(stmt.statements, breakStops);
|
|
38
|
+
if (ts.isLabeledStatement(stmt))
|
|
39
|
+
return stmtNoFallThrough(stmt.statement, breakStops);
|
|
40
|
+
if (ts.isIfStatement(stmt)) {
|
|
41
|
+
return (stmt.elseStatement !== undefined &&
|
|
42
|
+
stmtNoFallThrough(stmt.thenStatement, breakStops) &&
|
|
43
|
+
stmtNoFallThrough(stmt.elseStatement, breakStops));
|
|
44
|
+
}
|
|
45
|
+
if (ts.isTryStatement(stmt))
|
|
46
|
+
return tryNoFallThrough(stmt, breakStops);
|
|
47
|
+
if (ts.isSwitchStatement(stmt))
|
|
48
|
+
return switchNoFallThrough(stmt);
|
|
49
|
+
// loops may run zero times, so they always fall through; var/expr/etc fall through.
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function listNoFallThrough(stmts, breakStops) {
|
|
53
|
+
return stmts.some((s) => stmtNoFallThrough(s, breakStops));
|
|
54
|
+
}
|
|
55
|
+
function tryNoFallThrough(t, breakStops) {
|
|
56
|
+
// finally always runs; if it escapes, the whole try escapes.
|
|
57
|
+
if (t.finallyBlock && listNoFallThrough(t.finallyBlock.statements, breakStops))
|
|
58
|
+
return true;
|
|
59
|
+
if (!listNoFallThrough(t.tryBlock.statements, breakStops))
|
|
60
|
+
return false;
|
|
61
|
+
// try-block escapes; if there is a catch, a caught throw escapes only if catch does too.
|
|
62
|
+
if (!t.catchClause)
|
|
63
|
+
return true;
|
|
64
|
+
return listNoFallThrough(t.catchClause.block.statements, breakStops);
|
|
65
|
+
}
|
|
66
|
+
function switchNoFallThrough(sw) {
|
|
67
|
+
// code after the switch is unreachable iff every clause escapes the function
|
|
68
|
+
// (return/throw/process.exit — a `break` exits to AFTER the switch, so it does
|
|
69
|
+
// NOT escape) and a default clause is present (else a no-match falls through).
|
|
70
|
+
let hasDefault = false;
|
|
71
|
+
for (const clause of sw.caseBlock.clauses) {
|
|
72
|
+
if (clause.kind === ts.SyntaxKind.DefaultClause)
|
|
73
|
+
hasDefault = true;
|
|
74
|
+
if (!listNoFallThrough(clause.statements, /* breakStops */ false))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return hasDefault;
|
|
78
|
+
}
|
|
79
|
+
/** Collect assertion nodes under `node`, never entering a nested function scope
|
|
80
|
+
* (a return/assert inside a callback belongs to that callback, not this test). */
|
|
81
|
+
function collectAssertionsNoNesting(node, isAssertion, out) {
|
|
82
|
+
const visit = (n) => {
|
|
83
|
+
if (isAssertion(n))
|
|
84
|
+
out.push(n);
|
|
85
|
+
if (!ts.isFunctionLike(n))
|
|
86
|
+
ts.forEachChild(n, visit);
|
|
87
|
+
};
|
|
88
|
+
ts.forEachChild(node, visit);
|
|
89
|
+
if (isAssertion(node))
|
|
90
|
+
out.push(node);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Assertions sitting at a position control can never reach (C20). Walk each
|
|
94
|
+
* statement list keeping `reachable` (true at the head); once a statement does not
|
|
95
|
+
* fall through, the rest of the list is dead. Recurse into nested lists of a
|
|
96
|
+
* reachable statement with a fresh reachable=true. Stop at nested functions.
|
|
97
|
+
*/
|
|
98
|
+
export function assertionsInDeadCode(body, isAssertion) {
|
|
99
|
+
const dead = [];
|
|
100
|
+
const walkList = (stmts) => {
|
|
101
|
+
let reachable = true;
|
|
102
|
+
for (const st of stmts) {
|
|
103
|
+
if (!reachable) {
|
|
104
|
+
collectAssertionsNoNesting(st, isAssertion, dead);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
recurse(st);
|
|
108
|
+
if (stmtNoFallThrough(st, /* breakStops */ true))
|
|
109
|
+
reachable = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
// Descend into the nested statement lists of a reachable statement, each a fresh
|
|
114
|
+
// list. Never enters a function body (its returns are its own).
|
|
115
|
+
const recurse = (st) => {
|
|
116
|
+
if (ts.isBlock(st))
|
|
117
|
+
return walkList(st.statements);
|
|
118
|
+
if (ts.isIfStatement(st)) {
|
|
119
|
+
recurse(st.thenStatement);
|
|
120
|
+
if (st.elseStatement)
|
|
121
|
+
recurse(st.elseStatement);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (ts.isForStatement(st) || ts.isForOfStatement(st) || ts.isForInStatement(st) ||
|
|
125
|
+
ts.isWhileStatement(st) || ts.isDoStatement(st)) {
|
|
126
|
+
return recurse(st.statement);
|
|
127
|
+
}
|
|
128
|
+
if (ts.isLabeledStatement(st))
|
|
129
|
+
return recurse(st.statement);
|
|
130
|
+
if (ts.isTryStatement(st)) {
|
|
131
|
+
walkList(st.tryBlock.statements);
|
|
132
|
+
if (st.catchClause)
|
|
133
|
+
walkList(st.catchClause.block.statements);
|
|
134
|
+
if (st.finallyBlock)
|
|
135
|
+
walkList(st.finallyBlock.statements);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (ts.isSwitchStatement(st)) {
|
|
139
|
+
for (const clause of st.caseBlock.clauses)
|
|
140
|
+
walkList(clause.statements);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// expression/variable/etc: no nested statement list to walk (nested functions
|
|
144
|
+
// are skipped on purpose).
|
|
145
|
+
};
|
|
146
|
+
walkList(body.statements);
|
|
147
|
+
return dead;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Is at least one assertion guaranteed to run (so C21 must NOT fire)? Walks the
|
|
151
|
+
* "spine" of always-executed positions: top-level statements, blocks on the spine,
|
|
152
|
+
* the taken branch of an if/?: with a literal-constant condition, finally blocks,
|
|
153
|
+
* and - conservatively - a try block. A non-const if, any loop, switch, catch, or
|
|
154
|
+
* short-circuit is not guaranteed. Anything unmodeled is treated as guaranteed
|
|
155
|
+
* (suppress C21) to stay false-positive-averse.
|
|
156
|
+
*/
|
|
157
|
+
export function hasUnconditionalAssertion(body, isAssertion, litTruth, deadAsserts = new Set()) {
|
|
158
|
+
// An assertion already flagged C20 (dead code) is unreachable, so it is never the
|
|
159
|
+
// guaranteed-spine assertion. Walk a leaf statement's expression for a dead node so
|
|
160
|
+
// a dead top-level assertion does not mask a live conditional one (C21 must still
|
|
161
|
+
// fire). Scoped to the leaf expression only — a single ExpressionStatement/return
|
|
162
|
+
// has no sibling live asserts, so finding any dead node means this leaf is dead (#62).
|
|
163
|
+
const exprHasDeadAssertion = (e) => {
|
|
164
|
+
let dead = false;
|
|
165
|
+
const visit = (n) => {
|
|
166
|
+
if (deadAsserts.has(n)) {
|
|
167
|
+
dead = true;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
ts.forEachChild(n, visit);
|
|
171
|
+
};
|
|
172
|
+
visit(e);
|
|
173
|
+
return dead;
|
|
174
|
+
};
|
|
175
|
+
// An assertion that is itself the spine expression (not behind a short-circuit
|
|
176
|
+
// or ternary): expect(...).m(), await expect(...), (expect(...)).
|
|
177
|
+
const directAssertion = (e) => {
|
|
178
|
+
if (ts.isAwaitExpression(e) || ts.isParenthesizedExpression(e)) {
|
|
179
|
+
return directAssertion(e.expression);
|
|
180
|
+
}
|
|
181
|
+
if (isAssertion(e))
|
|
182
|
+
return true;
|
|
183
|
+
// chai/Cypress fluent call form: result.should.equal(1) / cy.get(x).should(...).
|
|
184
|
+
// isAssertion recognizes the `.should` property-access node, but on the spine the
|
|
185
|
+
// enclosing CallExpression is what we see, so scan its callee chain for `.should`.
|
|
186
|
+
// Only the property/call spine is walked, never `&&`/`?:` operands, so a
|
|
187
|
+
// short-circuited assertion still does not count as guaranteed.
|
|
188
|
+
if (ts.isCallExpression(e)) {
|
|
189
|
+
let base = e.expression;
|
|
190
|
+
while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) {
|
|
191
|
+
if (ts.isPropertyAccessExpression(base) && base.name.text === "should")
|
|
192
|
+
return true;
|
|
193
|
+
base = base.expression;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
};
|
|
198
|
+
const stmtGuaranteed = (st) => {
|
|
199
|
+
if (ts.isExpressionStatement(st)) {
|
|
200
|
+
if (exprHasDeadAssertion(st.expression))
|
|
201
|
+
return false;
|
|
202
|
+
return directAssertion(st.expression);
|
|
203
|
+
}
|
|
204
|
+
if (ts.isBlock(st))
|
|
205
|
+
return listGuaranteed(st.statements);
|
|
206
|
+
if (ts.isLabeledStatement(st))
|
|
207
|
+
return stmtGuaranteed(st.statement);
|
|
208
|
+
if (ts.isReturnStatement(st))
|
|
209
|
+
return st.expression !== undefined
|
|
210
|
+
&& !exprHasDeadAssertion(st.expression) && directAssertion(st.expression);
|
|
211
|
+
if (ts.isIfStatement(st)) {
|
|
212
|
+
const t = litTruth(st.expression);
|
|
213
|
+
if (t === true)
|
|
214
|
+
return stmtGuaranteed(st.thenStatement);
|
|
215
|
+
if (t === false)
|
|
216
|
+
return st.elseStatement !== undefined && stmtGuaranteed(st.elseStatement);
|
|
217
|
+
return false; // non-const condition: neither branch is guaranteed
|
|
218
|
+
}
|
|
219
|
+
if (ts.isTryStatement(st)) {
|
|
220
|
+
// conservative: a try block on the spine counts as guaranteed (so
|
|
221
|
+
// try{expect}finally{...} is not flagged); finally always runs; catch does not.
|
|
222
|
+
if (listGuaranteed(st.tryBlock.statements))
|
|
223
|
+
return true;
|
|
224
|
+
if (st.finallyBlock && listGuaranteed(st.finallyBlock.statements))
|
|
225
|
+
return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
// do/while is the one loop whose body always runs at least once, so an
|
|
229
|
+
// assertion in it IS unconditional (the condition only controls repetition).
|
|
230
|
+
if (ts.isDoStatement(st))
|
|
231
|
+
return stmtGuaranteed(st.statement);
|
|
232
|
+
// for/while/for-of/for-in/switch/catch: their body is not guaranteed to run.
|
|
233
|
+
return false;
|
|
234
|
+
};
|
|
235
|
+
const listGuaranteed = (stmts) => stmts.some(stmtGuaranteed);
|
|
236
|
+
return listGuaranteed(body.statements);
|
|
237
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { Finding } from "./types.js";
|
|
3
|
+
import { OutputFormat } from "./report.js";
|
|
4
|
+
/** Turn --output into a concrete file path. A directory (existing dir, a
|
|
5
|
+
* trailing separator, or an extension-less name like ".falsegreen") receives
|
|
6
|
+
* "report.<ext>" for the chosen format; anything else is treated as a file.
|
|
7
|
+
* Missing parent directories are created either way. */
|
|
8
|
+
export declare function resolveOutputPath(p: string, fmt: OutputFormat): string;
|
|
9
|
+
/** The JSON report object. Each finding carries both the primary `riskGroup`
|
|
10
|
+
* (closed taxonomy) and the legacy `group` (transition-compat), plus its fix
|
|
11
|
+
* hint. The tool block records the oracle-registry version that classified it. */
|
|
12
|
+
export declare function buildReport(findings: Finding[]): {
|
|
13
|
+
tool: string;
|
|
14
|
+
version: string;
|
|
15
|
+
oracleRegistryVersion: number;
|
|
16
|
+
judgments: Record<string, string>;
|
|
17
|
+
findings: {
|
|
18
|
+
riskGroup: import("./cases.js").RiskGroup;
|
|
19
|
+
group: "diagnostic" | "false-positive" | "coupling" | "project";
|
|
20
|
+
fix: string;
|
|
21
|
+
file: string;
|
|
22
|
+
line: number;
|
|
23
|
+
code: string;
|
|
24
|
+
detail: string;
|
|
25
|
+
confidence: import("./cases.js").Confidence;
|
|
26
|
+
title: string;
|
|
27
|
+
level: import("./cases.js").PyramidLevel;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
/** Render findings in the chosen format. JSON keeps the full report object
|
|
31
|
+
* (riskGroup/group/fix/oracleRegistryVersion/judgments); SARIF/JUnit follow the
|
|
32
|
+
* Python sibling's contract. */
|
|
33
|
+
export declare function render(findings: Finding[], fmt: OutputFormat): string;
|
|
34
|
+
export declare function renderText(findings: Finding[]): string;
|
|
35
|
+
/** True when this module is the process entry point. Package managers expose the
|
|
36
|
+
* `bin` through a symlink (node_modules/.bin/falsegreen-js), so process.argv[1]
|
|
37
|
+
* is the symlink while import.meta.url is the real dist/cli.js path; resolve the
|
|
38
|
+
* realpath before comparing, or `npx falsegreen-js` would exit without scanning. */
|
|
39
|
+
export declare function isDirectRun(invokedPath: string | undefined, moduleUrl: string): boolean;
|