@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +508 -6
- package/README.md +77 -26
- package/bin/warden.ts +50 -0
- package/package.json +27 -5
- package/src/adapter-check.ts +136 -0
- package/src/ast.ts +28 -0
- package/src/cli.ts +1374 -103
- package/src/command.ts +953 -0
- package/src/config.ts +184 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +106 -22
- package/src/fix.ts +120 -0
- package/src/formatters.ts +79 -9
- package/src/guide.ts +245 -0
- package/src/index.ts +206 -14
- package/src/project-context.ts +163 -0
- package/src/resolve.ts +530 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/ast.ts +3176 -85
- package/src/rules/circular-refs.ts +154 -0
- package/src/rules/composes-declarations.ts +704 -0
- package/src/rules/context-no-surface-types.ts +68 -8
- package/src/rules/contour-exists.ts +251 -0
- package/src/rules/contour-ids.ts +15 -0
- package/src/rules/dead-internal-trail.ts +154 -0
- package/src/rules/draft-file-marking.ts +160 -0
- package/src/rules/draft-visible-debt.ts +87 -0
- package/src/rules/error-mapping-completeness.ts +288 -0
- package/src/rules/example-valid.ts +401 -0
- package/src/rules/fires-declarations.ts +758 -0
- package/src/rules/implementation-returns-result.ts +1265 -95
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +580 -0
- package/src/rules/index.ts +219 -18
- package/src/rules/intent-propagation.ts +127 -0
- package/src/rules/layer-field-name-drift.ts +96 -0
- package/src/rules/metadata.ts +654 -0
- package/src/rules/missing-reconcile.ts +98 -0
- package/src/rules/missing-visibility.ts +110 -0
- package/src/rules/no-destructured-compose.ts +192 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +7 -7
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +111 -0
- package/src/rules/no-redundant-result-error-wrap.ts +331 -0
- package/src/rules/no-retired-cross-vocabulary.ts +194 -0
- package/src/rules/no-sync-result-assumption.ts +1134 -99
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +10 -9
- package/src/rules/no-top-level-surface.ts +389 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +150 -0
- package/src/rules/owner-projection-parity.ts +146 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/public-export-example-coverage.ts +553 -0
- package/src/rules/public-internal-deep-imports.ts +517 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +187 -0
- package/src/rules/reference-exists.ts +98 -0
- package/src/rules/registry-names.ts +145 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +704 -0
- package/src/rules/resource-exists.ts +179 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +38 -25
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +191 -0
- package/src/rules/specs.ts +9 -5
- package/src/rules/static-resource-accessor-preference.ts +657 -0
- package/src/rules/surface-facet-coherence.ts +370 -0
- package/src/rules/trail-versioning-source.ts +1094 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +270 -6
- package/src/rules/unmaterialized-activation-source.ts +84 -0
- package/src/rules/unreachable-detour-shadowing.ts +344 -0
- package/src/rules/valid-describe-refs.ts +160 -32
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +533 -0
- package/src/rules/warden-rules-use-ast.ts +996 -0
- package/src/rules/webhook-route-collision.ts +243 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/contour-exists.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/error-mapping-completeness.trail.ts +29 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +78 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-projection-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +162 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +194 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-facet-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +45 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/wrap-rule.ts +213 -0
- package/src/workspaces.ts +238 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/cli.d.ts +0 -46
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -221
- package/dist/cli.js.map +0 -1
- package/dist/drift.d.ts +0 -26
- package/dist/drift.d.ts.map +0 -1
- package/dist/drift.js +0 -27
- package/dist/drift.js.map +0 -1
- package/dist/formatters.d.ts +0 -29
- package/dist/formatters.d.ts.map +0 -1
- package/dist/formatters.js +0 -87
- package/dist/formatters.js.map +0 -1
- package/dist/index.d.ts +0 -26
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -26
- package/dist/index.js.map +0 -1
- package/dist/rules/ast.d.ts +0 -41
- package/dist/rules/ast.d.ts.map +0 -1
- package/dist/rules/ast.js +0 -163
- package/dist/rules/ast.js.map +0 -1
- package/dist/rules/context-no-surface-types.d.ts +0 -12
- package/dist/rules/context-no-surface-types.d.ts.map +0 -1
- package/dist/rules/context-no-surface-types.js +0 -96
- package/dist/rules/context-no-surface-types.js.map +0 -1
- package/dist/rules/implementation-returns-result.d.ts +0 -13
- package/dist/rules/implementation-returns-result.d.ts.map +0 -1
- package/dist/rules/implementation-returns-result.js +0 -231
- package/dist/rules/implementation-returns-result.js.map +0 -1
- package/dist/rules/index.d.ts +0 -22
- package/dist/rules/index.d.ts.map +0 -1
- package/dist/rules/index.js +0 -41
- package/dist/rules/index.js.map +0 -1
- package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
- package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
- package/dist/rules/no-direct-impl-in-route.js +0 -46
- package/dist/rules/no-direct-impl-in-route.js.map +0 -1
- package/dist/rules/no-direct-implementation-call.d.ts +0 -12
- package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
- package/dist/rules/no-direct-implementation-call.js +0 -39
- package/dist/rules/no-direct-implementation-call.js.map +0 -1
- package/dist/rules/no-sync-result-assumption.d.ts +0 -6
- package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
- package/dist/rules/no-sync-result-assumption.js +0 -98
- package/dist/rules/no-sync-result-assumption.js.map +0 -1
- package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
- package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
- package/dist/rules/no-throw-in-detour-target.js +0 -87
- package/dist/rules/no-throw-in-detour-target.js.map +0 -1
- package/dist/rules/no-throw-in-implementation.d.ts +0 -9
- package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
- package/dist/rules/no-throw-in-implementation.js +0 -34
- package/dist/rules/no-throw-in-implementation.js.map +0 -1
- package/dist/rules/prefer-schema-inference.d.ts +0 -7
- package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
- package/dist/rules/prefer-schema-inference.js +0 -86
- package/dist/rules/prefer-schema-inference.js.map +0 -1
- package/dist/rules/scan.d.ts +0 -8
- package/dist/rules/scan.d.ts.map +0 -1
- package/dist/rules/scan.js +0 -32
- package/dist/rules/scan.js.map +0 -1
- package/dist/rules/specs.d.ts +0 -29
- package/dist/rules/specs.d.ts.map +0 -1
- package/dist/rules/specs.js +0 -192
- package/dist/rules/specs.js.map +0 -1
- package/dist/rules/structure.d.ts +0 -13
- package/dist/rules/structure.d.ts.map +0 -1
- package/dist/rules/structure.js +0 -142
- package/dist/rules/structure.js.map +0 -1
- package/dist/rules/types.d.ts +0 -52
- package/dist/rules/types.d.ts.map +0 -1
- package/dist/rules/types.js +0 -2
- package/dist/rules/types.js.map +0 -1
- package/dist/rules/valid-describe-refs.d.ts +0 -7
- package/dist/rules/valid-describe-refs.d.ts.map +0 -1
- package/dist/rules/valid-describe-refs.js +0 -51
- package/dist/rules/valid-describe-refs.js.map +0 -1
- package/dist/rules/valid-detour-refs.d.ts +0 -6
- package/dist/rules/valid-detour-refs.d.ts.map +0 -1
- package/dist/rules/valid-detour-refs.js +0 -116
- package/dist/rules/valid-detour-refs.js.map +0 -1
- package/src/__tests__/cli.test.ts +0 -198
- package/src/__tests__/drift.test.ts +0 -74
- package/src/__tests__/formatters.test.ts +0 -157
- package/src/__tests__/implementation-returns-result.test.ts +0 -75
- package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
- package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
- package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
- package/src/__tests__/prefer-schema-inference.test.ts +0 -84
- package/src/__tests__/rules.test.ts +0 -188
- package/src/__tests__/valid-describe-refs.test.ts +0 -60
- package/src/rules/no-direct-impl-in-route.ts +0 -77
- package/src/rules/no-throw-in-detour-target.ts +0 -150
- package/src/rules/valid-detour-refs.ts +0 -187
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-governance rule: warden rules must inspect the AST via the helpers in
|
|
3
|
+
* `./ast.ts` rather than regex-scanning raw source text. Raw-text scans
|
|
4
|
+
* produce false positives on string literals, template payloads, and
|
|
5
|
+
* docstrings — see TRL-335 and ADR-0036.
|
|
6
|
+
*
|
|
7
|
+
* Three detection families are enforced:
|
|
8
|
+
*
|
|
9
|
+
* 1. `rawScanSite` — string methods on a raw-source identifier, e.g.
|
|
10
|
+
* `sourceCode.split(/\n/)`, `rawText.match(...)`, `text.replace(...)`.
|
|
11
|
+
* 2. `regexScanSite` — regex-receiver methods consuming a raw-source
|
|
12
|
+
* argument, e.g. `/re/.test(sourceCode)`, `new RegExp(...).exec(text)`.
|
|
13
|
+
* 3. `regexConstructionSite` — constructing a regex directly from a raw
|
|
14
|
+
* source identifier, e.g. `new RegExp(sourceCode)`, `RegExp(rawText, 'g')`.
|
|
15
|
+
* Interpolating raw source into a regex constructor is the same class of
|
|
16
|
+
* bug as scanning with one — see TRL-345.
|
|
17
|
+
*
|
|
18
|
+
* This rule is path-anchored to this package's own `src/rules/` directory so
|
|
19
|
+
* it never fires against a consumer repo that happens to share the same
|
|
20
|
+
* folder layout. `ast.ts` itself is excluded because it IS the raw-text
|
|
21
|
+
* interface to the parser; `types.ts`, `index.ts`, `registry-names.ts`, and
|
|
22
|
+
* anything under `__tests__` are also excluded.
|
|
23
|
+
*/
|
|
24
|
+
import { basename as pathBasename, dirname, resolve, sep } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
import { offsetToLine, parse, walk } from './ast.js';
|
|
28
|
+
import type { AstNode } from './ast.js';
|
|
29
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
30
|
+
|
|
31
|
+
const RULE_NAME = 'warden-rules-use-ast';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Absolute path to this package's rules directory, resolved from the rule's
|
|
35
|
+
* own module URL. Anchoring to the real on-disk location prevents the rule
|
|
36
|
+
* from firing against a foreign `packages/warden/src/rules/` in a consumer
|
|
37
|
+
* repository that happens to share the same folder structure.
|
|
38
|
+
*
|
|
39
|
+
* Dist-layout safeguard: when this module is bundled/transpiled to `dist/`
|
|
40
|
+
* (e.g. `packages/warden/dist/rules/warden-rules-use-ast.js`), the files
|
|
41
|
+
* being linted still live under `src/rules/`. A strict equality check
|
|
42
|
+
* against only the dist directory would cause the rule to silently emit
|
|
43
|
+
* zero diagnostics — a silent no-op. To keep the anchor robust, we compute
|
|
44
|
+
* a source-equivalent dir by substituting `/dist/` with `/src/` on the
|
|
45
|
+
* resolved path and accept either. This preserves the anti-false-positive
|
|
46
|
+
* guarantee from TRL-341 (we still require an exact directory match, not a
|
|
47
|
+
* suffix match) while surviving a future bundling change.
|
|
48
|
+
*/
|
|
49
|
+
const SELF_MODULE_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replace only the LAST occurrence of `/dist/` with `/src/`. A blanket
|
|
53
|
+
* `replaceAll` over-substitutes on paths that contain other `/dist/`
|
|
54
|
+
* segments higher up (e.g. `/home/runner/dist-artifacts/warden/dist/rules/`
|
|
55
|
+
* would incorrectly become `/home/runner/src-artifacts/warden/src/rules/`,
|
|
56
|
+
* a nonexistent directory — silently defeating the rule).
|
|
57
|
+
*
|
|
58
|
+
* Exported for unit testing. Not part of the public rule API.
|
|
59
|
+
*/
|
|
60
|
+
export const replaceLastDistSegmentWithSrc = (path: string): string => {
|
|
61
|
+
const distSegment = `${sep}dist${sep}`;
|
|
62
|
+
const srcSegment = `${sep}src${sep}`;
|
|
63
|
+
const lastIdx = path.lastIndexOf(distSegment);
|
|
64
|
+
if (lastIdx === -1) {
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
path.slice(0, lastIdx) +
|
|
69
|
+
srcSegment +
|
|
70
|
+
path.slice(lastIdx + distSegment.length)
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const SELF_RULES_DIRS: ReadonlySet<string> = new Set(
|
|
75
|
+
SELF_MODULE_DIR.includes(`${sep}dist${sep}`)
|
|
76
|
+
? [SELF_MODULE_DIR, replaceLastDistSegmentWithSrc(SELF_MODULE_DIR)]
|
|
77
|
+
: [SELF_MODULE_DIR]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stems of files in `src/rules/` (and their bundled `dist/rules/` twins) that
|
|
82
|
+
* are NOT themselves warden rules and therefore must not be checked. `ast` is
|
|
83
|
+
* the raw-text interface to the parser and legitimately touches source text;
|
|
84
|
+
* the others are support modules without a `check()` function.
|
|
85
|
+
*/
|
|
86
|
+
const EXCLUDED_STEMS: readonly string[] = [
|
|
87
|
+
'ast',
|
|
88
|
+
'index',
|
|
89
|
+
'registry-names',
|
|
90
|
+
'scan',
|
|
91
|
+
'specs',
|
|
92
|
+
'structure',
|
|
93
|
+
'types',
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Both `.ts` (source layout) and `.js` (dist layout) basenames must be
|
|
98
|
+
* excluded so the rule stays silent when pointed at a bundled tree. The
|
|
99
|
+
* dist-layout `ast.js` contains the same raw-text parser entry point as
|
|
100
|
+
* `ast.ts` and would false-positive if scanned.
|
|
101
|
+
*/
|
|
102
|
+
const EXCLUDED_BASENAMES: ReadonlySet<string> = new Set(
|
|
103
|
+
EXCLUDED_STEMS.flatMap((stem) => [`${stem}.ts`, `${stem}.js`])
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const isTargetFile = (filePath: string): boolean => {
|
|
107
|
+
const absolute = resolve(filePath);
|
|
108
|
+
if (!SELF_RULES_DIRS.has(dirname(absolute))) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const basename = pathBasename(absolute);
|
|
112
|
+
if (EXCLUDED_BASENAMES.has(basename)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (basename.endsWith('.test.ts') || basename.endsWith('.test.js')) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Names of the WardenRule methods that receive raw source text as their
|
|
123
|
+
* first parameter. The first parameter's *actual binding name* (not a fixed
|
|
124
|
+
* list of names) is what we track via scope analysis — see `buildSourceParamIndex`.
|
|
125
|
+
*
|
|
126
|
+
* `checkTopo` does not receive raw source text (it takes a `Topo`) so it is
|
|
127
|
+
* intentionally excluded.
|
|
128
|
+
*/
|
|
129
|
+
const SOURCE_PARAM_METHOD_NAMES: ReadonlySet<string> = new Set([
|
|
130
|
+
'check',
|
|
131
|
+
'checkWithContext',
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* String methods that indicate raw-text *scanning* when called on a
|
|
136
|
+
* source-text identifier. Deliberately narrow: these are the patterns that
|
|
137
|
+
* produce false positives on string literals, template payloads, and
|
|
138
|
+
* docstrings — the regression TRL-333/TRL-334/TRL-335 fixed.
|
|
139
|
+
*
|
|
140
|
+
* Not flagged: `.slice`, `.substring`, `.indexOf`, `.includes`. These also
|
|
141
|
+
* take source text as input, but have legitimate AST-adjacent uses — e.g.
|
|
142
|
+
* `sourceCode.slice(node.start, node.end)` to recover a node's original
|
|
143
|
+
* text from an AST-resolved range, or `sourceCode.includes('marker')` as a
|
|
144
|
+
* fast-bail check before parsing. Flagging them would produce false
|
|
145
|
+
* positives on idiomatic rules.
|
|
146
|
+
*/
|
|
147
|
+
const RAW_SCAN_METHODS: ReadonlySet<string> = new Set([
|
|
148
|
+
'match',
|
|
149
|
+
'matchAll',
|
|
150
|
+
'replace',
|
|
151
|
+
'replaceAll',
|
|
152
|
+
'search',
|
|
153
|
+
'split',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Methods on a regex receiver that consume a raw-text argument. Flagged
|
|
158
|
+
* when the argument is a raw-source identifier (`sourceCode`, `text`, etc.).
|
|
159
|
+
*/
|
|
160
|
+
const REGEX_SCAN_METHODS: ReadonlySet<string> = new Set(['exec', 'test']);
|
|
161
|
+
|
|
162
|
+
const getIdentifierName = (node: AstNode | undefined): string | null => {
|
|
163
|
+
if (!node || node.type !== 'Identifier') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const { name } = node as unknown as { name?: string };
|
|
167
|
+
return typeof name === 'string' ? name : null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Scope-based source-param resolution. Each detector returns the
|
|
172
|
+
* candidate `Identifier` AST node that must resolve to the enclosing
|
|
173
|
+
* `check` / `checkWithContext` method's first parameter binding — i.e.
|
|
174
|
+
* not shadowed by any intervening `const`/`let`/`var`/param declaration.
|
|
175
|
+
* See `resolvesToSourceParam` for the walk.
|
|
176
|
+
*/
|
|
177
|
+
interface RawScanSite {
|
|
178
|
+
readonly methodName: string;
|
|
179
|
+
readonly identifier: AstNode;
|
|
180
|
+
readonly identifierName: string;
|
|
181
|
+
readonly start: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface MemberCallParts {
|
|
185
|
+
readonly object: AstNode | undefined;
|
|
186
|
+
readonly property: AstNode | undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extract the `object`/`property` of a non-computed member call, or null
|
|
191
|
+
* for anything else. Keeps `rawScanSite` under the max-statements budget.
|
|
192
|
+
*/
|
|
193
|
+
const memberCallParts = (node: AstNode): MemberCallParts | null => {
|
|
194
|
+
if (node.type !== 'CallExpression') {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const { callee } = node as unknown as { callee?: AstNode };
|
|
198
|
+
if (
|
|
199
|
+
!callee ||
|
|
200
|
+
(callee.type !== 'MemberExpression' &&
|
|
201
|
+
callee.type !== 'StaticMemberExpression')
|
|
202
|
+
) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const { object, property, computed } = callee as unknown as {
|
|
206
|
+
object?: AstNode;
|
|
207
|
+
property?: AstNode;
|
|
208
|
+
computed?: boolean;
|
|
209
|
+
};
|
|
210
|
+
return computed ? null : { object, property };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const rawScanSite = (node: AstNode): RawScanSite | null => {
|
|
214
|
+
const parts = memberCallParts(node);
|
|
215
|
+
if (!parts || !parts.object) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const receiver = getIdentifierName(parts.object);
|
|
219
|
+
if (!receiver) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const methodName = getIdentifierName(parts.property);
|
|
223
|
+
if (!methodName || !RAW_SCAN_METHODS.has(methodName)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
identifier: parts.object,
|
|
228
|
+
identifierName: receiver,
|
|
229
|
+
methodName,
|
|
230
|
+
start: node.start,
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* True when `node` is a regex-producing expression: a regex literal
|
|
236
|
+
* (`/foo/`), `new RegExp(...)`, or a plain `RegExp(...)` call.
|
|
237
|
+
*/
|
|
238
|
+
const isRegexProducer = (node: AstNode | undefined): boolean => {
|
|
239
|
+
if (!node) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
if (node.type === 'Literal' && 'regex' in node && node['regex']) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (node.type === 'RegExpLiteral') {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (node.type === 'NewExpression' || node.type === 'CallExpression') {
|
|
249
|
+
const { callee } = node as unknown as { callee?: AstNode };
|
|
250
|
+
return getIdentifierName(callee) === 'RegExp';
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* First identifier argument of a call expression, or null. The returned
|
|
257
|
+
* identifier must still resolve to a tracked source-param binding (see
|
|
258
|
+
* `resolvesToSourceParam`) before a diagnostic is emitted — this pre-filter
|
|
259
|
+
* only narrows the candidate arg.
|
|
260
|
+
*/
|
|
261
|
+
const firstIdentifierArgument = (
|
|
262
|
+
node: AstNode
|
|
263
|
+
): { identifier: AstNode; name: string } | null => {
|
|
264
|
+
const args = (node as unknown as { arguments?: readonly AstNode[] })
|
|
265
|
+
.arguments;
|
|
266
|
+
if (!args) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
for (const arg of args) {
|
|
270
|
+
const name = getIdentifierName(arg);
|
|
271
|
+
if (name) {
|
|
272
|
+
return { identifier: arg, name };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const regexScanMethodName = (parts: MemberCallParts): string | null => {
|
|
279
|
+
if (!isRegexProducer(parts.object)) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const methodName = getIdentifierName(parts.property);
|
|
283
|
+
if (!methodName || !REGEX_SCAN_METHODS.has(methodName)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
return methodName;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Detects `/regex/.test(sourceCode)`, `new RegExp(...).exec(text)`, and
|
|
291
|
+
* similar regex-receiver calls that consume a raw-source identifier.
|
|
292
|
+
*/
|
|
293
|
+
const regexScanSite = (node: AstNode): RawScanSite | null => {
|
|
294
|
+
const parts = memberCallParts(node);
|
|
295
|
+
if (!parts) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const methodName = regexScanMethodName(parts);
|
|
299
|
+
if (!methodName) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const arg = firstIdentifierArgument(node);
|
|
303
|
+
if (!arg) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
identifier: arg.identifier,
|
|
308
|
+
identifierName: arg.name,
|
|
309
|
+
methodName,
|
|
310
|
+
start: node.start,
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
interface RegexConstructionSite {
|
|
315
|
+
readonly kind: 'new' | 'call';
|
|
316
|
+
readonly identifier: AstNode;
|
|
317
|
+
readonly identifierName: string;
|
|
318
|
+
readonly start: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Detects `new RegExp(sourceCode)` / `RegExp(rawText, 'g')` — constructing a
|
|
323
|
+
* regex from raw source text. Same anti-pattern family as
|
|
324
|
+
* `sourceCode.match(...)` and `/re/.test(sourceCode)`: raw source fed into a
|
|
325
|
+
* scanner. Fires when the callee is an `Identifier` named `RegExp` and at
|
|
326
|
+
* least one argument is an identifier that resolves, via scope analysis, to
|
|
327
|
+
* the enclosing `check` / `checkWithContext` method's first parameter.
|
|
328
|
+
*/
|
|
329
|
+
const regexConstructionSite = (node: AstNode): RegexConstructionSite | null => {
|
|
330
|
+
if (node.type !== 'NewExpression' && node.type !== 'CallExpression') {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const { callee } = node as unknown as { callee?: AstNode };
|
|
334
|
+
if (getIdentifierName(callee) !== 'RegExp') {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const arg = firstIdentifierArgument(node);
|
|
338
|
+
if (!arg) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
identifier: arg.identifier,
|
|
343
|
+
identifierName: arg.name,
|
|
344
|
+
kind: node.type === 'NewExpression' ? 'new' : 'call',
|
|
345
|
+
start: node.start,
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const DIAGNOSTIC_ADVICE =
|
|
350
|
+
'Warden rules must inspect the AST via packages/warden/src/rules/ast.ts helpers, not regex-scan raw source text. ' +
|
|
351
|
+
'Use findStringLiterals, findTrailDefinitions, findConfigProperty, or a similar AST walker. ' +
|
|
352
|
+
'Raw-text scanning produces false positives on string literals, template payloads, and docstrings — see TRL-335, ADR-0036.';
|
|
353
|
+
|
|
354
|
+
interface DetectedSite {
|
|
355
|
+
readonly identifier: AstNode;
|
|
356
|
+
readonly message: string;
|
|
357
|
+
readonly start: number;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const detectRawScan = (node: AstNode): DetectedSite | null => {
|
|
361
|
+
const scan = rawScanSite(node);
|
|
362
|
+
if (!scan) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
identifier: scan.identifier,
|
|
367
|
+
message: `${RULE_NAME}: ${scan.identifierName}.${scan.methodName}(...) treats source text as a string. ${DIAGNOSTIC_ADVICE}`,
|
|
368
|
+
start: scan.start,
|
|
369
|
+
};
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const detectRegexScan = (node: AstNode): DetectedSite | null => {
|
|
373
|
+
const regex = regexScanSite(node);
|
|
374
|
+
if (!regex) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
identifier: regex.identifier,
|
|
379
|
+
message: `${RULE_NAME}: regex.${regex.methodName}(${regex.identifierName}) scans raw source text. ${DIAGNOSTIC_ADVICE}`,
|
|
380
|
+
start: regex.start,
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const detectRegexConstruction = (node: AstNode): DetectedSite | null => {
|
|
385
|
+
const construction = regexConstructionSite(node);
|
|
386
|
+
if (!construction) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const prefix = construction.kind === 'new' ? 'new RegExp' : 'RegExp';
|
|
390
|
+
return {
|
|
391
|
+
identifier: construction.identifier,
|
|
392
|
+
message: `${RULE_NAME}: ${prefix}(${construction.identifierName}) constructs a regex from raw source text. ${DIAGNOSTIC_ADVICE}`,
|
|
393
|
+
start: construction.start,
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Dispatch chain for per-node detectors. Each detector tries one family in
|
|
399
|
+
* priority order. First match wins; descent into children still happens so
|
|
400
|
+
* nested offenses (e.g. a regex scan inside a callback passed to a raw-text
|
|
401
|
+
* scan) are still caught.
|
|
402
|
+
*/
|
|
403
|
+
const DETECTORS: readonly ((node: AstNode) => DetectedSite | null)[] = [
|
|
404
|
+
detectRawScan,
|
|
405
|
+
detectRegexScan,
|
|
406
|
+
detectRegexConstruction,
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
const detectSite = (node: AstNode): DetectedSite | null => {
|
|
410
|
+
for (const detector of DETECTORS) {
|
|
411
|
+
const site = detector(node);
|
|
412
|
+
if (site) {
|
|
413
|
+
return site;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Scope analysis (Option A — parameter-origin tracking).
|
|
421
|
+
//
|
|
422
|
+
// The pre-TRL-346 detectors gated on identifier spelling alone (a fixed set
|
|
423
|
+
// like `sourceCode`, `text`, `source`, `rawText`). That over-fires on
|
|
424
|
+
// unrelated locals that happen to share one of those names, and under-fires
|
|
425
|
+
// when a rule author picks a different name for the source parameter.
|
|
426
|
+
//
|
|
427
|
+
// Option A walks the AST with a scope stack, records the first parameter of
|
|
428
|
+
// any `check` / `checkWithContext` method (its *actual binding name*), and
|
|
429
|
+
// only flags a call site when the candidate identifier still refers to that
|
|
430
|
+
// exact binding — i.e. no intervening `const`/`let`/`var`/param has
|
|
431
|
+
// shadowed it.
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
interface Scope {
|
|
435
|
+
readonly declaredNames: ReadonlySet<string>;
|
|
436
|
+
readonly sourceParamName: string | null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Walk inner→outer. The first scope that declares `name` is the binding; the
|
|
441
|
+
* identifier resolves to a tracked source-param only when that declaring
|
|
442
|
+
* scope's `sourceParamName` matches. An identifier with no declaring scope
|
|
443
|
+
* (e.g. a free variable) is not a source-param binding.
|
|
444
|
+
*/
|
|
445
|
+
const resolvesToSourceParam = (
|
|
446
|
+
name: string,
|
|
447
|
+
scopes: readonly Scope[]
|
|
448
|
+
): boolean => {
|
|
449
|
+
for (let i = scopes.length - 1; i >= 0; i -= 1) {
|
|
450
|
+
const scope = scopes[i];
|
|
451
|
+
if (scope && scope.declaredNames.has(name)) {
|
|
452
|
+
return scope.sourceParamName === name;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const FUNCTION_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
459
|
+
'ArrowFunctionExpression',
|
|
460
|
+
'FunctionDeclaration',
|
|
461
|
+
'FunctionExpression',
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Name of a function-like node when it is a recognized WardenRule method
|
|
466
|
+
* (`check` or `checkWithContext`). Returns null otherwise.
|
|
467
|
+
*
|
|
468
|
+
* Handles three shapes:
|
|
469
|
+
* - object-literal method shorthand: `{ check(sc) { ... } }`
|
|
470
|
+
* (Property with `method: true`, or MethodDefinition)
|
|
471
|
+
* - arrow/function property: `{ check: (sc) => { ... } }`
|
|
472
|
+
* - top-level function declaration: `function check(sc) { ... }`
|
|
473
|
+
*
|
|
474
|
+
* The context-to-function link is resolved by the caller via the
|
|
475
|
+
* `methodFunctionStarts` map: we pre-walk the AST once to map the start
|
|
476
|
+
* offset of every recognized function to its method name, then consult the
|
|
477
|
+
* map when the scope walker enters that function.
|
|
478
|
+
*/
|
|
479
|
+
const methodNameFromKey = (key: AstNode | undefined): string | null => {
|
|
480
|
+
if (!key) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
if (key.type === 'Identifier') {
|
|
484
|
+
return (key as unknown as { name?: string }).name ?? null;
|
|
485
|
+
}
|
|
486
|
+
// String-literal keys like `'check': (sc) => { ... }`.
|
|
487
|
+
if (
|
|
488
|
+
(key.type === 'Literal' || key.type === 'StringLiteral') &&
|
|
489
|
+
typeof (key as unknown as { value?: unknown }).value === 'string'
|
|
490
|
+
) {
|
|
491
|
+
return (key as unknown as { value: string }).value;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const firstParamIdentifierName = (fn: AstNode): string | null => {
|
|
497
|
+
const { params } = fn as unknown as { params?: readonly AstNode[] };
|
|
498
|
+
const [first] = params ?? [];
|
|
499
|
+
if (!first) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
if (first.type === 'Identifier') {
|
|
503
|
+
return getIdentifierName(first);
|
|
504
|
+
}
|
|
505
|
+
if (first.type === 'AssignmentPattern') {
|
|
506
|
+
const { left } = first as unknown as { left?: AstNode };
|
|
507
|
+
return left?.type === 'Identifier' ? getIdentifierName(left) : null;
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Collect start offsets of function-like AST nodes that represent the body
|
|
514
|
+
* of a recognized WardenRule source-receiving method. Value is the declared
|
|
515
|
+
* first-parameter name, used as `sourceParamName` when the scope walker
|
|
516
|
+
* pushes that function's scope.
|
|
517
|
+
*/
|
|
518
|
+
const methodPropertyFunction = (
|
|
519
|
+
node: AstNode
|
|
520
|
+
): { fn: AstNode; name: string } | null => {
|
|
521
|
+
const { key, value } = node as unknown as {
|
|
522
|
+
key?: AstNode;
|
|
523
|
+
value?: AstNode;
|
|
524
|
+
};
|
|
525
|
+
const name = methodNameFromKey(key);
|
|
526
|
+
if (!name || !value || !FUNCTION_NODE_TYPES.has(value.type)) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
return SOURCE_PARAM_METHOD_NAMES.has(name) ? { fn: value, name } : null;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const namedFunctionDeclaration = (
|
|
533
|
+
node: AstNode
|
|
534
|
+
): { fn: AstNode; name: string } | null => {
|
|
535
|
+
const name = getIdentifierName((node as unknown as { id?: AstNode }).id);
|
|
536
|
+
if (!name || !SOURCE_PARAM_METHOD_NAMES.has(name)) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
return { fn: node, name };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const recognizedMethodFunction = (
|
|
543
|
+
node: AstNode
|
|
544
|
+
): { fn: AstNode; name: string } | null => {
|
|
545
|
+
if (node.type === 'Property' || node.type === 'MethodDefinition') {
|
|
546
|
+
return methodPropertyFunction(node);
|
|
547
|
+
}
|
|
548
|
+
if (node.type === 'FunctionDeclaration') {
|
|
549
|
+
return namedFunctionDeclaration(node);
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const buildSourceParamIndex = (ast: AstNode): ReadonlyMap<number, string> => {
|
|
555
|
+
const index = new Map<number, string>();
|
|
556
|
+
walk(ast, (node) => {
|
|
557
|
+
const recognized = recognizedMethodFunction(node);
|
|
558
|
+
if (!recognized) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const paramName = firstParamIdentifierName(recognized.fn);
|
|
562
|
+
if (paramName) {
|
|
563
|
+
index.set(recognized.fn.start, paramName);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
return index;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Collect identifier names introduced at this scope by
|
|
571
|
+
* `const`/`let`/`var`/function declarations or function params. We only
|
|
572
|
+
* inspect direct children — nested block statements and nested functions
|
|
573
|
+
* have their own scopes.
|
|
574
|
+
*/
|
|
575
|
+
const expandObjectPatternProperty = (property: AstNode): readonly AstNode[] => {
|
|
576
|
+
if (property.type === 'Property') {
|
|
577
|
+
const { value } = property as unknown as { value?: AstNode };
|
|
578
|
+
return value ? [value] : [];
|
|
579
|
+
}
|
|
580
|
+
if (property.type === 'RestElement') {
|
|
581
|
+
const { argument } = property as unknown as { argument?: AstNode };
|
|
582
|
+
return argument ? [argument] : [];
|
|
583
|
+
}
|
|
584
|
+
return [];
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const PATTERN_EXPANDERS: Record<string, (p: AstNode) => readonly AstNode[]> = {
|
|
588
|
+
ArrayPattern: (pattern) => {
|
|
589
|
+
const elements =
|
|
590
|
+
(pattern as unknown as { elements?: readonly (AstNode | null)[] })
|
|
591
|
+
.elements ?? [];
|
|
592
|
+
return elements.filter((el): el is AstNode => el !== null);
|
|
593
|
+
},
|
|
594
|
+
AssignmentPattern: (pattern) => {
|
|
595
|
+
const { left } = pattern as unknown as { left?: AstNode };
|
|
596
|
+
return left ? [left] : [];
|
|
597
|
+
},
|
|
598
|
+
ObjectPattern: (pattern) => {
|
|
599
|
+
const properties =
|
|
600
|
+
(pattern as unknown as { properties?: readonly AstNode[] }).properties ??
|
|
601
|
+
[];
|
|
602
|
+
return properties.flatMap(expandObjectPatternProperty);
|
|
603
|
+
},
|
|
604
|
+
RestElement: (pattern) => {
|
|
605
|
+
const { argument } = pattern as unknown as { argument?: AstNode };
|
|
606
|
+
return argument ? [argument] : [];
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Collect identifier names introduced by a binding pattern (function
|
|
612
|
+
* parameter, destructuring target, etc.). Iterative worklist over
|
|
613
|
+
* {@link PATTERN_EXPANDERS}: each expander yields one level of child
|
|
614
|
+
* patterns, and the loop bottoms out at `Identifier` nodes. The iterative
|
|
615
|
+
* shape avoids mutual recursion so every helper stays under the
|
|
616
|
+
* `max-statements` budget.
|
|
617
|
+
*/
|
|
618
|
+
const visitPatternNode = (
|
|
619
|
+
current: AstNode,
|
|
620
|
+
into: Set<string>,
|
|
621
|
+
worklist: AstNode[]
|
|
622
|
+
): void => {
|
|
623
|
+
if (current.type === 'Identifier') {
|
|
624
|
+
const name = getIdentifierName(current);
|
|
625
|
+
if (name) {
|
|
626
|
+
into.add(name);
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const expand = PATTERN_EXPANDERS[current.type];
|
|
631
|
+
if (expand) {
|
|
632
|
+
worklist.push(...expand(current));
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const collectBindingIdsFromPattern = (
|
|
637
|
+
pattern: AstNode | undefined,
|
|
638
|
+
into: Set<string>
|
|
639
|
+
): void => {
|
|
640
|
+
if (!pattern) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const worklist: AstNode[] = [pattern];
|
|
644
|
+
while (worklist.length > 0) {
|
|
645
|
+
const current = worklist.pop();
|
|
646
|
+
if (current) {
|
|
647
|
+
visitPatternNode(current, into, worklist);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
interface FunctionScopeBindingsEx {
|
|
653
|
+
/** Param names exactly — used to identify the source-param binding. */
|
|
654
|
+
readonly paramNames: Set<string>;
|
|
655
|
+
/** Hoisted `var` names inside the function body. May overlap with params. */
|
|
656
|
+
readonly hoistedVarNames: Set<string>;
|
|
657
|
+
/** Combined set of declared names visible in this function scope. */
|
|
658
|
+
readonly declaredNames: Set<string>;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const collectParamBindingsFromFunction = (fn: AstNode): Set<string> => {
|
|
662
|
+
const paramNames = new Set<string>();
|
|
663
|
+
const params =
|
|
664
|
+
(fn as unknown as { params?: readonly AstNode[] }).params ?? [];
|
|
665
|
+
for (const param of params) {
|
|
666
|
+
collectBindingIdsFromPattern(param, paramNames);
|
|
667
|
+
}
|
|
668
|
+
return paramNames;
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const collectHoistedVarsFromFunctionBody = (fn: AstNode): Set<string> => {
|
|
672
|
+
const hoistedVarNames = new Set<string>();
|
|
673
|
+
const { body } = fn as unknown as { body?: AstNode };
|
|
674
|
+
if (body && typeof body === 'object' && (body as AstNode).type) {
|
|
675
|
+
// eslint-disable-next-line no-use-before-define
|
|
676
|
+
collectHoistedVarBindings(body, hoistedVarNames);
|
|
677
|
+
}
|
|
678
|
+
return hoistedVarNames;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Combine param names and body-level hoisted `var` names into a single
|
|
683
|
+
* declared-names set, while keeping the two subsets addressable. The
|
|
684
|
+
* hoisted set is kept separate so the scope walker can tell whether a
|
|
685
|
+
* source-param identity has been overwritten by a same-named hoisted local —
|
|
686
|
+
* a shadow that would otherwise be invisible because both names live in the
|
|
687
|
+
* same function-scope binding set.
|
|
688
|
+
*/
|
|
689
|
+
const collectFunctionScopeBindingsEx = (
|
|
690
|
+
fn: AstNode
|
|
691
|
+
): FunctionScopeBindingsEx => {
|
|
692
|
+
const paramNames = collectParamBindingsFromFunction(fn);
|
|
693
|
+
const hoistedVarNames = collectHoistedVarsFromFunctionBody(fn);
|
|
694
|
+
const declaredNames = new Set<string>(paramNames);
|
|
695
|
+
for (const name of hoistedVarNames) {
|
|
696
|
+
declaredNames.add(name);
|
|
697
|
+
}
|
|
698
|
+
return { declaredNames, hoistedVarNames, paramNames };
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const addVariableDeclarationNames = (
|
|
702
|
+
stmt: AstNode,
|
|
703
|
+
names: Set<string>
|
|
704
|
+
): void => {
|
|
705
|
+
const declarations =
|
|
706
|
+
(stmt as unknown as { declarations?: readonly AstNode[] }).declarations ??
|
|
707
|
+
[];
|
|
708
|
+
for (const decl of declarations) {
|
|
709
|
+
collectBindingIdsFromPattern(
|
|
710
|
+
(decl as unknown as { id?: AstNode }).id,
|
|
711
|
+
names
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const isVarVariableDeclaration = (stmt: AstNode): boolean =>
|
|
717
|
+
stmt.type === 'VariableDeclaration' &&
|
|
718
|
+
(stmt as unknown as { kind?: string }).kind === 'var';
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* True when `node` owns its own VariableEnvironment and therefore stops `var`
|
|
722
|
+
* hoisting from composing into the enclosing function/program scope.
|
|
723
|
+
*/
|
|
724
|
+
const ownsVariableEnvironmentForHoisting = (node: AstNode): boolean =>
|
|
725
|
+
FUNCTION_NODE_TYPES.has(node.type) || node.type === 'StaticBlock';
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Collect `var` declarations that hoist to the nearest function (or program)
|
|
729
|
+
* scope from anywhere inside `root`, without composing a nested function or
|
|
730
|
+
* static-block boundary. Mirrors the hoisting semantics used by
|
|
731
|
+
* {@link ./no-sync-result-assumption.ts} so `if (cond) { var sourceCode = ... }`
|
|
732
|
+
* inside a `check()` body correctly shadows the method's first parameter.
|
|
733
|
+
*/
|
|
734
|
+
const collectHoistedVarBindings = (root: AstNode, out: Set<string>): void => {
|
|
735
|
+
const walkVar = (node: AstNode, isRoot: boolean): void => {
|
|
736
|
+
if (!isRoot && ownsVariableEnvironmentForHoisting(node)) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (isVarVariableDeclaration(node)) {
|
|
740
|
+
addVariableDeclarationNames(node, out);
|
|
741
|
+
}
|
|
742
|
+
for (const val of Object.values(node)) {
|
|
743
|
+
if (Array.isArray(val)) {
|
|
744
|
+
for (const item of val) {
|
|
745
|
+
if (item && typeof item === 'object' && (item as AstNode).type) {
|
|
746
|
+
walkVar(item as AstNode, false);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} else if (val && typeof val === 'object' && (val as AstNode).type) {
|
|
750
|
+
walkVar(val as AstNode, false);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
walkVar(root, true);
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const addFunctionDeclarationName = (
|
|
758
|
+
stmt: AstNode,
|
|
759
|
+
names: Set<string>
|
|
760
|
+
): void => {
|
|
761
|
+
const name = getIdentifierName((stmt as unknown as { id?: AstNode }).id);
|
|
762
|
+
if (name) {
|
|
763
|
+
names.add(name);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const collectBlockDeclarationNames = (block: AstNode): Set<string> => {
|
|
768
|
+
const names = new Set<string>();
|
|
769
|
+
const body = (block as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
770
|
+
for (const stmt of body) {
|
|
771
|
+
// `var` is function-scoped, not block-scoped — hoisted into the nearest
|
|
772
|
+
// enclosing function (or program) scope by
|
|
773
|
+
// {@link collectHoistedVarBindings}. Registering it here would make
|
|
774
|
+
// `if (cond) { var x = ... }` look block-local and fail to shadow a
|
|
775
|
+
// same-named outer binding when the write escapes the block.
|
|
776
|
+
if (
|
|
777
|
+
stmt.type === 'VariableDeclaration' &&
|
|
778
|
+
!isVarVariableDeclaration(stmt)
|
|
779
|
+
) {
|
|
780
|
+
addVariableDeclarationNames(stmt, names);
|
|
781
|
+
} else if (stmt.type === 'FunctionDeclaration') {
|
|
782
|
+
addFunctionDeclarationName(stmt, names);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return names;
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Collect the names a `CatchClause` parameter introduces into its body. The
|
|
790
|
+
* catch clause has its own binding scope distinct from the surrounding block;
|
|
791
|
+
* without this, `try {} catch (sourceCode) { sourceCode.split(...) }` would
|
|
792
|
+
* resolve `sourceCode` to the enclosing `check()` parameter and fire.
|
|
793
|
+
*/
|
|
794
|
+
const collectCatchClauseDeclarationNames = (node: AstNode): Set<string> => {
|
|
795
|
+
const names = new Set<string>();
|
|
796
|
+
const { param } = node as unknown as { param?: AstNode };
|
|
797
|
+
collectBindingIdsFromPattern(param, names);
|
|
798
|
+
return names;
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
interface ScopeWalkContext {
|
|
802
|
+
readonly diagnostics: WardenDiagnostic[];
|
|
803
|
+
readonly filePath: string;
|
|
804
|
+
readonly methodFunctionStarts: ReadonlyMap<number, string>;
|
|
805
|
+
readonly sourceCode: string;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const recordDiagnostic = (ctx: ScopeWalkContext, site: DetectedSite): void => {
|
|
809
|
+
ctx.diagnostics.push({
|
|
810
|
+
filePath: ctx.filePath,
|
|
811
|
+
line: offsetToLine(ctx.sourceCode, site.start),
|
|
812
|
+
message: site.message,
|
|
813
|
+
rule: RULE_NAME,
|
|
814
|
+
severity: 'error' as const,
|
|
815
|
+
});
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Emit a diagnostic if `node` is a detected site whose candidate identifier
|
|
820
|
+
* resolves (via the current scope stack) to a tracked source-param binding.
|
|
821
|
+
*/
|
|
822
|
+
const maybeRecordDetection = (
|
|
823
|
+
node: AstNode,
|
|
824
|
+
scopes: readonly Scope[],
|
|
825
|
+
ctx: ScopeWalkContext
|
|
826
|
+
): void => {
|
|
827
|
+
const site = detectSite(node);
|
|
828
|
+
if (!site) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const name = getIdentifierName(site.identifier);
|
|
832
|
+
if (name && resolvesToSourceParam(name, scopes)) {
|
|
833
|
+
recordDiagnostic(ctx, site);
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Resolve the effective source-param name for a function scope. A hoisted
|
|
839
|
+
* `var` with the same name as the source param overwrites the param's slot
|
|
840
|
+
* in the function's VariableEnvironment, so the enclosing identifier no
|
|
841
|
+
* longer resolves to the raw-source binding.
|
|
842
|
+
*/
|
|
843
|
+
const resolveScopeSourceParamName = (
|
|
844
|
+
methodParamName: string | null,
|
|
845
|
+
hoistedVarNames: ReadonlySet<string>
|
|
846
|
+
): string | null =>
|
|
847
|
+
methodParamName && hoistedVarNames.has(methodParamName)
|
|
848
|
+
? null
|
|
849
|
+
: methodParamName;
|
|
850
|
+
|
|
851
|
+
const pushFunctionScope = (
|
|
852
|
+
node: AstNode,
|
|
853
|
+
ctx: ScopeWalkContext,
|
|
854
|
+
scopes: Scope[]
|
|
855
|
+
): void => {
|
|
856
|
+
const methodParamName = ctx.methodFunctionStarts.get(node.start) ?? null;
|
|
857
|
+
const { declaredNames, hoistedVarNames } =
|
|
858
|
+
collectFunctionScopeBindingsEx(node);
|
|
859
|
+
scopes.push({
|
|
860
|
+
declaredNames,
|
|
861
|
+
sourceParamName: resolveScopeSourceParamName(
|
|
862
|
+
methodParamName,
|
|
863
|
+
hoistedVarNames
|
|
864
|
+
),
|
|
865
|
+
});
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
/** Collector for scope frames that carry no source-param identity. */
|
|
869
|
+
const SIMPLE_SCOPE_COLLECTORS: Record<
|
|
870
|
+
string,
|
|
871
|
+
(node: AstNode) => ReadonlySet<string>
|
|
872
|
+
> = {
|
|
873
|
+
BlockStatement: collectBlockDeclarationNames,
|
|
874
|
+
CatchClause: collectCatchClauseDeclarationNames,
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Push the scope a function node introduces, or null when the node is not
|
|
879
|
+
* scope-introducing. Returning a dispose function keeps `visitWithScopes`
|
|
880
|
+
* small and keeps the scope stack strictly paired.
|
|
881
|
+
*/
|
|
882
|
+
const enterScopeForNode = (
|
|
883
|
+
node: AstNode,
|
|
884
|
+
ctx: ScopeWalkContext,
|
|
885
|
+
scopes: Scope[]
|
|
886
|
+
): boolean => {
|
|
887
|
+
if (FUNCTION_NODE_TYPES.has(node.type)) {
|
|
888
|
+
pushFunctionScope(node, ctx, scopes);
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
const collector = SIMPLE_SCOPE_COLLECTORS[node.type];
|
|
892
|
+
if (collector) {
|
|
893
|
+
scopes.push({
|
|
894
|
+
declaredNames: collector(node),
|
|
895
|
+
sourceParamName: null,
|
|
896
|
+
});
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
return false;
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
interface EnterFrame {
|
|
903
|
+
kind: 'enter';
|
|
904
|
+
node: AstNode;
|
|
905
|
+
}
|
|
906
|
+
type WalkFrame = EnterFrame | { kind: 'leave'; pushed: boolean };
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Build "enter" frames for every AST child of `node`. Returned reversed so
|
|
910
|
+
* consumers can `Array#push(...frames)` onto a stack and still visit
|
|
911
|
+
* children in source order via `Array#pop`.
|
|
912
|
+
*/
|
|
913
|
+
const collectChildFrames = (node: AstNode): readonly EnterFrame[] => {
|
|
914
|
+
const frames: EnterFrame[] = [];
|
|
915
|
+
for (const value of Object.values(node)) {
|
|
916
|
+
if (Array.isArray(value)) {
|
|
917
|
+
for (const item of value) {
|
|
918
|
+
if (item && typeof item === 'object' && (item as AstNode).type) {
|
|
919
|
+
frames.push({ kind: 'enter', node: item as AstNode });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (value && typeof value === 'object' && (value as AstNode).type) {
|
|
925
|
+
frames.push({ kind: 'enter', node: value as AstNode });
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return frames.toReversed();
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const processFrame = (
|
|
932
|
+
frame: WalkFrame,
|
|
933
|
+
scopes: Scope[],
|
|
934
|
+
ctx: ScopeWalkContext,
|
|
935
|
+
stack: WalkFrame[]
|
|
936
|
+
): void => {
|
|
937
|
+
if (frame.kind === 'leave') {
|
|
938
|
+
if (frame.pushed) {
|
|
939
|
+
scopes.pop();
|
|
940
|
+
}
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const { node } = frame;
|
|
944
|
+
maybeRecordDetection(node, scopes, ctx);
|
|
945
|
+
const pushed = enterScopeForNode(node, ctx, scopes);
|
|
946
|
+
stack.push({ kind: 'leave', pushed });
|
|
947
|
+
stack.push(...collectChildFrames(node));
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Scope-aware AST walker. Iterative DFS: each enter frame schedules the
|
|
952
|
+
* node's children in source order and queues a matching leave frame so
|
|
953
|
+
* scope pops stay balanced with their pushes.
|
|
954
|
+
*/
|
|
955
|
+
const analyze = (
|
|
956
|
+
sourceCode: string,
|
|
957
|
+
filePath: string,
|
|
958
|
+
ast: AstNode
|
|
959
|
+
): readonly WardenDiagnostic[] => {
|
|
960
|
+
const ctx: ScopeWalkContext = {
|
|
961
|
+
diagnostics: [],
|
|
962
|
+
filePath,
|
|
963
|
+
methodFunctionStarts: buildSourceParamIndex(ast),
|
|
964
|
+
sourceCode,
|
|
965
|
+
};
|
|
966
|
+
const scopes: Scope[] = [];
|
|
967
|
+
const stack: WalkFrame[] = [{ kind: 'enter', node: ast }];
|
|
968
|
+
while (stack.length > 0) {
|
|
969
|
+
const frame = stack.pop();
|
|
970
|
+
if (frame) {
|
|
971
|
+
processFrame(frame, scopes, ctx, stack);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return ctx.diagnostics;
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Warden rule enforcing that warden rules themselves walk the AST rather than
|
|
979
|
+
* regex-scan raw source text.
|
|
980
|
+
*/
|
|
981
|
+
export const wardenRulesUseAst: WardenRule = {
|
|
982
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
983
|
+
if (!isTargetFile(filePath)) {
|
|
984
|
+
return [];
|
|
985
|
+
}
|
|
986
|
+
const ast = parse(filePath, sourceCode);
|
|
987
|
+
if (!ast) {
|
|
988
|
+
return [];
|
|
989
|
+
}
|
|
990
|
+
return analyze(sourceCode, filePath, ast);
|
|
991
|
+
},
|
|
992
|
+
description:
|
|
993
|
+
'Enforces that warden rules inspect the AST via packages/warden/src/rules/ast.ts helpers rather than regex-scanning raw source text.',
|
|
994
|
+
name: RULE_NAME,
|
|
995
|
+
severity: 'error',
|
|
996
|
+
};
|