falsegreen-js 0.3.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/CHANGELOG.md +140 -1
- package/README.md +72 -13
- package/dist/cases.d.ts +51 -3
- package/dist/cases.js +91 -43
- package/dist/cfg.d.ts +32 -0
- package/dist/cfg.js +237 -0
- package/dist/cli.d.ts +27 -1
- package/dist/cli.js +136 -41
- 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 +538 -90
- package/dist/scan.d.ts +1 -0
- package/dist/scan.js +0 -0
- package/dist/types.js +2 -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,56 +26,90 @@ 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
|
-
C6: { title: "weak check — only verifies something came back (toBeTruthy/toBeDefined, length > 0)",
|
|
23
|
-
C7: { title: "compares a thing to itself (expect(x).toBe(x))",
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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" },
|
|
33
45
|
// --- JS/TS ecosystem-specific --------------------------------------------
|
|
34
|
-
JS1: { title: "focused test (it.only / fit / describe.only) silently skips the rest of the suite",
|
|
35
|
-
JS2: { title: "expect(x) with no matcher — the assertion is never executed",
|
|
36
|
-
JS3: { title: "snapshot is the only assertion (toMatchSnapshot generated from the output itself)",
|
|
37
|
-
JS4: { title: "skipped test (it.skip / xit / xdescribe / it.todo) never runs",
|
|
38
|
-
JS5: { title: "async query/event not awaited (findBy* / waitFor / user-event) — the assertion may never settle",
|
|
39
|
-
JS6: { title: "empty describe/suite block — the suite reports green but runs nothing",
|
|
40
|
-
JS7: { title: "assertion inside a non-awaited setTimeout/setInterval/then callback — it may run after the test ends",
|
|
41
|
-
JS8: { title: "mocks the unit under test (jest.mock/vi.mock of an imported module asserted directly) — tests the mock, not the code",
|
|
42
|
-
JS9: { title: "assertion in a dead branch (if(false) / if(true){}else) — it never runs",
|
|
43
|
-
JS11: { title: "try/catch swallows the assertion — a failing expect is caught and the test stays green",
|
|
44
|
-
JS13: { title: "query (getBy*/queryBy*/wrapper.find) as a loose statement — its result is never asserted",
|
|
45
|
-
JS15: { title: "inappropriate assertion — the comparison is wrapped in a boolean (expect(a===b).toBe(true)), so the failure message is blind",
|
|
46
|
-
JS17: { title: "commented-out test block (// it(...) / // test(...)) — a disabled test that no longer runs",
|
|
47
|
-
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",
|
|
48
|
-
JS21: { title: "matcher referenced but never called (expect(x).toBe with no ()) — the assertion never executes",
|
|
49
|
-
JS22: { title: "empty it.each/test.each table — the test is generated with zero cases and never runs",
|
|
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" },
|
|
62
|
+
JS23: { title: "expect.assertions(N) with fewer unconditional expect() calls than N — the guard can never be met", group: "execution", severity: "high", defaultOn: true, judgment: "J1" },
|
|
63
|
+
JS24: { title: "Cypress query (cy.get/find/contains) with no terminating .should/.and and no expect in .then — its result is never asserted", group: "effectiveness", severity: "low", defaultOn: true, judgment: "J4" },
|
|
50
64
|
// --- diagnostic group (maintainability; default off, opt-in via --diagnostics
|
|
51
65
|
// or config severity). These are NOT false-green: the test still protects. They
|
|
52
66
|
// are a "plus" for test-code health, mirroring falsegreen's D/M group. -------
|
|
53
|
-
D1: { title: "assertion roulette — many assertions in one test; a failure does not say which",
|
|
54
|
-
D3: { title: "duplicate assert — the same assertion appears more than once in a test",
|
|
55
|
-
D4: { title: "it.each/test.each without titled cases — a failing case is identified only by its index",
|
|
56
|
-
D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle",
|
|
57
|
-
D7: { title: "anonymous test — empty or missing description",
|
|
58
|
-
D8: { title: "magic number in an assertion — a bare numeric literal instead of a named constant",
|
|
59
|
-
M2: { title: "test body exceeds the line-count threshold — hard to read and maintain",
|
|
67
|
+
D1: { title: "assertion roulette — many assertions in one test; a failure does not say which", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
68
|
+
D3: { title: "duplicate assert — the same assertion appears more than once in a test", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
69
|
+
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" },
|
|
70
|
+
D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
71
|
+
D7: { title: "anonymous test — empty or missing description", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
72
|
+
D8: { title: "magic number in an assertion — a bare numeric literal instead of a named constant", group: "diagnostic", severity: "low", defaultOn: false, judgment: "J4" },
|
|
73
|
+
M2: { title: "test body exceeds the line-count threshold — hard to read and maintain", group: "structure", severity: "low", defaultOn: false, judgment: "J5" },
|
|
60
74
|
// --- project layer (config-audit only; emitted by --config-audit, never by
|
|
61
75
|
// the per-file scan). The suite goes green by configuration, not by a smell
|
|
62
76
|
// inside any one test file. ------------------------------------------------
|
|
63
|
-
PL7: { title: "no coverage gate (coverageThreshold / coverage.thresholds) - coverage can fall to zero and the suite still passes",
|
|
64
|
-
PL8: { title: "bail stops the run early (bail) - the reported test count is incomplete",
|
|
65
|
-
PL10: { title: "passWithNoTests lets an empty or fully-filtered suite report green",
|
|
77
|
+
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" },
|
|
78
|
+
PL8: { title: "bail stops the run early (bail) - the reported test count is incomplete", group: "execution", severity: "low", defaultOn: true, judgment: "J5" },
|
|
79
|
+
PL10: { title: "passWithNoTests lets an empty or fully-filtered suite report green", group: "execution", severity: "low", defaultOn: true, judgment: "J1" },
|
|
66
80
|
};
|
|
67
81
|
/** Default thresholds for the diagnostic group (overridable later via config). */
|
|
68
82
|
export const DIAGNOSTIC_THRESHOLDS = { assertionRoulette: 5, longTest: 50 };
|
|
83
|
+
/**
|
|
84
|
+
* Effective default state of a code as a single value: its severity when the
|
|
85
|
+
* default scan emits it, or "off" when it is opt-in. Derives the legacy
|
|
86
|
+
* three-valued "confidence" from the independent severity + defaultOn axes, so
|
|
87
|
+
* the rest of the pipeline (makeFinding, effectiveConf, exit code) keeps working
|
|
88
|
+
* unchanged while the taxonomy stays separate from the blocking decision.
|
|
89
|
+
*/
|
|
90
|
+
export function baseConfidence(code) {
|
|
91
|
+
const c = CASES[code];
|
|
92
|
+
if (!c)
|
|
93
|
+
throw new Error(`falsegreen-js: unknown code "${code}" — not in the case catalog`);
|
|
94
|
+
return c.defaultOn ? c.severity : "off";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Primary taxonomy: the conceptual failure mode, read from the closed per-code
|
|
98
|
+
* table. Rejects an unknown code instead of defaulting, so a typo or a code that
|
|
99
|
+
* was added to the rules but never classified fails loudly.
|
|
100
|
+
*/
|
|
101
|
+
export function riskGroupOf(code) {
|
|
102
|
+
const c = CASES[code];
|
|
103
|
+
if (!c)
|
|
104
|
+
throw new Error(`falsegreen-js: unknown code "${code}" — not in the case catalog`);
|
|
105
|
+
return c.group;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Legacy product grouping (false-positive / diagnostic / coupling / project),
|
|
109
|
+
* kept only as a transition-compat field in the JSON report. New consumers
|
|
110
|
+
* should read `riskGroup` (riskGroupOf). Prefix-based by design: it mirrors the
|
|
111
|
+
* pre-0.3 output exactly so downstream filters do not break across the upgrade.
|
|
112
|
+
*/
|
|
69
113
|
export function groupOf(code) {
|
|
70
114
|
if (code.startsWith("PL"))
|
|
71
115
|
return "project";
|
|
@@ -86,6 +130,7 @@ export const FIX_HINTS = {
|
|
|
86
130
|
C5: "assert the real behaviour, not a constant or tautology",
|
|
87
131
|
C6: "assert the actual value, not just that something came back",
|
|
88
132
|
C7: "compare against an independent expected value, not the subject itself",
|
|
133
|
+
C44: "assert the actual length, not that it is at least zero (always true)",
|
|
89
134
|
C20: "move the assertion before the return/throw so it runs",
|
|
90
135
|
C23: "use a fixture or temp file instead of a real path or hard-coded URL",
|
|
91
136
|
C8: "use toBeCloseTo() or a tolerance instead of exact float equality",
|
|
@@ -95,13 +140,14 @@ export const FIX_HINTS = {
|
|
|
95
140
|
C9: "pass an error type or message to toThrow()",
|
|
96
141
|
C37: "remove the duplicate it.each/test.each case",
|
|
97
142
|
CC: "restore the commented-out assertion, or delete it",
|
|
143
|
+
C48: "assert the behaviour a real user hits; don't force the product's test-mode branch from the test",
|
|
98
144
|
JS1: "remove .only (it.only/fit/describe.only) so the whole suite runs",
|
|
99
145
|
JS2: "add a matcher (expect(x).toBe(...)) so the assertion runs",
|
|
100
146
|
JS3: "add a real assertion; don't rely only on a self-generated snapshot",
|
|
101
147
|
JS4: "remove .skip/xit/todo, or implement the test",
|
|
102
148
|
JS5: "await the async query/event before asserting",
|
|
103
149
|
JS6: "add tests to the describe block, or remove it",
|
|
104
|
-
JS7: "await the promise/
|
|
150
|
+
JS7: "await the promise, or use/flush fake timers, or assert synchronously",
|
|
105
151
|
JS8: "unmock the unit under test; mock only its collaborators",
|
|
106
152
|
JS9: "remove the dead branch so the assertion runs",
|
|
107
153
|
JS11: "let the assertion error propagate; don't catch it",
|
|
@@ -111,6 +157,8 @@ export const FIX_HINTS = {
|
|
|
111
157
|
JS18: "use async/await instead of the done callback",
|
|
112
158
|
JS21: "call the matcher (add ()) so the assertion executes",
|
|
113
159
|
JS22: "add at least one row to the it.each/test.each table",
|
|
160
|
+
JS23: "make the unconditional expect() count match expect.assertions(N), or remove the guard",
|
|
161
|
+
JS24: "end the cy query in .should()/.and(), or assert in .then()",
|
|
114
162
|
D1: "give each assertion a message, or split the test",
|
|
115
163
|
D3: "remove the duplicate assertion",
|
|
116
164
|
D4: "add titled cases to it.each/test.each",
|
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,10 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Finding } from "./types.js";
|
|
3
|
+
import { OutputFormat } from "./report.js";
|
|
3
4
|
/** Turn --output into a concrete file path. A directory (existing dir, a
|
|
4
5
|
* trailing separator, or an extension-less name like ".falsegreen") receives
|
|
5
6
|
* "report.<ext>" for the chosen format; anything else is treated as a file.
|
|
6
7
|
* Missing parent directories are created either way. */
|
|
7
|
-
export declare function resolveOutputPath(p: string, fmt:
|
|
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;
|
|
8
34
|
export declare function renderText(findings: Finding[]): string;
|
|
9
35
|
/** True when this module is the process entry point. Package managers expose the
|
|
10
36
|
* `bin` through a symlink (node_modules/.bin/falsegreen-js), so process.argv[1]
|