@vibe-agent-toolkit/utils 0.1.42 → 0.2.0-rc.10
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/README.md +81 -19
- package/dist/asset-reference.js +3 -11
- package/dist/asset-reference.js.map +1 -1
- package/dist/compare-code-units.d.ts +28 -0
- package/dist/compare-code-units.d.ts.map +1 -0
- package/dist/compare-code-units.js +33 -0
- package/dist/compare-code-units.js.map +1 -0
- package/dist/crawl-timing.d.ts +657 -0
- package/dist/crawl-timing.d.ts.map +1 -0
- package/dist/crawl-timing.js +765 -0
- package/dist/crawl-timing.js.map +1 -0
- package/dist/crawl.d.ts +2 -4
- package/dist/crawl.d.ts.map +1 -1
- package/dist/crawl.js +2 -4
- package/dist/crawl.js.map +1 -1
- package/dist/dirent-kind.d.ts +83 -0
- package/dist/dirent-kind.d.ts.map +1 -0
- package/dist/dirent-kind.js +129 -0
- package/dist/dirent-kind.js.map +1 -0
- package/dist/entrypoint.d.ts +54 -0
- package/dist/entrypoint.d.ts.map +1 -0
- package/dist/entrypoint.js +71 -0
- package/dist/entrypoint.js.map +1 -0
- package/dist/env-flag.d.ts +51 -0
- package/dist/env-flag.d.ts.map +1 -0
- package/dist/env-flag.js +62 -0
- package/dist/env-flag.js.map +1 -0
- package/dist/errors/errno.d.ts +51 -0
- package/dist/errors/errno.d.ts.map +1 -0
- package/dist/errors/errno.js +112 -0
- package/dist/errors/errno.js.map +1 -0
- package/dist/errors/vat-error.d.ts +60 -0
- package/dist/errors/vat-error.d.ts.map +1 -0
- package/dist/errors/vat-error.js +86 -0
- package/dist/errors/vat-error.js.map +1 -0
- package/dist/file-crawler.d.ts +60 -0
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +214 -24
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs/file-hash.d.ts.map +1 -1
- package/dist/fs/file-hash.js +0 -1
- package/dist/fs/file-hash.js.map +1 -1
- package/dist/fs-utils.d.ts +628 -38
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +770 -71
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +5 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +9 -1
- package/dist/fs.js.map +1 -1
- package/dist/git-root-cache.d.ts +44 -0
- package/dist/git-root-cache.d.ts.map +1 -0
- package/dist/git-root-cache.js +68 -0
- package/dist/git-root-cache.js.map +1 -0
- package/dist/git-run.d.ts +167 -0
- package/dist/git-run.d.ts.map +1 -0
- package/dist/git-run.js +154 -0
- package/dist/git-run.js.map +1 -0
- package/dist/git-snapshot.d.ts +199 -0
- package/dist/git-snapshot.d.ts.map +1 -0
- package/dist/git-snapshot.js +250 -0
- package/dist/git-snapshot.js.map +1 -0
- package/dist/git-tracker.d.ts +166 -3
- package/dist/git-tracker.d.ts.map +1 -1
- package/dist/git-tracker.js +250 -22
- package/dist/git-tracker.js.map +1 -1
- package/dist/git-utils.d.ts +95 -1
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +227 -81
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +13 -4
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +12 -4
- package/dist/git.js.map +1 -1
- package/dist/gitignore-checker.d.ts.map +1 -1
- package/dist/gitignore-checker.js +15 -6
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/glob/glob-pattern.js +5 -5
- package/dist/glob/glob-pattern.js.map +1 -1
- package/dist/index.d.ts +33 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -33
- package/dist/index.js.map +1 -1
- package/dist/listing-refusal.d.ts +112 -0
- package/dist/listing-refusal.d.ts.map +1 -0
- package/dist/listing-refusal.js +105 -0
- package/dist/listing-refusal.js.map +1 -0
- package/dist/numeric-args.d.ts +24 -0
- package/dist/numeric-args.d.ts.map +1 -0
- package/dist/numeric-args.js +37 -0
- package/dist/numeric-args.js.map +1 -0
- package/dist/path-containment.d.ts +64 -0
- package/dist/path-containment.d.ts.map +1 -0
- package/dist/path-containment.js +111 -0
- package/dist/path-containment.js.map +1 -0
- package/dist/path-core.d.ts +143 -18
- package/dist/path-core.d.ts.map +1 -1
- package/dist/path-core.js +171 -23
- package/dist/path-core.js.map +1 -1
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/path-utils.js +42 -47
- package/dist/path-utils.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -1
- package/dist/path.js.map +1 -1
- package/dist/process.d.ts +7 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +7 -0
- package/dist/process.js.map +1 -1
- package/dist/project-utils.d.ts +7 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +24 -10
- package/dist/project-utils.js.map +1 -1
- package/dist/project.d.ts +7 -5
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +7 -5
- package/dist/project.js.map +1 -1
- package/dist/safe-exec.d.ts +40 -11
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +111 -25
- package/dist/safe-exec.js.map +1 -1
- package/dist/skill-targets.d.ts +2 -2
- package/dist/skill-targets.js +2 -2
- package/dist/skill-test/auth-resolver.d.ts +10 -2
- package/dist/skill-test/auth-resolver.d.ts.map +1 -1
- package/dist/skill-test/auth-resolver.js +26 -11
- package/dist/skill-test/auth-resolver.js.map +1 -1
- package/dist/skill-test/index.d.ts +1 -1
- package/dist/skill-test/index.d.ts.map +1 -1
- package/dist/skill-test/index.js +1 -1
- package/dist/skill-test/index.js.map +1 -1
- package/dist/skill-test/spawn-claude.d.ts +30 -1
- package/dist/skill-test/spawn-claude.d.ts.map +1 -1
- package/dist/skill-test/spawn-claude.js +71 -5
- package/dist/skill-test/spawn-claude.js.map +1 -1
- package/dist/skill-test/transcript.d.ts +41 -2
- package/dist/skill-test/transcript.d.ts.map +1 -1
- package/dist/skill-test/transcript.js +55 -10
- package/dist/skill-test/transcript.js.map +1 -1
- package/dist/spawn-hardened.js +0 -1
- package/dist/spawn-hardened.js.map +1 -1
- package/dist/stdio-blocking.js +13 -14
- package/dist/stdio-blocking.js.map +1 -1
- package/dist/test-helpers.d.ts +130 -79
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +229 -124
- package/dist/test-helpers.js.map +1 -1
- package/dist/testing/executables.d.ts +32 -0
- package/dist/testing/executables.d.ts.map +1 -0
- package/dist/testing/executables.js +70 -0
- package/dist/testing/executables.js.map +1 -0
- package/dist/testing/hostile-tree.d.ts +79 -0
- package/dist/testing/hostile-tree.d.ts.map +1 -0
- package/dist/testing/hostile-tree.js +164 -0
- package/dist/testing/hostile-tree.js.map +1 -0
- package/dist/testing/platform-gates.d.ts +31 -0
- package/dist/testing/platform-gates.d.ts.map +1 -0
- package/dist/testing/platform-gates.js +31 -0
- package/dist/testing/platform-gates.js.map +1 -0
- package/dist/testing/temp-dir.d.ts +255 -0
- package/dist/testing/temp-dir.d.ts.map +1 -0
- package/dist/testing/temp-dir.js +363 -0
- package/dist/testing/temp-dir.js.map +1 -0
- package/dist/testing.d.ts +94 -3
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +119 -3
- package/dist/testing.js.map +1 -1
- package/dist/text-content.d.ts +197 -0
- package/dist/text-content.d.ts.map +1 -0
- package/dist/text-content.js +354 -0
- package/dist/text-content.js.map +1 -0
- package/dist/text-file.d.ts +45 -0
- package/dist/text-file.d.ts.map +1 -0
- package/dist/text-file.js +51 -0
- package/dist/text-file.js.map +1 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +16 -0
- package/dist/text.js.map +1 -0
- package/dist/timing-dump.d.ts +136 -0
- package/dist/timing-dump.d.ts.map +1 -0
- package/dist/timing-dump.js +229 -0
- package/dist/timing-dump.js.map +1 -0
- package/dist/windows-shell.d.ts +0 -1
- package/dist/windows-shell.d.ts.map +1 -1
- package/dist/windows-shell.js +0 -1
- package/dist/windows-shell.js.map +1 -1
- package/dist/zod-introspection.js +2 -2
- package/dist/zod-introspection.js.map +1 -1
- package/eslint/README.md +224 -50
- package/eslint/index.cjs +111 -97
- package/eslint/index.d.cts +16 -6
- package/eslint/rules/callee-name.cjs +23 -0
- package/eslint/rules/commands-import-boundary.cjs +148 -0
- package/eslint/rules/dead-import.cjs +62 -12
- package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
- package/eslint/rules/eslint-rule-factory.cjs +21 -3
- package/eslint/rules/exempt-path-matcher.cjs +2 -1
- package/eslint/rules/explicit-zod-strictness.cjs +120 -0
- package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
- package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
- package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
- package/eslint/rules/no-blind-catch.cjs +171 -0
- package/eslint/rules/no-child-process-execSync.cjs +8 -0
- package/eslint/rules/no-decaying-referent.cjs +217 -0
- package/eslint/rules/no-dotdot-containment.cjs +160 -0
- package/eslint/rules/no-file-url-string-concat.cjs +4 -1
- package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
- package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
- package/eslint/rules/no-fs-promises-cp.cjs +7 -0
- package/eslint/rules/no-fs-realpathSync.cjs +8 -0
- package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
- package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
- package/eslint/rules/no-literal-process-exit.cjs +129 -0
- package/eslint/rules/no-manual-path-normalize.cjs +146 -96
- package/eslint/rules/no-os-tmpdir.cjs +8 -0
- package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
- package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
- package/eslint/rules/no-path-startswith.cjs +7 -2
- package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
- package/eslint/rules/no-raw-node-path.cjs +563 -0
- package/eslint/rules/no-raw-text-decode.cjs +305 -0
- package/eslint/rules/no-registry-count-pin.cjs +255 -0
- package/eslint/rules/no-self-package-import.cjs +168 -0
- package/eslint/rules/no-test-scoped-functions.cjs +10 -3
- package/eslint/rules/no-unix-shell-commands.cjs +6 -2
- package/eslint/rules/no-unsafe-root-join.cjs +23 -2
- package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
- package/eslint/rules/no-version-literal.cjs +151 -0
- package/eslint/rules/prefer-startswith-over-regex.cjs +33 -1
- package/eslint/rules/require-justified-skip.cjs +8 -2
- package/eslint/rules/safe-import.cjs +1 -1
- package/package.json +16 -16
- package/dist/link-auth/build-headers.d.ts +0 -34
- package/dist/link-auth/build-headers.d.ts.map +0 -1
- package/dist/link-auth/build-headers.js +0 -58
- package/dist/link-auth/build-headers.js.map +0 -1
- package/dist/link-auth/expand-macro.d.ts +0 -38
- package/dist/link-auth/expand-macro.d.ts.map +0 -1
- package/dist/link-auth/expand-macro.js +0 -133
- package/dist/link-auth/expand-macro.js.map +0 -1
- package/dist/link-auth/macros.yaml +0 -50
- package/dist/link-auth/resolve-token.d.ts +0 -83
- package/dist/link-auth/resolve-token.d.ts.map +0 -1
- package/dist/link-auth/resolve-token.js +0 -101
- package/dist/link-auth/resolve-token.js.map +0 -1
- package/dist/link-auth/resolve.d.ts +0 -102
- package/dist/link-auth/resolve.d.ts.map +0 -1
- package/dist/link-auth/resolve.js +0 -66
- package/dist/link-auth/resolve.js.map +0 -1
- package/dist/link-auth/rewrite.d.ts +0 -52
- package/dist/link-auth/rewrite.d.ts.map +0 -1
- package/dist/link-auth/rewrite.js +0 -102
- package/dist/link-auth/rewrite.js.map +0 -1
- package/dist/link-auth/select-provider.d.ts +0 -30
- package/dist/link-auth/select-provider.d.ts.map +0 -1
- package/dist/link-auth/select-provider.js +0 -55
- package/dist/link-auth/select-provider.js.map +0 -1
- package/dist/link-auth/template.d.ts +0 -40
- package/dist/link-auth/template.d.ts.map +0 -1
- package/dist/link-auth/template.js +0 -89
- package/dist/link-auth/template.js.map +0 -1
- package/dist/link-auth/transforms.d.ts +0 -46
- package/dist/link-auth/transforms.d.ts.map +0 -1
- package/dist/link-auth/transforms.js +0 -52
- package/dist/link-auth/transforms.js.map +0 -1
- package/dist/template-entry.d.ts +0 -10
- package/dist/template-entry.d.ts.map +0 -1
- package/dist/template-entry.js +0 -10
- package/dist/template-entry.js.map +0 -1
- package/dist/template.d.ts +0 -7
- package/dist/template.d.ts.map +0 -1
- package/dist/template.js +0 -18
- package/dist/template.js.map +0 -1
- package/eslint/rules/no-path-join.cjs +0 -14
- package/eslint/rules/no-path-relative.cjs +0 -14
- package/eslint/rules/no-path-resolve.cjs +0 -14
- package/eslint/rules/path-function-rule-factory.cjs +0 -417
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-raw-text-decode
|
|
3
|
+
*
|
|
4
|
+
* **One seam turns file bytes into text; every other route is an error.**
|
|
5
|
+
*
|
|
6
|
+
* ## What it is for
|
|
7
|
+
*
|
|
8
|
+
* `bytes.toString('utf-8')` is a guess dressed as a conversion. It ignores every
|
|
9
|
+
* byte-order mark, cannot express UTF-16BE at all (Node's `Buffer` has no such
|
|
10
|
+
* encoding), and turns a perfectly ordinary UTF-16 document into NUL-interleaved
|
|
11
|
+
* mojibake — which downstream code then classifies as *binary*. Measured in VAT:
|
|
12
|
+
* a `working-tree-encoding=UTF-16` checkout of a markdown file yielding one
|
|
13
|
+
* heading and one link produced **no blob row, no section and no reference**,
|
|
14
|
+
* because the read decoded it wrong and the binary sniff believed the result.
|
|
15
|
+
* PowerShell 5.1's `Out-File` and `>` write UTF-16LE by default, so this is a
|
|
16
|
+
* Windows-authored document, not an exotic one.
|
|
17
|
+
*
|
|
18
|
+
* The fix is a single decoding seam that reads the encoding off the BOM and says
|
|
19
|
+
* so. This rule is what keeps a second, private decoder from growing beside it.
|
|
20
|
+
*
|
|
21
|
+
* ## The three shapes it catches
|
|
22
|
+
*
|
|
23
|
+
* | shape | example |
|
|
24
|
+
* |---|---|
|
|
25
|
+
* | `toString(<encoding>)` on any value | `buf.toString('utf-8')` |
|
|
26
|
+
* | constructing a decoder | `new TextDecoder('utf-16le')` |
|
|
27
|
+
* | letting `fs` decode | `readFile(p, 'utf-8')`, `readFileSync(p, { encoding: 'latin1' })` |
|
|
28
|
+
*
|
|
29
|
+
* ## The line this rule is drawing: whose choice was the encoding?
|
|
30
|
+
*
|
|
31
|
+
* **Not every `'utf-8'` read is a content read**, and the distinction is the
|
|
32
|
+
* whole difference between a rule that survives and one that gets widened until
|
|
33
|
+
* it means nothing. Three categories, and every call site is in exactly one:
|
|
34
|
+
*
|
|
35
|
+
* 1. **A document from the corpus** — an adopter's markdown, HTML, `SKILL.md`,
|
|
36
|
+
* config file, JSON schema or `package.json`. Nobody in this codebase chose
|
|
37
|
+
* the encoding; it has to be **discovered** from the bytes. *This is the
|
|
38
|
+
* rule's target*, and the seam is the only correct reader.
|
|
39
|
+
* 2. **An artifact this project wrote** — its own cache entry, its own emitted
|
|
40
|
+
* manifest, an asset it publishes beside its own code. The encoding was
|
|
41
|
+
* **chosen at the write**, so reading it back the same way is a closed loop,
|
|
42
|
+
* not a decode.
|
|
43
|
+
* 3. **Bytes that were never a file** — a subprocess's stdout, an HTTP response
|
|
44
|
+
* body, a Buffer this process built. There is no file encoding to discover;
|
|
45
|
+
* the **producer's contract** decides.
|
|
46
|
+
*
|
|
47
|
+
* Static analysis cannot tell the three apart — `buf.toString('utf8')` looks
|
|
48
|
+
* identical whether the Buffer came from `readFile` or from `spawn`. So the rule
|
|
49
|
+
* reports all three, and 2 and 3 are settled at the call site with a one-line
|
|
50
|
+
* `eslint-disable-next-line` that **names the writer or the producer**.
|
|
51
|
+
*
|
|
52
|
+
* That gives a reviewer a falsifiable test, which is the point: *a justification
|
|
53
|
+
* that cannot name who wrote the bytes is a category-1 call wearing a disable
|
|
54
|
+
* comment.* "It's always UTF-8 in practice" names nobody and does not qualify.
|
|
55
|
+
* Do not settle these by adding paths to `exemptFiles` until lint goes quiet —
|
|
56
|
+
* `exemptFiles` is for the seam's own implementation file, nothing else.
|
|
57
|
+
*
|
|
58
|
+
* ## Why the encoding test is an EXCLUSION and not an inclusion list
|
|
59
|
+
*
|
|
60
|
+
* An inclusion list of character encodings would have to enumerate every legal
|
|
61
|
+
* spelling — `utf8`, `utf-8`, `utf16le`, `ucs2`, `ucs-2`, `latin1`, `binary`,
|
|
62
|
+
* `ascii`, the `iso-8859-*` family, the `windows-125*` family, and every
|
|
63
|
+
* `TextDecoder` label alias beyond those — and a spelling it missed would pass
|
|
64
|
+
* silently. That is a rule that fails OPEN, in the one direction that matters.
|
|
65
|
+
*
|
|
66
|
+
* Node's `Buffer` encodings are a CLOSED set, and exactly three of them are
|
|
67
|
+
* binary-to-text codecs rather than character encodings: `base64`, `base64url`
|
|
68
|
+
* and `hex`. So "a string argument that is not one of those three" is both
|
|
69
|
+
* exhaustive over `Buffer` and correct for `TextDecoder` (whose labels are all
|
|
70
|
+
* character encodings). Excluding three known-safe values fails CLOSED: an
|
|
71
|
+
* encoding nobody anticipated still fires.
|
|
72
|
+
*
|
|
73
|
+
* ## What it does NOT catch, stated so the coverage is not overclaimed
|
|
74
|
+
*
|
|
75
|
+
* - **A computed encoding** — `buf.toString(enc)`, `readFile(p, enc)`. Firing on
|
|
76
|
+
* a non-literal second argument is untenable without type information:
|
|
77
|
+
* `n.toString(radix)` with a variable radix is ordinary code, and
|
|
78
|
+
* `readFile(p, callback)` in callback style has the same shape as
|
|
79
|
+
* `readFile(p, encoding)`. A rule that fires on both teaches people to disable
|
|
80
|
+
* it, which costs its true positives too — this repo has already demoted one
|
|
81
|
+
* rule (`no-unsafe-root-join`) for precisely that. So the trigger is a string
|
|
82
|
+
* LITERAL, and a variable holding `'utf-8'` is a bypass a reviewer can see and
|
|
83
|
+
* a linter cannot.
|
|
84
|
+
* - `createReadStream(path, 'utf-8')` and other streaming decodes. No VAT code
|
|
85
|
+
* does this; it is a real hole, not a decision that it is safe.
|
|
86
|
+
* - A subprocess's stdout decoded with `.toString('utf8')` IS caught, and that
|
|
87
|
+
* is usually a false positive — process output is not file content and its
|
|
88
|
+
* encoding is the child's business. Nothing here can tell the two apart
|
|
89
|
+
* statically, which is why the rule is registered against the directories that
|
|
90
|
+
* read corpus documents rather than repo-wide. See the consuming config.
|
|
91
|
+
* - `require()`/`import()` of a JSON or data file, which decode below the public
|
|
92
|
+
* `fs` surface entirely.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```javascript
|
|
96
|
+
* // ❌ BAD
|
|
97
|
+
* const text = (await readFile(p)).toString('utf-8');
|
|
98
|
+
* const text = await readFile(p, 'utf-8');
|
|
99
|
+
* const text = new TextDecoder().decode(bytes);
|
|
100
|
+
*
|
|
101
|
+
* // ✅ GOOD — category 1, the corpus document
|
|
102
|
+
* const { text } = await readTextContent(p);
|
|
103
|
+
* const { text } = decodeTextContent(bytes);
|
|
104
|
+
*
|
|
105
|
+
* // ✅ GOOD — category 3, and the justification names the producer
|
|
106
|
+
* // eslint-disable-next-line local/no-raw-text-decode -- subprocess stdout; producer is the credential helper spawned above
|
|
107
|
+
* const out = result.stdout.toString('utf8');
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
'use strict';
|
|
112
|
+
|
|
113
|
+
const {
|
|
114
|
+
EXEMPT_FILES_SCHEMA,
|
|
115
|
+
UNANCHORED_EXEMPT_FILE,
|
|
116
|
+
UNANCHORED_EXEMPT_MESSAGE,
|
|
117
|
+
createConfigurableExemptPathMatcher,
|
|
118
|
+
reportUnanchoredExemptEntries,
|
|
119
|
+
} = require('./exempt-path-matcher.cjs');
|
|
120
|
+
const { resolveSafeModule, withSafeModuleOption } = require('./safe-import.cjs');
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The default module named in the advice text.
|
|
124
|
+
*
|
|
125
|
+
* A placeholder, and deliberately not this package: the seam lives in the
|
|
126
|
+
* consuming repo, so `safeModule` is the option every real config sets. Named
|
|
127
|
+
* rather than left blank so the message is still a sentence when it is not.
|
|
128
|
+
*/
|
|
129
|
+
const DEFAULT_SEAM_MODULE = 'your content-decoding module';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The three `Buffer` encodings that are binary-to-text codecs, not character
|
|
133
|
+
* encodings. Everything else is a decode. See the module docstring for why the
|
|
134
|
+
* test is shaped this way round.
|
|
135
|
+
*/
|
|
136
|
+
const BINARY_TO_TEXT_CODECS = new Set(['base64', 'base64url', 'hex']);
|
|
137
|
+
|
|
138
|
+
/** The `fs` readers that will decode for you if handed an encoding. */
|
|
139
|
+
const DECODING_READERS = new Set(['readFile', 'readFileSync']);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Is this node a string literal (or a template with no substitutions)?
|
|
143
|
+
*
|
|
144
|
+
* @param {object} node - AST node
|
|
145
|
+
* @returns {string | null} Its value, or `null` when it is not a plain string
|
|
146
|
+
*/
|
|
147
|
+
function stringValueOf(node) {
|
|
148
|
+
if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
149
|
+
if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
150
|
+
return node.quasis[0]?.value?.cooked ?? null;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Does this argument spell out a character encoding?
|
|
157
|
+
*
|
|
158
|
+
* Only a string LITERAL counts — see the module docstring for why a computed
|
|
159
|
+
* encoding is deliberately out of reach. A numeric radix (`n.toString(16)`) is
|
|
160
|
+
* not a string and so is never a decode.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} node - The argument node
|
|
163
|
+
* @returns {boolean} True when the argument names a character encoding
|
|
164
|
+
*/
|
|
165
|
+
function isCharacterEncodingArgument(node) {
|
|
166
|
+
const literal = stringValueOf(node);
|
|
167
|
+
if (literal === null) return false;
|
|
168
|
+
return !BINARY_TO_TEXT_CODECS.has(literal.toLowerCase());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The `{ encoding: '...' }` option object's encoding argument, if it selects a
|
|
173
|
+
* character encoding.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} node - The argument node
|
|
176
|
+
* @returns {boolean} True when the options object asks `fs` to decode
|
|
177
|
+
*/
|
|
178
|
+
function optionsObjectDecodes(node) {
|
|
179
|
+
if (node?.type !== 'ObjectExpression') return false;
|
|
180
|
+
for (const property of node.properties) {
|
|
181
|
+
if (property.type !== 'Property') continue;
|
|
182
|
+
const key = property.key?.type === 'Identifier'
|
|
183
|
+
? property.key.name
|
|
184
|
+
: stringValueOf(property.key);
|
|
185
|
+
if (key !== 'encoding') continue;
|
|
186
|
+
// An explicit `encoding: null` / `encoding: undefined` asks for a Buffer.
|
|
187
|
+
if (property.value?.type === 'Literal' && property.value.value === null) return false;
|
|
188
|
+
return isCharacterEncodingArgument(property.value);
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The called function's name, whether it is bare or a member call. */
|
|
194
|
+
function calleeName(node) {
|
|
195
|
+
if (node.callee?.type === 'Identifier') return node.callee.name;
|
|
196
|
+
if (node.callee?.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
197
|
+
return node.callee.property.name;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const exemptMatcherFor = createConfigurableExemptPathMatcher([]);
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
meta: {
|
|
206
|
+
type: 'problem',
|
|
207
|
+
docs: {
|
|
208
|
+
description:
|
|
209
|
+
'Disallow decoding bytes to text outside the one content-decoding seam; '
|
|
210
|
+
+ 'raw `toString(encoding)` / `TextDecoder` / `readFile(path, encoding)` '
|
|
211
|
+
+ 'ignore byte-order marks and cannot express UTF-16BE at all.',
|
|
212
|
+
category: 'Content decoding',
|
|
213
|
+
bans: "`buf.toString('utf-8')`, `new TextDecoder(…)`, `readFile(p, 'utf-8')`",
|
|
214
|
+
useInstead: 'one project-owned decoding seam',
|
|
215
|
+
// Not in `recommended`: it names a SEAM that does not exist until a consumer
|
|
216
|
+
// builds one. `decodeTextContent()` is VAT's module, in VAT's repo; an adopter
|
|
217
|
+
// who installed this package for `safePath.join()` has no content-decoding
|
|
218
|
+
// seam to be pointed at, so every message would advise importing from `your
|
|
219
|
+
// content-decoding module`. Enabled — with `safeModule` naming the real seam
|
|
220
|
+
// and `exemptFiles` naming the file that implements it — by whoever has one.
|
|
221
|
+
recommended: false,
|
|
222
|
+
recommendedSeverity: 'error',
|
|
223
|
+
},
|
|
224
|
+
schema: [withSafeModuleOption(EXEMPT_FILES_SCHEMA)],
|
|
225
|
+
messages: {
|
|
226
|
+
bufferTextDecode:
|
|
227
|
+
'Do not decode bytes with `.toString({{encoding}})` — it ignores any byte-order mark and '
|
|
228
|
+
+ 'cannot express UTF-16BE. Use `decodeTextContent()` from {{safeModule}}.',
|
|
229
|
+
textDecoderConstruct:
|
|
230
|
+
'Do not construct a `TextDecoder` here — the encoding has to be decided from the bytes, '
|
|
231
|
+
+ 'in one place. Use `decodeTextContent()` from {{safeModule}}.',
|
|
232
|
+
fsReadTextEncoding:
|
|
233
|
+
'Do not let `{{reader}}` decode — it applies the encoding you name with no BOM handling. '
|
|
234
|
+
+ 'Read bytes and use `readTextContent()` / `decodeTextContent()` from {{safeModule}}.',
|
|
235
|
+
[UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
create(context) {
|
|
240
|
+
const seamModule = resolveSafeModule(context, DEFAULT_SEAM_MODULE);
|
|
241
|
+
|
|
242
|
+
// The seam itself must be able to call the primitives it wraps.
|
|
243
|
+
if (exemptMatcherFor(context)(context.getFilename())) {
|
|
244
|
+
// Still surface a malformed exemption list: the file we are standing in
|
|
245
|
+
// may be exempt only BECAUSE the entry is unanchored.
|
|
246
|
+
return {
|
|
247
|
+
Program(node) {
|
|
248
|
+
reportUnanchoredExemptEntries(context, node);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
Program(node) {
|
|
255
|
+
reportUnanchoredExemptEntries(context, node);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
NewExpression(node) {
|
|
259
|
+
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'TextDecoder') return;
|
|
260
|
+
context.report({
|
|
261
|
+
node,
|
|
262
|
+
messageId: 'textDecoderConstruct',
|
|
263
|
+
data: { safeModule: seamModule },
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
CallExpression(node) {
|
|
268
|
+
const name = calleeName(node);
|
|
269
|
+
if (name === null) return;
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
name === 'toString'
|
|
273
|
+
&& node.callee.type === 'MemberExpression'
|
|
274
|
+
&& node.arguments.length > 0
|
|
275
|
+
&& isCharacterEncodingArgument(node.arguments[0])
|
|
276
|
+
) {
|
|
277
|
+
context.report({
|
|
278
|
+
node,
|
|
279
|
+
messageId: 'bufferTextDecode',
|
|
280
|
+
data: {
|
|
281
|
+
safeModule: seamModule,
|
|
282
|
+
encoding: context.getSourceCode().getText(node.arguments[0]),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!DECODING_READERS.has(name) || node.arguments.length < 2) return;
|
|
289
|
+
const second = node.arguments[1];
|
|
290
|
+
// Object form first: `{ encoding: 'hex' }` is an ObjectExpression, and
|
|
291
|
+
// asking the positional test about it would answer the wrong question.
|
|
292
|
+
const decodes = second?.type === 'ObjectExpression'
|
|
293
|
+
? optionsObjectDecodes(second)
|
|
294
|
+
: isCharacterEncodingArgument(second);
|
|
295
|
+
if (decodes) {
|
|
296
|
+
context.report({
|
|
297
|
+
node,
|
|
298
|
+
messageId: 'fsReadTextEncoding',
|
|
299
|
+
data: { reader: name, safeModule: seamModule },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-registry-count-pin
|
|
3
|
+
*
|
|
4
|
+
* In a test file, flags `expect(<x>).toHaveLength(<n>)` and
|
|
5
|
+
* `expect(<x>).toBe(<n>)` when `n` is a literal at or above `minLiteral` and
|
|
6
|
+
* `x` is derived from something the file IMPORTED — a registry, a manifest, a
|
|
7
|
+
* rule table pulled from src — rather than from a fixture the test built.
|
|
8
|
+
*
|
|
9
|
+
* Such a pin is a change detector and only a change detector: it fails on
|
|
10
|
+
* every legitimate addition, is fixed by retyping the number, and proves
|
|
11
|
+
* nothing about the set it stands for. The audit found the same four numbers
|
|
12
|
+
* retyped in two files and a prose sentence, all three wrong at once. The
|
|
13
|
+
* assertion that means something is the SET — `toEqual([...names].sort())` —
|
|
14
|
+
* or a derivation from the same source the registry is built from.
|
|
15
|
+
*
|
|
16
|
+
* ## The heuristic, and what it deliberately misses
|
|
17
|
+
*
|
|
18
|
+
* `x` is registry-derived when its value chain reaches an import binding used
|
|
19
|
+
* as a VALUE: `REGISTRY`, `REGISTRY.length`, `Object.keys(REGISTRY)`,
|
|
20
|
+
* `[...REGISTRY]`, `REGISTRY.filter(…)`, a local `const` that holds one of
|
|
21
|
+
* those, or a call to an imported function whose every argument is itself
|
|
22
|
+
* registry-derived (so a zero-argument accessor like `allSpecs()` counts).
|
|
23
|
+
*
|
|
24
|
+
* An imported function applied to LOCAL data — `parse(fixtureText).links` —
|
|
25
|
+
* is NOT registry-derived: the count is the fixture's. That includes a literal
|
|
26
|
+
* argument, so `loadModule('../index.cjs')` is missed on purpose; a literal
|
|
27
|
+
* module path and a literal fixture are indistinguishable syntactically, and a
|
|
28
|
+
* rule that fired on `parse('# heading')` would be switched off within a week.
|
|
29
|
+
* The same holds for a method on an imported receiver: `safePath.join(tmp,
|
|
30
|
+
* 'x')` is an imported namespace of FUNCTIONS fed local data, so the result is
|
|
31
|
+
* local — only a method whose arguments are all derived (or callbacks) keeps
|
|
32
|
+
* the receiver's derivation. Measured on this tree: the receiver-only reading
|
|
33
|
+
* flagged `hash.toHaveLength(64)` on a hash of a local file.
|
|
34
|
+
*
|
|
35
|
+
* Option `minLiteral: number` (default 5) — the smallest literal that reads as
|
|
36
|
+
* a count of a registry rather than a shape assertion on a small fixture.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // BAD — fails on every addition, fixed by retyping 27 → 28
|
|
40
|
+
* expect(Object.keys(plugin.rules)).toHaveLength(27);
|
|
41
|
+
*
|
|
42
|
+
* // GOOD — says WHICH, and fails only when the set actually changes
|
|
43
|
+
* expect(Object.keys(plugin.rules).sort()).toEqual(readdirSync(rulesDir).map(stripExt).sort());
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
const { isTestFile } = require('./exempt-path-matcher.cjs');
|
|
49
|
+
|
|
50
|
+
const COUNT_MATCHERS = new Set(['toHaveLength', 'toBe']);
|
|
51
|
+
const DEFAULT_MIN_LITERAL = 5;
|
|
52
|
+
const FUNCTION_ARGUMENT_TYPES = new Set(['ArrowFunctionExpression', 'FunctionExpression']);
|
|
53
|
+
|
|
54
|
+
/** The variable an identifier resolves to, walking up the scope chain, or null. */
|
|
55
|
+
function resolveVariable(sourceCode, identifier) {
|
|
56
|
+
let scope = sourceCode.getScope(identifier);
|
|
57
|
+
while (scope) {
|
|
58
|
+
const variable = scope.set.get(identifier.name);
|
|
59
|
+
if (variable) {
|
|
60
|
+
return variable;
|
|
61
|
+
}
|
|
62
|
+
scope = scope.upper;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether `expr` is `expect(...)` or `expect.soft(...)`. */
|
|
68
|
+
function isExpectCall(expr) {
|
|
69
|
+
if (expr.type !== 'CallExpression') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const { callee } = expr;
|
|
73
|
+
if (callee.type === 'Identifier') {
|
|
74
|
+
return callee.name === 'expect';
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
callee.type === 'MemberExpression' &&
|
|
78
|
+
callee.object.type === 'Identifier' &&
|
|
79
|
+
callee.object.name === 'expect' &&
|
|
80
|
+
callee.property.type === 'Identifier' &&
|
|
81
|
+
callee.property.name === 'soft'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The subject of the `expect(...)` a matcher chain hangs off, descending
|
|
87
|
+
* through modifiers like `.not` / `.resolves`, or null when the chain does not
|
|
88
|
+
* start at `expect`.
|
|
89
|
+
*/
|
|
90
|
+
function expectSubject(matcherCallee) {
|
|
91
|
+
let current = matcherCallee.object;
|
|
92
|
+
while (current.type === 'MemberExpression') {
|
|
93
|
+
current = current.object;
|
|
94
|
+
}
|
|
95
|
+
return isExpectCall(current) ? (current.arguments[0] ?? null) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a numeric literal argument at or above `min` was passed. */
|
|
99
|
+
function countLiteral(node, min) {
|
|
100
|
+
const arg = node.arguments[0];
|
|
101
|
+
if (node.arguments.length !== 1 || arg.type !== 'Literal' || typeof arg.value !== 'number') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return arg.value >= min ? arg : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether every element of a call/array/new argument list is registry-derived. */
|
|
108
|
+
function createDerivationCheck(sourceCode) {
|
|
109
|
+
const visiting = new Set();
|
|
110
|
+
|
|
111
|
+
function variableIsDerived(variable) {
|
|
112
|
+
const def = variable.defs[0];
|
|
113
|
+
if (!def) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (def.type === 'ImportBinding') {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (def.type !== 'Variable' || !def.node.init || visiting.has(variable)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
visiting.add(variable);
|
|
123
|
+
const result = isDerived(def.node.init);
|
|
124
|
+
visiting.delete(variable);
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function elementIsDerived(element) {
|
|
129
|
+
return element?.type === 'SpreadElement' ? isDerived(element.argument) : isDerived(element);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether no LOCAL data enters a call: every argument is registry-derived,
|
|
134
|
+
* a callback (`.filter((r) => …)` transforms, it does not supply data), or
|
|
135
|
+
* absent. A literal counts as local data — see the module comment.
|
|
136
|
+
*/
|
|
137
|
+
function argumentsCarryNoLocalData(node) {
|
|
138
|
+
return node.arguments.every(
|
|
139
|
+
(arg) => FUNCTION_ARGUMENT_TYPES.has(arg.type) || elementIsDerived(arg),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function callIsDerived(node) {
|
|
144
|
+
const { callee } = node;
|
|
145
|
+
if (callee.type === 'MemberExpression') {
|
|
146
|
+
// `Object.keys(x)` — a global transform: look at what went in.
|
|
147
|
+
// `x.filter(cb)` / `safePath.join(tmp, 'x')` — a method on a value: the
|
|
148
|
+
// receiver must be derived AND nothing local may enter, or `safePath`
|
|
149
|
+
// (an imported namespace of FUNCTIONS) would make every path derived.
|
|
150
|
+
const receiver = callee.object;
|
|
151
|
+
const receiverVar = receiver.type === 'Identifier' ? resolveVariable(sourceCode, receiver) : null;
|
|
152
|
+
const receiverIsGlobal = receiver.type === 'Identifier' && (receiverVar?.defs.length ?? 0) === 0;
|
|
153
|
+
if (receiverIsGlobal) {
|
|
154
|
+
return node.arguments.some(elementIsDerived);
|
|
155
|
+
}
|
|
156
|
+
return isDerived(receiver) && argumentsCarryNoLocalData(node);
|
|
157
|
+
}
|
|
158
|
+
if (callee.type !== 'Identifier') {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const variable = resolveVariable(sourceCode, callee);
|
|
162
|
+
const isImportedFn = variable?.defs[0]?.type === 'ImportBinding';
|
|
163
|
+
return isImportedFn && argumentsCarryNoLocalData(node);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isDerived(expr) {
|
|
167
|
+
if (!expr) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
switch (expr.type) {
|
|
171
|
+
case 'Identifier': {
|
|
172
|
+
const variable = resolveVariable(sourceCode, expr);
|
|
173
|
+
return variable !== null && variableIsDerived(variable);
|
|
174
|
+
}
|
|
175
|
+
case 'MemberExpression':
|
|
176
|
+
return isDerived(expr.object);
|
|
177
|
+
case 'CallExpression':
|
|
178
|
+
return callIsDerived(expr);
|
|
179
|
+
case 'NewExpression':
|
|
180
|
+
return expr.arguments.some(elementIsDerived);
|
|
181
|
+
case 'ArrayExpression':
|
|
182
|
+
return expr.elements.some(elementIsDerived);
|
|
183
|
+
case 'AwaitExpression':
|
|
184
|
+
return isDerived(expr.argument);
|
|
185
|
+
case 'ChainExpression':
|
|
186
|
+
case 'TSAsExpression':
|
|
187
|
+
case 'TSNonNullExpression':
|
|
188
|
+
return isDerived(expr.expression);
|
|
189
|
+
default:
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return isDerived;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
meta: {
|
|
199
|
+
type: 'suggestion',
|
|
200
|
+
docs: {
|
|
201
|
+
description:
|
|
202
|
+
'Disallow pinning the size of an imported registry with a literal in tests — ' +
|
|
203
|
+
'toHaveLength(27) on something pulled from src is a change detector fixed by retyping',
|
|
204
|
+
recommended: false,
|
|
205
|
+
recommendedSeverity: 'warn',
|
|
206
|
+
},
|
|
207
|
+
schema: [
|
|
208
|
+
{
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
minLiteral: { type: 'integer', minimum: 0 },
|
|
212
|
+
},
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
messages: {
|
|
217
|
+
registryCountPin:
|
|
218
|
+
'This pins the size of something imported from src to the literal {{literal}}. It fails on ' +
|
|
219
|
+
'every legitimate addition and is fixed by retyping the number, so it detects change ' +
|
|
220
|
+
'without checking anything. Assert the SET (toEqual([...names].sort())) or derive the ' +
|
|
221
|
+
'expected count from the same source the registry is built from.',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
create(context) {
|
|
226
|
+
const filename = context.filename ?? context.getFilename();
|
|
227
|
+
if (!isTestFile(filename)) {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
231
|
+
const min = context.options?.[0]?.minLiteral ?? DEFAULT_MIN_LITERAL;
|
|
232
|
+
const isDerived = createDerivationCheck(sourceCode);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
CallExpression(node) {
|
|
236
|
+
const { callee } = node;
|
|
237
|
+
if (
|
|
238
|
+
callee.type !== 'MemberExpression' ||
|
|
239
|
+
callee.property.type !== 'Identifier' ||
|
|
240
|
+
!COUNT_MATCHERS.has(callee.property.name)
|
|
241
|
+
) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const literal = countLiteral(node, min);
|
|
245
|
+
if (literal === null) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const subject = expectSubject(callee);
|
|
249
|
+
if (subject !== null && isDerived(subject)) {
|
|
250
|
+
context.report({ node, messageId: 'registryCountPin', data: { literal: String(literal.raw) } });
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
};
|