Files
msd-core/tests/complexity-trigger.test.cjs
Tom Boucher b901d1e06f feat(#1953): complexity-triggered refactor extension point (execute:post) (#3261)
* test(#1953): failing-first suite for the complexity-triggered refactor hook

60 behavioral cases against src/complexity-trigger.cts, which does not exist yet:
decision-point counting, the comment/literal stripping leak surface, threshold and
jump-delta boundaries at limit-1/limit/limit+1, stable-anchor baseline semantics,
and fs fault injection via mock.method. Two fast-check properties assert that
stripping never manufactures a decision point and that comments and string
literals are score-neutral.

Also registers the refactor-trigger capability manifest (inert until
refactor.trigger_enabled) and regenerates the capability registry and matrix.

Verified RED on the remote runner before any implementation exists.

* feat(#1953): complexity-triggered refactor extension point

Adds the opt-in refactor-trigger capability. After a phase executes, an
execute:post step measures per-function complexity for the files the phase
touched and writes a scoped refactor proposal when a function crosses the
configured threshold or drifts past its recorded anchor.

Design notes worth carrying:

- The signal is computed in-core (decision-point counting over comment- and
  literal-stripped source, Node builtins only) rather than via Memtrace or a
  shelled-out analyzer. The hook fires as a deterministic CLI, not an agent
  with MCP tools, and core takes no external dependencies — this is the only
  option a behavioral test can bind to. The metric sits behind a seam.
- The baseline is a stable anchor, not a rolling value: set on first
  observation, moved only on disposition. A rolling baseline makes the delta
  the single-phase change, so a function creeping +2 per phase never trips a
  delta of 5 and the jump check adds nothing over the absolute threshold.
- Strict mode records an open deviation window in the broken-windows ledger
  rather than declaring its own ship:pre gate. ship.md has no generic ship:pre
  gate dispatch — only two hardcoded branches — so a third gate of any kind
  would be declared and never evaluated.
- The gate clears on the proposal being dispositioned, never on the score
  improving. A blocking complexity number is one an executor can satisfy by
  splitting a coherent function in two.

execute-phase.md gains a generic execute:post step-dispatch contract; it
previously matched only ref.skill == "code-review", so any other step
registered there was declared and never run. The code-review branch is
unchanged.

Full rationale in ADR-1953.

Closes #1953

* fix(#1953): close git option injection and symlink escape in the refactor hook

Three findings from the isolated security review, all fixed inline.

HIGH — changedFilesSince interpolated the --since value into a revision
token placed before the -- separator. A -- only stops PATHSPEC parsing of
arguments after it; git still option-parses what comes before. So
--since '--output=/tmp/x' became --output=/tmp/x..HEAD, which git accepts
as --output=<file> and uses to redirect diff output — an arbitrary write.
Fixed with --end-of-options before the revision range plus a conservative
ref validator. The validator deliberately permits ~ ^ @ { } because those
are legitimate git REVISION syntax (HEAD~1, main@{yesterday}) as distinct
from ref-NAME syntax; --end-of-options is the actual barrier. The doc
comment asserting the trailing -- was sufficient was wrong and is corrected.

MEDIUM — resolveConfinedPath confined by string prefix only, so a symlink
committed inside the repo passed the check (its own path is under cwd) and
readFileSync then followed it outside the root. Now lstat-checks for a
regular file and skips anything else with REFACTOR_FILE_UNREADABLE, so one
bad path skips one file and the run continues.

LOW — the new execute:post dispatch contract showed the gsd_run example
before the rule requiring ref.command be validated first. That prose is
executed by an agent, so textual order is execution order. Reordered.

Refs #1953

* fix(#1953): make the analyzer able to see TypeScript at all

Found by running the shipped analyzer over its own source: it reported
functions=1 for a 940-line module with 24 function forms. A return-type
annotation or a generic parameter list made a function invisible —
`function f(a): number {}` and `function f<T>(a: T): T {}` both detected as
zero. Since gsd-core is written in .cts and the capability declares
.ts/.cts/.mts analyzable, the feature silently found nothing in this repo's
own primary language while reporting success. A safety net that reports
"all clear" because it cannot see is worse than no safety net.

All 98 tests passed over this, because every fixture was plain JS — the
exact failure the test matrix's own "assert against the shape production
uses" warning describes. Adds a TypeScript-shapes suite covering return
types (including unions, generics, object literals and type predicates),
generic parameter lists (constrained and defaulted), export/async/generator
combinations, annotated arrows, class-method modifiers, and optional/
default/rest params — plus the two traps: an overload signature has no body
and must not count, and `a < b && c > d` is a comparison, not a generic.
Detection now reports 24/37/21 functions for the three source files, which
matches a hand count exactly.

Also from review:

- The strict-mode ledger dedup identified entries by parsing a prose
  description string. That is banned by CONTRIBUTING's raw-text-matching
  rule and was a real bug: the "exactly one window per untriaged proposal"
  guarantee rested on prose matching, so rewording a description or editing
  WINDOWS.md by hand silently produced duplicates. Now matches structurally
  on kind + phase + file + line.
- A property test asserted on the stripper's output text. Reframed to
  assert the same invariant through analyzeSource's score.
- nextBaseline's `candidates` parameter has been dead since the anchor
  change; removed from the signature and all call sites.
- Extracted the duplicated require-or-degrade and capability-check
  boilerplate.
- ADR-1953's Implementation bullet still named a `refactor.ship-gate` in
  check-command-router.cts — a leftover from the design cut D6 rejects.
  That file is untouched and no such gate exists. Removed.

Refs #1953

* fix(#1953): keep execute-phase.md under its byte ceiling; un-vacuum the large-file test

Five of the seven remote-runner failures were one cause: the execute:post
dispatch contract, written out inline, grew execute-phase.md 1876 bytes
(93,400 -> 95,276) against a frozen PRE_PHASE6 ceiling of 93,600. A drift-ack
does not clear that — tests/phase6-capstone-conformance.test.cjs and
tests/fix-2285-claude-orchestration-wiring.test.cjs assert the file is
literally under the cap.

The contract now lives in gsd-core/references/loop-hook-dispatch.md, which
already claimed to be the point-agnostic dispatch reference and already
documented ref.skill and ref.agent. It gains the ref.command shape, its
in-context validation rule, the advisory-by-construction statement, and a
note that a point whose workflow hand-rolls one kind is not implementing
this contract. execute-phase.md now defers to it in one line: 145 bytes of
growth, 55 B of headroom under the cap. Better placement than the first cut
— the reference was overstating its coverage, and this makes the claim true
rather than duplicating prose next to it.

Acknowledged by appending to tests/emitted-drift-acks/2930-*.json rather
than a new 1953-*.json: two ack sources may never name the same path, and
that fragment is already the accumulating ack for this file.

Sixth and seventh failures: analyzesLargeFileWithinBounds tripped its own
vacuity guard — the fixture generated ~480 KB against a `> 500000` assert,
so the guard fired and the three assertions after it never ran. The test
has been vacuous since it was written. The matrix row specifies ~1 MB, so
N goes 8000 -> 20000 (1.17 MB, 17% margin) and the guard to > 1_000_000.
Verified by reproducing the exact body against the compiled module: 1168888
bytes, 118 ms, all four assertions hold.

Refs #1953

* fix(#1953): fold the execute:post step deferral into the existing resolve line

The remaining two failures were one test: execute-phase.md carries a SECOND,
tighter assertion than the 93,600 ceiling — `<=93400`, which is exactly its
current size. The file cannot grow by a single byte. My previous fix got it
under 93,600 but not under 93,400, so it still failed. ("H." in the report is
just the parent describe of that same test, not a separate defect.)

Rather than add a paragraph, the deferral now REPLACES the existing hook
resolution line. It read:

  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where
  `kind == "step"` and `ref.skill == "code-review"`.

which is the bug itself written down — only code-review was ever dispatched.
It now reads:

  Dispatch each `kind == "step"` hook per
  @gsd-core/references/loop-hook-dispatch.md. For `code-review`:

The following prose already begins "If no active code-review step hook
exists", so it reads correctly and the code-review handling is untouched.
Net effect on the file is -11 bytes: 93,400 -> 93,389, under the margin
assertion rather than merely under the ceiling.

That also removes the need for a drift-ack: the file shrank, so there is no
growth to acknowledge, and the append to the shared 2930-*.json fragment is
reverted. Leaving it would have shipped a claim of "145 bytes of growth"
that is no longer true, on a file six other issues share.

The test's own comment states the principle this ended up honoring: "the host
loop must stay small — optional-feature detail belongs in the capability
fragment, not the host workflow." Putting the dispatch contract in the
reference rather than inline is that rule, applied.

Refs #1953

* fix(#1953): keep the code-review hook literal the workflow test requires

tests/code-review.test.cjs extracts the <step name="code_review_gate"> block
and asserts it contains `ref.skill == "code-review"` verbatim. The previous
commit replaced the line carrying that literal, so the token vanished and the
test went red — a fair assertion: code-review IS the bespoke branch there and
the workflow should still name it.

Restored inside the same one-line deferral, which now reads:

  Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.
  `ref.skill == "code-review"`:

93,396 bytes — still under the `<=93400` margin assertion and 4 bytes below
the base, so the file continues to shrink rather than grow.

Because three consecutive runs were each reddened by a different assertion on
this one file, this change was verified by sweeping ALL of them at once rather
than one run at a time: every test under tests/ that reads execute-phase.md or
references/loop-hook-dispatch.md was located by resolving its path constants,
and each content/size assertion was evaluated directly against the working
tree — 22 assertions, plus two real executions (gen-section-manifest --check,
and emitted-attribution's full real-tree differential). All pass.

That sweep also confirms the earlier judgement call: the net change to
execute-phase.md is a SHRINK, and the size ratchet only gates growth, so
reverting the append to the shared 2930-*.json ack fragment was correct — an
ack would have been both unnecessary and factually wrong.

Refs #1953

* chore(#1953): backfill changeset pr number to 3261

* docs(#1953): add the missing how-to for acting on a refactor proposal

Reference and explanation shipped (COMMANDS.md, CONFIGURATION.md,
FEATURES.md 159, ADR-1953) but the Diataxis how-to quadrant did not, and
that is the one a user reaches for. CONTRIBUTING's required-docs table is
'new command -> COMMANDS.md + FEATURES.md', so CI was green on a gap.

Enabling this feature is genuinely multi-step and no single page walked it:
turn it on, tune the threshold, understand advisory vs strict, discover
that strict needs a SECOND toggle on a DIFFERENT capability, and know what
to do when a proposal appears. The two-toggle subtlety in particular was a
footnote in a config table; here it is a section with both commands.

Follows the shape of its closest siblings, resolve-edge-coverage-findings
and resolve-prohibition-findings — both 'the loop surfaced a finding, here
is what to do with it'. Includes a reason-code table for the silent cases,
since the analyzer is deliberately quiet in six situations and a user who
expected a proposal needs to tell 'nothing to report' from 'could not look'.

Indexed from docs/README.md beside the other loop how-tos.

Docs-only: exempt from the push gate, no re-verification, pass marker on
2af188b4 untouched.

Refs #1953

* feat(#1953): warn when strict mode is on but nothing will actually block

Closes acceptance criterion 5, which I had wrongly marked satisfied.

refactor.trigger_strict records an untriaged proposal as an open deviation
window, but a ship only STOPS if workflow.windows_enforce is also on — a
toggle owned by the broken-windows capability that this feature neither sets
nor requires. So a user could enable strict, believe ship was gated, and find
out otherwise at ship time.

The split itself stays: requires:["broken-windows"] would force-install the
ledger on advisory users who never enable strict, and a ship:pre gate of our
own would never fire because ship.md has no generic ship:pre gate dispatch.
What was missing was discoverability, so that is what this fixes.

`refactor evaluate` now emits a typed REFACTOR_STRICT_NOT_ENFORCING warning,
naming the exact remediation command, whenever strict is on and either
workflow.windows_enforce is off or broken-windows is unavailable. It fires
only on a run that produced a candidate — with nothing to block on there is
nothing to warn about, and warning every run would be noise.

Reads workflow.windows_enforce through the same resolveConfigKey walk the
router already uses for its own keys rather than a second config reader.
Four tests cover the matrix: strict+enforce-off warns, strict+enforce-on does
not, strict+ledger-absent warns, strict-off never warns.

Also corrects a user-facing message in this same file that told the user to
run `gsd-tools config-set` — the wrong form. docs/CONFIGURATION.md and the
broken-windows capability both use `gsd config-set`, and gsd-tools is invoked
as `node gsd-tools.cjs`, so the bare form may not resolve. The two adjacent
messages in this file now agree.

Refs #1953

---------

Co-authored-by: sim <sim@local>
2026-08-09 19:52:47 -04:00

961 lines
36 KiB
JavaScript

'use strict';
/**
* Complexity-triggered refactor extension point — analyzer, evaluator, and
* baseline-persistence behavioral + property tests.
*
* Module: gsd-core/bin/lib/complexity-trigger.cjs (compiled from
* src/complexity-trigger.cts — deliberately absent; this file is
* written failing-first per issue #1953's TDD directive).
*
* Spec sources (authoritative, do not drift from these without a design
* change):
* - .gsd/phase/feat-1953-complexity-triggered-refactor/41-api-contract.md
* - .gsd/phase/feat-1953-complexity-triggered-refactor/50-test-matrix.md
* - .gsd/phase/feat-1953-complexity-triggered-refactor/40-design.md
*
* Coverage map — test matrix rows 1-54 (analyzer counting, the literal-strip
* leak surface, CRLF/empty/size bounds, properties, file selection,
* threshold/jump evaluation, baseline persistence) plus the typed-surface
* enum/constant lock tests. Rows 55+ (git adapter, CLI, ship gate, loop
* wiring) live in tests/refactor-trigger-cli.test.cjs — out of scope here.
*
* Hermetic: every temp dir via createTempDir + t.after(cleanup); every fs
* mock via mock.method(fs, ...) restored via t.after(...mock.restore()).
* No shared module-level fixture state (matrix row 91). Ambient GSD_* env
* is not touched by this file — every fixture here is either pure in-memory
* input or an isolated tmpdir (matrix row 92 is structural, not exercised
* by this leaf-module suite).
*/
const { describe, test, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
const {
BASELINE_FILE_NAME,
PROPOSAL_SUFFIX,
SCHEMA_VERSION,
DEFAULTS,
ANALYZABLE_EXTENSIONS,
VERDICT,
REASON,
stripLiterals,
analyzeSource,
isAnalyzablePath,
evaluateCandidates,
nextBaseline,
reanchorBaseline,
readBaseline,
writeBaseline,
} = require('../gsd-core/bin/lib/complexity-trigger.cjs');
// ---------------------------------------------------------------------------
// Analyzer — counting (matrix rows 1-10)
// ---------------------------------------------------------------------------
describe('complexity-trigger: analyzer — counting decision points', () => {
test('scoresFlatFunctionAsOne', () => {
const source = ['function f() {', ' return 1;', '}'].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions.length, 1);
assert.equal(r.functions[0].name, 'f');
assert.equal(r.functions[0].startLine, 1);
assert.equal(r.functions[0].score, 1);
});
test('scoresSingleIfAsTwo', () => {
const source = [
'function f(x) {',
' if (x) {',
' return 1;',
' }',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions[0].score, 2);
});
test('countsEachDecisionConstructOnce', () => {
// Each of the twelve listed constructs exactly once: if, else if, for,
// for..of, for..in, do, while (via one do-while so "while" is not
// double-counted by a separate stand-alone while statement), case,
// catch, &&, ||, ?:. Base 1 + 12 = 13.
const source = [
'function f(x) {',
' if (x === 1) { return 1; }',
' else if (x === 2) { return 2; }',
' for (let i = 0; i < 1; i++) { g(i); }',
' for (const y of x) { g(y); }',
' for (const k in x) { g(k); }',
' let n = 0;',
' do { n += 1; } while (n < 1);',
' switch (x) {',
' case 1: g(1); break;',
' default: g(0);',
' }',
' try { g(x); } catch (e) { g(e); }',
' const a = x && 1;',
' const b = x || 1;',
' const c = x ? 1 : 2;',
' return a + b + c;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions.length, 1);
assert.equal(r.functions[0].score, 13);
});
test('doesNotCountBareElse', () => {
const source = [
'function f(x) {',
' if (x) { return 1; }',
' else { return 2; }',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.functions[0].score, 2, 'bare else must not add a point beyond the if');
});
test('doesNotCountSwitchDefault', () => {
const source = [
'function f(x) {',
' switch (x) {',
' case 1: return 1;',
' default: return 0;',
' }',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.functions[0].score, 2, 'base 1 + one case; default: must not add a point');
});
test('doesNotConflateLengthWithComplexity', () => {
const bodyLines = Array.from({ length: 200 }, (_, i) => ` const v${i} = ${i};`);
const source = ['function f() {', ...bodyLines, ' return 0;', '}'].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions[0].score, 1, 'a long but flat function must still score 1');
});
test('scoresFlatSwitchByCaseCountPinningKnownBias', () => {
const caseLines = Array.from({ length: 12 }, (_, i) => ` case ${i + 1}: return ${i + 1};`);
const source = [
'function f(x) {',
' switch (x) {',
...caseLines,
' }',
' return 0;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.functions[0].score, 13, 'flat 12-case switch scores 1 + 12 by design — the known bias is pinned, not accidental');
});
test('attributesScoresToEachFunction', () => {
const source = [
'function a() {',
' return 1;',
'}',
'',
'function b(x) {',
' if (x) {',
' return 1;',
' }',
' return 0;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.functions.length, 2);
assert.equal(r.functions[0].name, 'a');
assert.equal(r.functions[0].startLine, 1);
assert.equal(r.functions[0].score, 1);
assert.equal(r.functions[1].name, 'b');
assert.equal(r.functions[1].startLine, 5);
assert.equal(r.functions[1].score, 2);
});
test('doesNotDoubleCountNestedFunction', () => {
const source = [
'function outer() {',
' function inner(x) {',
' if (x) {',
' return 1;',
' }',
' return 0;',
' }',
' return inner;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.functions.length, 2);
const outer = r.functions.find((fn) => fn.name === 'outer');
const inner = r.functions.find((fn) => fn.name === 'inner');
assert.equal(outer.score, 1, 'the nested if must not double-count into outer');
assert.equal(inner.score, 2, 'the nested if is attributed to the innermost function');
});
test('detectsAllFunctionForms', () => {
const source = [
'const arrow = (x) => x + 1;',
'const obj = {',
' method(x) {',
' return x;',
' },',
'};',
'async function asyncFn() {',
' return 1;',
'}',
'function* genFn() {',
' yield 1;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions.length, 4, 'arrow, method shorthand, async function, and generator must all be detected');
assert.ok(r.functions.every((fn) => fn.score === 1), 'none of these forms has a branch of its own');
});
});
// ---------------------------------------------------------------------------
// Analyzer — the leak surface (matrix rows 11-21)
// ---------------------------------------------------------------------------
describe('complexity-trigger: analyzer — the leak surface', () => {
test('ignoresDecisionKeywordInLineComment', () => {
const source = [
'function f() {',
' // if (x) { return 1; }',
' return 0;',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1);
});
test('ignoresDecisionKeywordInBlockComment', () => {
const source = [
'function f() {',
' /* if (x) { return 1 } && y */',
' return 0;',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1);
});
test('ignoresDecisionKeywordInString', () => {
const source = [
'function f() {',
' const s = "if (x) { return 1; }";',
' return s;',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1);
});
test('ignoresDecisionKeywordInTemplateLiteral', () => {
const source = [
'function f() {',
' const s = `if (x) { return 1; }`;',
' return s;',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1);
});
test('ignoresAlternationInsideRegexLiteral', () => {
const source = [
'function f(s) {',
' return /a|b/.test(s);',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1, 'the | inside /a|b/ must not count as ||');
});
test('doesNotMistakeDivisionForRegexLiteral', () => {
const source = [
'function f(a, b, c) {',
' return a / b / c;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions[0].score, 1, 'a / b / c must be read as division, never as a regex literal');
});
test('doesNotCountOptionalChainingAsTernary', () => {
const source = [
'function f(a) {',
' return a?.b;',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1);
});
test('doesNotCountNullishCoalescingAsBranch', () => {
const source = [
'function f(x, y) {',
' return x ?? y;',
'}',
].join('\n');
assert.equal(analyzeSource(source).functions[0].score, 1);
});
test('handlesEscapedQuoteWithoutSwallowingCode', () => {
const source = [
'function f(x) {',
" const s = 'it\\'s fine';",
' if (x) { return s; }',
' return null;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions[0].score, 2, 'the escaped quote must not swallow the following if');
});
test('countsBranchInsideTemplateInterpolation', () => {
const source = [
'function f(x) {',
' const s = `value: ${x ? 1 : 2}`;',
' return s;',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions[0].score, 2, 'the ternary inside ${} is real code and must be counted');
});
test('refusesToScoreUnterminatedLiteral', () => {
const unterminatedString = [
'function f() {',
' const s = "never closed;',
'}',
].join('\n');
const unterminatedTemplate = [
'function f() {',
' const s = `never closed;',
'}',
].join('\n');
const unterminatedComment = [
'function f() {',
' /* never closed',
' return 1;',
'}',
].join('\n');
for (const src of [unterminatedString, unterminatedTemplate, unterminatedComment]) {
const r = analyzeSource(src);
assert.equal(r.ok, false);
assert.equal(r.reason, REASON.REFACTOR_ANALYZER_UNPARSEABLE);
assert.equal(r.functions, undefined, 'an unparseable source must never emit a number');
}
});
test('producesIdenticalScoresForCrlfAndLf', () => {
const lfSource = [
'function f(x) {',
' if (x) {',
' return 1;',
' } else if (x === 2) {',
' return 2;',
' }',
' return 0;',
'}',
'',
'function g(y) {',
' return y && y || y ? 1 : 0;',
'}',
].join('\n');
const crlfSource = lfSource.replace(/\n/g, '\r\n');
const lfResult = analyzeSource(lfSource);
const crlfResult = analyzeSource(crlfSource);
assert.equal(lfResult.ok, true);
assert.equal(crlfResult.ok, true);
assert.deepEqual(
crlfResult.functions,
lfResult.functions,
'CRLF and LF forms of the same source must yield byte-identical scores AND line numbers',
);
});
test('returnsEmptyResultForEmptyFile', () => {
const r = analyzeSource('');
assert.equal(r.ok, true);
assert.deepEqual(r.functions, []);
});
test('returnsEmptyResultForWhitespaceOnlyFile', () => {
const r = analyzeSource(' \n\t\n \n');
assert.equal(r.ok, true);
assert.deepEqual(r.functions, []);
});
test('returnsEmptyResultWhenNoFunctionsPresent', () => {
const source = [
'const x = 1;',
'if (x) {',
' console.log(x);',
'}',
].join('\n');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.deepEqual(r.functions, [], 'top-level code belongs to no function, and must never be a synthetic entry');
});
test('analyzesLargeFileWithinBounds', () => {
const N = 20000;
const chunks = Array.from({ length: N }, (_, i) => [
`function f${i}(x) {`,
' if (x) { return 1; }',
' return 0;',
'}',
].join('\n'));
const source = chunks.join('\n\n');
assert.ok(source.length > 1_000_000, 'fixture must actually be large enough to exercise the bound');
const r = analyzeSource(source);
assert.equal(r.ok, true);
assert.equal(r.functions.length, N);
assert.ok(r.functions.every((fn) => fn.score === 2));
});
});
// ---------------------------------------------------------------------------
// Analyzer — properties (matrix rows 27-28)
// ---------------------------------------------------------------------------
describe('complexity-trigger: analyzer — properties', () => {
test('propertyStripNeverManufacturesDecisionPoints', () => {
// #1953 defect 3: the invariant is asserted through the TYPED surface
// (analyzeSource's numeric score), never by pattern-matching the text
// stripLiterals produced (CONTRIBUTING.md:800 bans grepping the SUT's
// own output, with no carve-out for a text transform's own return
// value). A fuzzed string embedded as INERT content (a block comment,
// or a string literal) can never manufacture a decision point — proven
// by the reported score staying byte-for-byte the same number.
const BASE = ['function f(x) {', ' if (x) { return 1; }', ' return 0;', '}', ''].join('\n');
const baseAnalyzed = analyzeSource(BASE);
assert.equal(baseAnalyzed.ok, true);
const baselineScores = baseAnalyzed.functions.map((fn) => fn.score);
fc.assert(
fc.property(fc.string({ maxLength: 300 }), (source) => {
const stripped = stripLiterals(source);
if (stripped.ok) {
assert.ok(
stripped.stripped.length <= source.length,
`stripLiterals must never grow the source (in=${source.length}, out=${stripped.stripped.length})`,
);
}
const analyzed = analyzeSource(source);
if (analyzed.ok) {
for (const fn of analyzed.functions) {
assert.ok(fn.score >= 1, 'every detected function must score at least 1');
}
}
// Sanitize just enough to keep the wrapper well-formed (never
// "*/" inside the comment; never an unescaped quote, backslash, or
// raw newline inside the string) — the content is otherwise
// untouched, including any decision-point-shaped substrings.
const commentSafe = source.replace(/\*/g, ' ');
const withComment = analyzeSource(`${BASE}\n/* ${commentSafe} */\n`);
assert.equal(withComment.ok, true, 'a sanitized block comment must never make the source unparseable');
assert.deepEqual(
withComment.functions.map((fn) => fn.score),
baselineScores,
'embedding fuzzed text inside a block comment must never change the reported score',
);
const stringSafe = source.replace(/["\\\r\n]/g, ' ');
const withString = analyzeSource(`${BASE}\nconst __s = "${stringSafe}";\n`);
assert.equal(withString.ok, true, 'a sanitized string literal must never make the source unparseable');
assert.deepEqual(
withString.functions.map((fn) => fn.score),
baselineScores,
'embedding fuzzed text inside a string literal must never change the reported score',
);
}),
{ numRuns: 50, seed: 42 },
);
});
test('propertyCommentsAndStringsAreScoreNeutral', () => {
const BASE_SOURCES = [
['function f(x) {', ' if (x) { return 1; }', ' return 0;', '}'].join('\n'),
['function g(x, y) {', ' return x && y;', '}'].join('\n'),
['function h(x) {', ' for (let i = 0; i < x; i++) { g(i); }', '}'].join('\n'),
];
const APPENDERS = [
(src) => [src, '// if (x) { return 1; } && y || z ? 1 : 2', ''].join('\n'),
(src) => [src, '/* if (x) { return 1 } && y */', ''].join('\n'),
(src) => [src, 'const __s = "if (x) { return 1; } && y || z ? 1 : 2";', ''].join('\n'),
];
fc.assert(
fc.property(
fc.constantFrom(...BASE_SOURCES),
fc.constantFrom(...APPENDERS),
(base, appendFn) => {
const before = analyzeSource(base);
const after = analyzeSource(appendFn(base));
assert.equal(before.ok, true);
assert.equal(after.ok, true);
assert.deepEqual(
before.functions.map((fn) => fn.score),
after.functions.map((fn) => fn.score),
'appending a comment/string containing decision keywords must never change a score',
);
},
),
{ numRuns: 20, seed: 42 },
);
});
});
// ---------------------------------------------------------------------------
// File selection (matrix rows 29-32)
// ---------------------------------------------------------------------------
describe('complexity-trigger: file selection', () => {
test('analyzesSupportedExtensions', () => {
for (const ext of ANALYZABLE_EXTENSIONS) {
assert.equal(isAnalyzablePath(`src/foo${ext}`), true, `expected ${ext} to be analyzable`);
}
});
test('skipsUnsupportedExtensionsWithoutScoringZero', () => {
for (const p of ['docs/readme.md', 'package.json', 'ci.yml', 'notes.txt', 'Makefile']) {
assert.equal(isAnalyzablePath(p), false, `expected ${p} to be unsupported`);
}
});
test('excludesTestFilesByDefault', () => {
assert.equal(isAnalyzablePath('tests/foo.test.cjs'), false);
assert.equal(isAnalyzablePath('tests\\foo.test.cjs'), false, 'separators are normalized unconditionally');
});
test('excludesGeneratedLibPathsExplicitly', () => {
assert.equal(isAnalyzablePath('gsd-core/bin/lib/foo.cjs'), false);
assert.equal(isAnalyzablePath('gsd-core\\bin\\lib\\foo.cjs'), false);
});
});
// ---------------------------------------------------------------------------
// Threshold / jump evaluation (matrix rows 33-44)
// ---------------------------------------------------------------------------
describe('complexity-trigger: threshold / jump evaluation', () => {
test('doesNotTriggerBelowThreshold', () => {
const T = DEFAULTS.threshold;
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score: T - 1 }] }];
const r = evaluateCandidates({ analyzed, baseline: {} });
assert.equal(r.verdict, VERDICT.BELOW_THRESHOLD);
assert.deepEqual(r.candidates, []);
assert.equal(r.target, null);
});
test('doesNotTriggerAtExactThreshold', () => {
const T = DEFAULTS.threshold;
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score: T }] }];
const r = evaluateCandidates({ analyzed, baseline: {} });
assert.equal(r.verdict, VERDICT.BELOW_THRESHOLD, 'strictly-greater semantics: score === T must not trigger');
assert.deepEqual(r.candidates, []);
});
test('triggersAboveThreshold', () => {
const T = DEFAULTS.threshold;
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score: T + 1 }] }];
const r = evaluateCandidates({ analyzed, baseline: {} });
assert.equal(r.verdict, VERDICT.TRIGGERED);
assert.equal(r.candidates.length, 1);
assert.deepEqual(r.candidates[0].reasons, ['threshold']);
assert.equal(r.candidates[0].baseline, null);
assert.equal(r.candidates[0].delta, null);
});
test('doesNotTriggerBelowJumpDelta', () => {
const T = DEFAULTS.threshold;
const D = DEFAULTS.jumpDelta;
const score = T - 5;
const baseline = { 'a.js::f': { score: score - (D - 1) } };
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score }] }];
const r = evaluateCandidates({ analyzed, baseline });
assert.equal(r.verdict, VERDICT.BELOW_THRESHOLD);
assert.deepEqual(r.candidates, []);
});
test('doesNotTriggerAtExactJumpDelta', () => {
const T = DEFAULTS.threshold;
const D = DEFAULTS.jumpDelta;
const score = T - 5;
const baseline = { 'a.js::f': { score: score - D } };
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score }] }];
const r = evaluateCandidates({ analyzed, baseline });
assert.equal(r.verdict, VERDICT.BELOW_THRESHOLD, 'strictly-greater semantics: delta === D must not trigger');
});
test('triggersAboveJumpDelta', () => {
const T = DEFAULTS.threshold;
const D = DEFAULTS.jumpDelta;
const score = T - 5;
const baseline = { 'a.js::f': { score: score - (D + 1) } };
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score }] }];
const r = evaluateCandidates({ analyzed, baseline });
assert.equal(r.verdict, VERDICT.TRIGGERED);
assert.deepEqual(r.candidates[0].reasons, ['jump']);
});
test('emitsSingleCandidateCarryingBothReasons', () => {
const T = DEFAULTS.threshold;
const D = DEFAULTS.jumpDelta;
const score = T + 1;
const baseline = { 'a.js::f': { score: score - (D + 1) } };
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score }] }];
const r = evaluateCandidates({ analyzed, baseline });
assert.equal(r.candidates.length, 1, 'one candidate, never two entries');
assert.deepEqual(r.candidates[0].reasons, ['threshold', 'jump'], 'both reasons, ordered');
});
test('doesNotTriggerWhenComplexityDecreased', () => {
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score: 10 }] }];
const baseline = { 'a.js::f': { score: 20 } };
const r = evaluateCandidates({ analyzed, baseline });
assert.equal(r.verdict, VERDICT.BELOW_THRESHOLD);
assert.deepEqual(r.candidates, []);
});
test('doesNotTreatMissingBaselineAsZeroBaseline', () => {
const T = DEFAULTS.threshold;
const analyzed = [{ file: 'a.js', ok: true, method: 'decision-points', functions: [{ name: 'f', startLine: 1, endLine: 3, score: T + 1 }] }];
const r = evaluateCandidates({ analyzed, baseline: {} });
assert.equal(r.candidates.length, 1);
assert.equal(r.candidates[0].baseline, null);
assert.equal(r.candidates[0].delta, null, 'no baseline means delta is null — never equal to score');
assert.notEqual(r.candidates[0].delta, r.candidates[0].score);
assert.deepEqual(r.candidates[0].reasons, ['threshold'], 'jump must not be evaluated with no baseline');
});
test('ordersCandidatesDeterministically', () => {
const analyzed = [
{ file: 'b.js', ok: true, method: 'decision-points', functions: [{ name: 'y', startLine: 1, endLine: 3, score: 20 }] },
{
file: 'a.js',
ok: true,
method: 'decision-points',
functions: [
{ name: 'x', startLine: 1, endLine: 3, score: 20 },
{ name: 'z', startLine: 10, endLine: 13, score: 18 },
],
},
];
const baseline = { 'a.js::x': { score: 10 } }; // y and z have no baseline entry
const r = evaluateCandidates({ analyzed, baseline, threshold: 15, jumpDelta: 100 });
const ids = r.candidates.map((c) => `${c.file}::${c.name}`);
assert.deepEqual(ids, ['a.js::x', 'b.js::y', 'a.js::z'], 'score desc, delta desc (null last), file asc, name asc');
assert.equal(r.target, r.candidates[0]);
});
test('breaksCandidateTiesStably', () => {
const analyzed = [
{
file: 'a.js',
ok: true,
method: 'decision-points',
functions: [
{ name: 'bravo', startLine: 20, endLine: 22, score: 20 },
{ name: 'alpha', startLine: 5, endLine: 7, score: 20 },
],
},
];
const r = evaluateCandidates({ analyzed, baseline: {}, threshold: 15, jumpDelta: 100 });
assert.deepEqual(r.candidates.map((c) => c.name), ['alpha', 'bravo'], 'tie on every other key breaks by name ascending');
});
test('rejectsNonNumericThresholdInsteadOfNaNComparing', () => {
const scoreAboveDefaultThreshold = DEFAULTS.threshold + 1;
const analyzed = [{
file: 'a.js',
ok: true,
method: 'decision-points',
functions: [{ name: 'f', startLine: 1, endLine: 3, score: scoreAboveDefaultThreshold }],
}];
for (const bad of [0, -5, 'not-a-number', NaN, undefined, null, {}]) {
const r = evaluateCandidates({ analyzed, baseline: {}, threshold: bad, jumpDelta: bad });
assert.equal(r.thresholdUsed, DEFAULTS.threshold, `threshold ${String(bad)} must fall back to the default`);
assert.equal(r.jumpDeltaUsed, DEFAULTS.jumpDelta, `jumpDelta ${String(bad)} must fall back to the default`);
assert.equal(r.verdict, VERDICT.TRIGGERED, 'fallback must still evaluate — never a silent NaN-comparison never-trigger');
}
});
});
// ---------------------------------------------------------------------------
// Baseline persistence (matrix rows 45-54)
// ---------------------------------------------------------------------------
describe('complexity-trigger: baseline persistence', () => {
test('treatsMissingBaselineAsEmpty', (t) => {
const tmp = createTempDir('complexity-trigger-');
t.after(() => cleanup(tmp));
const r = readBaseline(tmp);
assert.equal(r.ok, true);
assert.deepEqual(r.baseline, {});
assert.equal(r.reason, undefined);
});
test('doesNotClobberMalformedBaselineOnReadFailure', (t) => {
const tmp = createTempDir('complexity-trigger-');
t.after(() => cleanup(tmp));
const baselinePath = path.join(tmp, BASELINE_FILE_NAME);
fs.writeFileSync(baselinePath, '{not valid json', 'utf8');
const before = fs.readFileSync(baselinePath, 'utf8');
const r = readBaseline(tmp);
assert.equal(r.ok, false);
assert.equal(r.reason, REASON.REFACTOR_BASELINE_MALFORMED);
assert.deepEqual(r.baseline, {});
const after = fs.readFileSync(baselinePath, 'utf8');
assert.equal(after, before, 'a failing read must never rewrite the malformed file');
});
test('rejectsWrongShapedBaseline', (t) => {
const tmp = createTempDir('complexity-trigger-');
t.after(() => cleanup(tmp));
const baselinePath = path.join(tmp, BASELINE_FILE_NAME);
for (const bad of ['[]', '"a string"', 'null', '42']) {
fs.writeFileSync(baselinePath, bad, 'utf8');
const r = readBaseline(tmp);
assert.equal(r.ok, false, `shape ${bad} must be rejected`);
assert.equal(r.reason, REASON.REFACTOR_BASELINE_MALFORMED);
}
});
test('anchorsBaselineOnFirstObservation', () => {
const prev = {};
const analyzed = [{
file: 'a.js', ok: true, method: 'decision-points',
functions: [{ name: 'f', startLine: 1, endLine: 3, score: 6 }],
}];
const next = nextBaseline(prev, analyzed, { analyzedFiles: ['a.js'] });
assert.equal(next['a.js::f'].score, 6, 'first observation inserts the anchor at the current score');
});
test('doesNotAdvanceAnchorOnPlainEvaluate', () => {
const prev = { 'a.js::f': { score: 3 } };
const analyzed = [{
file: 'a.js', ok: true, method: 'decision-points',
functions: [{ name: 'f', startLine: 1, endLine: 3, score: 6 }],
}];
const next = nextBaseline(prev, analyzed, { analyzedFiles: ['a.js'] });
assert.equal(next['a.js::f'].score, 3, 'a stable anchor must not advance on a plain evaluate, even for a non-triggering function');
});
test('freezesBaselineWhileProposalUntriaged', () => {
// The anchor is unconditionally stable across evaluate — not merely
// "frozen because it triggered". This proves it holds even when the
// function is (still) triggering on a second evaluate.
const prev = { 'a.js::f': { score: 10 } };
const analyzed = [{
file: 'a.js', ok: true, method: 'decision-points',
functions: [{ name: 'f', startLine: 1, endLine: 5, score: DEFAULTS.threshold + 5 }],
}];
const evaluation = evaluateCandidates({ analyzed, baseline: prev });
assert.equal(evaluation.verdict, VERDICT.TRIGGERED);
const next = nextBaseline(prev, analyzed, { analyzedFiles: ['a.js'] });
assert.equal(next['a.js::f'], prev['a.js::f'], 'the anchor carries forward unchanged, not merely equal');
assert.equal(next['a.js::f'].score, 10);
});
test('reanchorsBaselineOnDisposition', () => {
// Proves reanchorBaseline is the ONLY thing that moves an anchor:
// nextBaseline leaves it untouched regardless of candidates; only an
// explicit disposition call moves it.
const key = 'src/a.cts::target';
const prev = { [key]: { score: 3 } };
const analyzed = [{
file: 'src/a.cts', ok: true, method: 'decision-points',
functions: [{ name: 'target', startLine: 1, endLine: 20, score: 11 }],
}];
const evaluation = evaluateCandidates({ analyzed, baseline: prev });
assert.equal(evaluation.verdict, VERDICT.TRIGGERED, 'setup sanity: the function does trigger, proving the freeze below is not merely "nothing to move"');
const afterEvaluate = nextBaseline(prev, analyzed, { analyzedFiles: ['src/a.cts'] });
assert.equal(afterEvaluate[key].score, 3, 'a plain evaluate must never move the anchor');
// Accept: re-anchor to a LOWER post-refactor score.
const afterAccept = reanchorBaseline(prev, key, 4);
assert.equal(afterAccept[key].score, 4, 'accept re-anchors to the post-refactor score');
// Decline: re-anchor to the current HIGHER score (debt consciously accepted).
const afterDecline = reanchorBaseline(prev, key, 11);
assert.equal(afterDecline[key].score, 11, 'decline re-anchors to the current score');
});
test('detectsIncrementalCreepAcrossRuns', () => {
const T = 15;
const D = 5;
const anchor = { 'a.js::f': { score: 8 } };
let baseline = anchor;
let firstTriggeringRound = null;
let firstTriggeringCandidate = null;
for (let round = 1; round <= 5; round += 1) {
const score = 6 + round * 2; // round1=8, round2=10, round3=12, round4=14, round5=16
const analyzed = [{
file: 'a.js', ok: true, method: 'decision-points',
functions: [{ name: 'f', startLine: 1, endLine: 5, score }],
}];
const evaluation = evaluateCandidates({ analyzed, baseline, threshold: T, jumpDelta: D });
if (firstTriggeringRound === null && evaluation.verdict === VERDICT.TRIGGERED) {
firstTriggeringRound = round;
firstTriggeringCandidate = evaluation.candidates[0];
}
baseline = nextBaseline(baseline, analyzed, { analyzedFiles: ['a.js'] });
}
// Anchor is stable at 8 throughout (a plain evaluate never advances it),
// so the delta is CUMULATIVE since the anchor: round1=0, round2=2,
// round3=4, round4=6, round5=8. Delta first exceeds D=5 at round 4
// (6 > 5) — one full run before the absolute score would cross T=15
// (score 16 at round 5). This is the point of the test: the jump-delta
// catches the creep earlier than, and independently of, the absolute
// threshold — it is not redundant with it.
assert.equal(firstTriggeringRound, 4);
assert.ok(
firstTriggeringCandidate.reasons.includes('jump'),
'round 4 must trigger via the jump reason, proving the jump-delta adds value over the absolute threshold',
);
});
test('prunesBaselineForDeletedFile', () => {
const analyzed = [{
file: 'a.js', ok: true, method: 'decision-points',
functions: [{ name: 'f', startLine: 1, endLine: 3, score: 5 }],
}];
const prev = { 'a.js::f': { score: 5 }, 'a.js::g': { score: 8 } };
const next = nextBaseline(prev, analyzed, { analyzedFiles: ['a.js'] });
assert.ok(!('a.js::g' in next), 'g no longer exists in the analyzed file; its baseline entry must be pruned');
assert.equal(next['a.js::f'].score, 5);
const prevWithUntouchedFile = { 'a.js::f': { score: 5 }, 'b.js::h': { score: 99 } };
const nextWithUntouchedFile = nextBaseline(prevWithUntouchedFile, analyzed, { analyzedFiles: ['a.js'] });
assert.equal(
nextWithUntouchedFile['b.js::h'].score,
99,
'an entry whose file is absent from analyzedFiles must never be pruned',
);
});
test('leavesNoPartialBaselineWhenWriteFails', (t) => {
const tmp = createTempDir('complexity-trigger-');
t.after(() => cleanup(tmp));
const writeMock = mock.method(fs, 'writeFileSync', () => {
const err = new Error('ENOSPC: no space left on device');
err.code = 'ENOSPC';
throw err;
});
t.after(() => writeMock.mock.restore());
const r = writeBaseline(tmp, { 'a.js::f': { score: 5 } });
assert.equal(r.ok, false);
assert.equal(r.reason, REASON.REFACTOR_BASELINE_WRITE_FAILED);
const leftover = fs.readdirSync(tmp).filter((n) => n.includes('.tmp'));
assert.deepEqual(leftover, [], 'no orphan tmp file must remain after write failure');
});
test('cleansUpTempFileWhenRenameFails', (t) => {
const tmp = createTempDir('complexity-trigger-');
t.after(() => cleanup(tmp));
const renameMock = mock.method(fs, 'renameSync', () => {
const err = new Error('EPERM: operation not permitted');
err.code = 'EPERM';
throw err;
});
t.after(() => renameMock.mock.restore());
const r = writeBaseline(tmp, { 'a.js::f': { score: 5 } });
assert.equal(r.ok, false);
assert.equal(r.reason, REASON.REFACTOR_BASELINE_WRITE_FAILED);
const leftover = fs.readdirSync(tmp).filter((n) => n.includes('.tmp'));
assert.deepEqual(leftover, [], 'the orphan .tmp file must be unlinked when rename fails');
});
test('degradesReadBaselineOnDeniedRead', (t) => {
// An injected EACCES on the baseline read must degrade readBaseline to
// { ok:false, baseline:{}, reason: REFACTOR_BASELINE_MALFORMED } without
// throwing.
const tmp = createTempDir('complexity-trigger-');
t.after(() => cleanup(tmp));
const readMock = mock.method(fs, 'readFileSync', () => {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
});
t.after(() => readMock.mock.restore());
const r = readBaseline(tmp);
assert.equal(r.ok, false);
assert.deepEqual(r.baseline, {});
assert.equal(r.reason, REASON.REFACTOR_BASELINE_MALFORMED);
});
});
// ---------------------------------------------------------------------------
// Typed surface — frozen enums and constants
// ---------------------------------------------------------------------------
describe('complexity-trigger: typed surface', () => {
test('locksReasonEnumKeys', () => {
const expected = [
'REFACTOR_ALREADY_DISPOSITIONED',
'REFACTOR_ANALYZER_UNPARSEABLE',
'REFACTOR_ANALYZER_UNSUPPORTED',
'REFACTOR_ARTIFACT_NOT_FOUND',
'REFACTOR_BASELINE_MALFORMED',
'REFACTOR_BASELINE_WRITE_FAILED',
'REFACTOR_DECLINE_REASON_EMPTY',
'REFACTOR_DISABLED',
'REFACTOR_FILE_UNREADABLE',
'REFACTOR_GIT_UNAVAILABLE',
'REFACTOR_INVALID_PHASE',
'REFACTOR_NO_TOUCHED_FILES',
'REFACTOR_OK',
'REFACTOR_STRICT_NOT_ENFORCING',
'REFACTOR_USAGE',
].sort();
assert.deepEqual(Object.keys(REASON).sort(), expected);
assert.ok(Object.isFrozen(REASON));
});
test('locksVerdictEnumKeys', () => {
const expected = ['BELOW_THRESHOLD', 'SKIPPED', 'TRIGGERED'].sort();
assert.deepEqual(Object.keys(VERDICT).sort(), expected);
assert.ok(Object.isFrozen(VERDICT));
});
test('exposes frozen DEFAULTS and ANALYZABLE_EXTENSIONS with documented values', () => {
assert.deepEqual(DEFAULTS, { threshold: 15, jumpDelta: 5 });
assert.ok(Object.isFrozen(DEFAULTS));
assert.deepEqual(ANALYZABLE_EXTENSIONS, ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']);
assert.ok(Object.isFrozen(ANALYZABLE_EXTENSIONS));
});
test('exposes the documented module-scoped constants', () => {
assert.equal(BASELINE_FILE_NAME, 'complexity-baseline.json');
assert.equal(PROPOSAL_SUFFIX, '-REFACTOR.md');
assert.equal(SCHEMA_VERSION, 1);
});
});