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/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,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)", 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
- C6: { title: "weak check — only verifies something came back (toBeTruthy/toBeDefined, length > 0)", confidence: "low", judgment: "J4" },
23
- C7: { title: "compares a thing to itself (expect(x).toBe(x))", confidence: "high", judgment: "J2" },
24
- C20: { title: "assertion in dead code after a return/throw it never runs", confidence: "high", judgment: "J1" },
25
- C23: { title: "reads a real file at a literal path or hits a hard-coded URL (mystery guest)", confidence: "low", judgment: "J6" },
26
- C8: { title: "exact equality on a float (fails on rounding, not bugs)", confidence: "low", judgment: "J4" },
27
- C16: { title: "result depends on time, randomness or a fixed timer", confidence: "low", judgment: "J1" },
28
- C18: { title: "compares String()/JSON.stringify()/`${x}` of a value to a literal (checks formatting, not the value)", confidence: "low", judgment: "J2" },
29
- C21: { title: "every assertion is conditional none runs unconditionally", confidence: "low", judgment: "J1" },
30
- C9: { title: "expect(...).toThrow() with no error type or message accepts any error", confidence: "low", judgment: "J4" },
31
- C37: { title: "duplicate case in it.each/test.eachthe same scenario runs twice", confidence: "low", judgment: "J4" },
32
- 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" },
33
45
  // --- JS/TS ecosystem-specific --------------------------------------------
34
- JS1: { title: "focused test (it.only / fit / describe.only) silently skips the rest of the suite", confidence: "high", judgment: "J1" },
35
- JS2: { title: "expect(x) with no matcher — the assertion is never executed", confidence: "high", judgment: "J1" },
36
- JS3: { title: "snapshot is the only assertion (toMatchSnapshot generated from the output itself)", confidence: "low", judgment: "J2" },
37
- JS4: { title: "skipped test (it.skip / xit / xdescribe / it.todo) never runs", confidence: "low", judgment: "J1" },
38
- JS5: { title: "async query/event not awaited (findBy* / waitFor / user-event) — the assertion may never settle", confidence: "low", judgment: "J1" },
39
- JS6: { title: "empty describe/suite block — the suite reports green but runs nothing", confidence: "high", judgment: "J1" },
40
- JS7: { title: "assertion inside a non-awaited setTimeout/setInterval/then callback — it may run after the test ends", confidence: "low", judgment: "J1" },
41
- JS8: { title: "mocks the unit under test (jest.mock/vi.mock of an imported module asserted directly) — tests the mock, not the code", confidence: "low", judgment: "J3" },
42
- JS9: { title: "assertion in a dead branch (if(false) / if(true){}else) — it never runs", confidence: "high", judgment: "J1" },
43
- JS11: { title: "try/catch swallows the assertion — a failing expect is caught and the test stays green", confidence: "low", judgment: "J1" },
44
- JS13: { title: "query (getBy*/queryBy*/wrapper.find) as a loose statement — its result is never asserted", confidence: "low", judgment: "J4" },
45
- 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
- JS17: { title: "commented-out test block (// it(...) / // test(...)) — a disabled test that no longer runs", confidence: "low", judgment: "J1" },
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", confidence: "low", judgment: "J1" },
48
- JS21: { title: "matcher referenced but never called (expect(x).toBe with no ()) — the assertion never executes", confidence: "high", judgment: "J1" },
49
- JS22: { title: "empty it.each/test.each table — the test is generated with zero cases and never runs", confidence: "high", judgment: "J1" },
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", confidence: "off", judgment: "J4" },
54
- D3: { title: "duplicate assert — the same assertion appears more than once in a test", confidence: "off", judgment: "J4" },
55
- D4: { title: "it.each/test.each without titled cases — a failing case is identified only by its index", confidence: "off", judgment: "J4" },
56
- D6: { title: "console.* in a test body — a debug artifact that bypasses the oracle", confidence: "off", judgment: "J4" },
57
- D7: { title: "anonymous test — empty or missing description", confidence: "off", judgment: "J4" },
58
- D8: { title: "magic number in an assertion — a bare numeric literal instead of a named constant", confidence: "off", judgment: "J4" },
59
- M2: { title: "test body exceeds the line-count threshold — hard to read and maintain", confidence: "off", judgment: "J5" },
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", confidence: "low", judgment: "J5" },
64
- PL8: { title: "bail stops the run early (bail) - the reported test count is incomplete", confidence: "low", judgment: "J5" },
65
- PL10: { title: "passWithNoTests lets an empty or fully-filtered suite report green", confidence: "low", judgment: "J1" },
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/timer, or assert synchronously",
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: "json" | "text"): string;
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]