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/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
- * confidence: "high" => blocks (exit 20); "low" => warns (exit 10); "off" => silent.
7
- * judgment: which semantic question (J1-J6, see falsegreen-skill) the code belongs to.
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)", confidence: "high", judgment: "J1" },
20
- C2b: { title: "test calls things but checks nothing", confidence: "low", judgment: "J1" },
21
- C5: { title: "always-true check (expect(true).toBe(true), assert(1))", confidence: "high", judgment: "J2" },
22
- C7: { title: "compares a thing to itself (expect(x).toBe(x))", confidence: "high", judgment: "J2" },
23
- C8: { title: "exact equality on a float (fails on rounding, not bugs)", confidence: "low", judgment: "J4" },
24
- C16: { title: "result depends on time, randomness or a fixed timer", confidence: "low", judgment: "J1" },
25
- C18: { title: "compares String()/JSON.stringify()/`${x}` of a value to a literal (checks formatting, not the value)", confidence: "low", judgment: "J2" },
26
- C21: { title: "every assertion is conditional none runs unconditionally", confidence: "low", judgment: "J1" },
27
- C9: { title: "expect(...).toThrow() with no error type or message accepts any error", confidence: "low", judgment: "J4" },
28
- C37: { title: "duplicate case in it.each/test.each the same scenario runs twice", confidence: "low", judgment: "J4" },
29
- CC: { title: "commented-out assertion (check switched off)", confidence: "low", judgment: "J1" },
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", confidence: "high", judgment: "J1" },
32
- JS2: { title: "expect(x) with no matcher — the assertion is never executed", confidence: "high", judgment: "J1" },
33
- JS3: { title: "snapshot is the only assertion (toMatchSnapshot generated from the output itself)", confidence: "low", judgment: "J2" },
34
- JS4: { title: "skipped test (it.skip / xit / xdescribe / it.todo) never runs", confidence: "low", judgment: "J1" },
35
- JS5: { title: "async query/event not awaited (findBy* / waitFor / user-event) — the assertion may never settle", confidence: "low", judgment: "J1" },
36
- JS6: { title: "empty describe/suite block — the suite reports green but runs nothing", confidence: "high", judgment: "J1" },
37
- JS7: { title: "assertion inside a non-awaited setTimeout/setInterval/then callback — it may run after the test ends", confidence: "low", judgment: "J1" },
38
- JS9: { title: "assertion in a dead branch (if(false) / if(true){}else)it never runs", confidence: "high", judgment: "J1" },
39
- JS11: { title: "try/catch swallows the assertion a failing expect is caught and the test stays green", confidence: "low", judgment: "J1" },
40
- JS13: { title: "query (getBy*/queryBy*/wrapper.find) as a loose statement its result is never asserted", confidence: "low", judgment: "J4" },
41
- JS15: { title: "inappropriate assertion the comparison is wrapped in a boolean (expect(a===b).toBe(true)), so the failure message is blind", confidence: "low", judgment: "J4" },
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", confidence: "off", judgment: "J4" },
46
- D3: { title: "duplicate assert — the same assertion appears more than once in a test", confidence: "off", judgment: "J4" },
47
- D4: { title: "it.each/test.each without titled cases — a failing case is identified only by its index", confidence: "off", judgment: "J4" },
48
- D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle", confidence: "off", judgment: "J4" },
49
- D7: { title: "anonymous test — empty or missing description", confidence: "off", judgment: "J4" },
50
- D8: { title: "magic number in an assertion — a bare numeric literal instead of a named constant", confidence: "off", judgment: "J4" },
51
- M2: { title: "test body exceeds the line-count threshold — hard to read and maintain", confidence: "off", judgment: "J5" },
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
- export {};
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;