@clossys/writer 0.3.2
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 +196 -0
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/addressability.d.ts +245 -0
- package/dist/addressability.d.ts.map +1 -0
- package/dist/addressability.js +567 -0
- package/dist/addressability.js.map +1 -0
- package/dist/checker.d.ts +159 -0
- package/dist/checker.d.ts.map +1 -0
- package/dist/checker.js +177 -0
- package/dist/checker.js.map +1 -0
- package/dist/cli.d.ts +149 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1049 -0
- package/dist/cli.js.map +1 -0
- package/dist/copy-gate.d.ts +107 -0
- package/dist/copy-gate.d.ts.map +1 -0
- package/dist/copy-gate.js +132 -0
- package/dist/copy-gate.js.map +1 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +64 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-coverage.d.ts +227 -0
- package/dist/locale-coverage.d.ts.map +1 -0
- package/dist/locale-coverage.js +414 -0
- package/dist/locale-coverage.js.map +1 -0
- package/dist/passage.d.ts +294 -0
- package/dist/passage.d.ts.map +1 -0
- package/dist/passage.js +463 -0
- package/dist/passage.js.map +1 -0
- package/dist/path-exclusions.d.ts +114 -0
- package/dist/path-exclusions.d.ts.map +1 -0
- package/dist/path-exclusions.js +213 -0
- package/dist/path-exclusions.js.map +1 -0
- package/dist/registry.d.ts +61 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +64 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +33 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +133 -0
- package/dist/resolve.js.map +1 -0
- package/dist/scan.d.ts +457 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +1698 -0
- package/dist/scan.js.map +1 -0
- package/dist/schema.d.ts +81 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +482 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/dist/voice/checker.d.ts +186 -0
- package/dist/voice/checker.d.ts.map +1 -0
- package/dist/voice/checker.js +483 -0
- package/dist/voice/checker.js.map +1 -0
- package/dist/voice/derivation-coverage.d.ts +168 -0
- package/dist/voice/derivation-coverage.d.ts.map +1 -0
- package/dist/voice/derivation-coverage.js +181 -0
- package/dist/voice/derivation-coverage.js.map +1 -0
- package/dist/voice/fields.d.ts +107 -0
- package/dist/voice/fields.d.ts.map +1 -0
- package/dist/voice/fields.js +144 -0
- package/dist/voice/fields.js.map +1 -0
- package/dist/voice/index.d.ts +19 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +24 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/internal/parse-template.d.ts +27 -0
- package/dist/voice/internal/parse-template.d.ts.map +1 -0
- package/dist/voice/internal/parse-template.js +111 -0
- package/dist/voice/internal/parse-template.js.map +1 -0
- package/dist/voice/internal/pattern-safety.d.ts +111 -0
- package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
- package/dist/voice/internal/pattern-safety.js +278 -0
- package/dist/voice/internal/pattern-safety.js.map +1 -0
- package/dist/voice/schema.d.ts +65 -0
- package/dist/voice/schema.d.ts.map +1 -0
- package/dist/voice/schema.js +535 -0
- package/dist/voice/schema.js.map +1 -0
- package/dist/voice/types.d.ts +282 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +37 -0
- package/dist/voice/types.js.map +1 -0
- package/package.json +72 -0
- package/src/addressability.ts +664 -0
- package/src/checker.ts +293 -0
- package/src/cli.ts +1166 -0
- package/src/copy-gate.ts +197 -0
- package/src/fingerprint.ts +66 -0
- package/src/index.ts +163 -0
- package/src/locale-coverage.ts +512 -0
- package/src/passage.ts +644 -0
- package/src/path-exclusions.ts +265 -0
- package/src/registry.ts +103 -0
- package/src/resolve.ts +172 -0
- package/src/scan.ts +1959 -0
- package/src/schema.ts +528 -0
- package/src/types.ts +301 -0
- package/src/voice/checker.ts +590 -0
- package/src/voice/derivation-coverage.ts +217 -0
- package/src/voice/fields.ts +160 -0
- package/src/voice/index.ts +55 -0
- package/src/voice/internal/parse-template.ts +114 -0
- package/src/voice/internal/pattern-safety.ts +336 -0
- package/src/voice/schema.ts +585 -0
- package/src/voice/types.ts +329 -0
- package/templates/voice-record.template.jsonc +135 -0
package/src/scan.ts
ADDED
|
@@ -0,0 +1,1959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scanCopySourceTree` and `extractCopyCandidates` — the I/O half AND the
|
|
3
|
+
* hard-analysis half of this gate, unlike the split `@clossys/
|
|
4
|
+
* strategy` draws between its own `scan.ts` (dumb directory walk) and
|
|
5
|
+
* `facts-gate.ts` (the matchers). Here the walk is the easy part; finding
|
|
6
|
+
* which string literals in a real source file are actually user-facing
|
|
7
|
+
* copy — as opposed to a class name, an import specifier, an object key, a
|
|
8
|
+
* `data-*`/`aria-*` attribute, an enum token, or a message meant for a
|
|
9
|
+
* developer, not a user — is the genuinely hard problem, and it is a
|
|
10
|
+
* problem about SOURCE TEXT, not about a file tree. So it lives here, next
|
|
11
|
+
* to the walk that feeds it, and `copy-gate.ts` is left with the simpler
|
|
12
|
+
* job: compare what this file found against a `CopyRecord` and report.
|
|
13
|
+
*
|
|
14
|
+
* NO PARSER LIBRARY — this repository's zero-runtime-dependency default for
|
|
15
|
+
* every package here, `@example/ui` (which wraps React) being the
|
|
16
|
+
* one deliberate exception. What follows is a small hand-rolled character
|
|
17
|
+
* scanner
|
|
18
|
+
* that finds string/template literal BOUNDARIES correctly (the part that
|
|
19
|
+
* is genuinely hard to get wrong-free with a regex: escaped quotes, nested
|
|
20
|
+
* template interpolation, comments, and regex literals all look enough
|
|
21
|
+
* like strings to confuse a naive `"[^"]*"` pattern), plus a set of
|
|
22
|
+
* narrow, LOCAL, documented heuristics — never a full grammar — that look
|
|
23
|
+
* at the raw text immediately around a literal to decide whether it is a
|
|
24
|
+
* user-facing string. Every one of those heuristics is a real, stated
|
|
25
|
+
* trade-off, not a black box: see "THE BOUNDARY" below for the full
|
|
26
|
+
* reasoning, calibrated against real code in this same repository
|
|
27
|
+
* (`packages/ui/src/blocks/Pagination.tsx`, `packages/ui/src/atoms/
|
|
28
|
+
* Select.tsx`, `packages/ui/src/atoms/Table.tsx` — see each package's own
|
|
29
|
+
* test fixtures, lifted near-verbatim from those three files).
|
|
30
|
+
*
|
|
31
|
+
* ============================================================================
|
|
32
|
+
* THE BOUNDARY — what counts as a "copy candidate", and why
|
|
33
|
+
* ============================================================================
|
|
34
|
+
*
|
|
35
|
+
* DEFAULT IS INCLUDE. A bare string or template literal used as a VALUE
|
|
36
|
+
* (never a key, a specifier, or a type) is a copy candidate unless one of
|
|
37
|
+
* the exclusions below applies. This is deliberate, not an oversight: this
|
|
38
|
+
* package's own calibration example — `rangeSummary = "No results"` in
|
|
39
|
+
* `Pagination.tsx` — is a plain literal assignment with NO special
|
|
40
|
+
* surrounding syntax at all (no JSX attribute, no call argument, nothing
|
|
41
|
+
* to allowlist against). A scanner that only fires inside a hand-picked
|
|
42
|
+
* allowlist of "known copy positions" would silently miss exactly this
|
|
43
|
+
* case, which is the single most common shape real UI copy actually takes
|
|
44
|
+
* in this codebase. So the default has to be permissive, and the real work
|
|
45
|
+
* is in a precise, narrow EXCLUDE list:
|
|
46
|
+
*
|
|
47
|
+
* 1. Import/export/require specifiers (`from "x"`, `import("x")`,
|
|
48
|
+
* `require("x")`) — a module path is an address, not copy.
|
|
49
|
+
* 2. Object-literal / destructuring keys (`{ "some-key": value }`) —
|
|
50
|
+
* recognized as: the literal is immediately followed by `:`, AND its
|
|
51
|
+
* immediately preceding non-whitespace character is `{` or `,` (an
|
|
52
|
+
* object-literal position), AND that preceding character is NOT `?`
|
|
53
|
+
* (a ternary's `cond ? "a" : "b"` also has a literal immediately
|
|
54
|
+
* followed by `:`-shaped text further along, but the literal
|
|
55
|
+
* immediately BEFORE a ternary's `:` is preceded by `?`, never `{`/
|
|
56
|
+
* `,` — this distinction is exactly what keeps
|
|
57
|
+
* `totalItems === 0 ? "No results" : ...` — a real line from
|
|
58
|
+
* `Pagination.tsx` — correctly classified as copy, not a key).
|
|
59
|
+
* 3. Values inside a `type` alias or `interface` body — TypeScript's own
|
|
60
|
+
* type language, never a runtime string a user sees.
|
|
61
|
+
* 4. `aria-*` and `data-*` attribute/prop VALUES, in both JSX
|
|
62
|
+
* (`aria-label="Previous page"`) and camelCase-identifier form
|
|
63
|
+
* (`` "aria-label": ariaLabel = "Select all rows" ``, the exact shape
|
|
64
|
+
* `Table.tsx`'s `TableSelectAllCheckbox` uses). Stated explicitly,
|
|
65
|
+
* because it is the one exclusion most worth arguing with: an
|
|
66
|
+
* `aria-label` genuinely is read aloud to a screen-reader user, so it
|
|
67
|
+
* IS user-facing in the most literal sense. It is excluded anyway,
|
|
68
|
+
* for a scope reason, not a "doesn't matter" reason: this repository
|
|
69
|
+
* leans heavily on icon-only controls (`Pagination`'s prev/next
|
|
70
|
+
* buttons, `Table`'s row-selection checkboxes), each of which carries
|
|
71
|
+
* an `aria-label` as its ONLY accessible name. Registering those
|
|
72
|
+
* alongside visible copy would roughly double this gate's candidate
|
|
73
|
+
* volume on `packages/ui` alone, for a category (accessibility
|
|
74
|
+
* labels) that has a different owner, a different review cadence, and
|
|
75
|
+
* arguably needs a different check shape (e.g. "every icon-only
|
|
76
|
+
* control has SOME aria-label" is a presence check, not a
|
|
77
|
+
* traceability-to-copy check). Wiring accessibility labels into this
|
|
78
|
+
* gate is real, valid future scope — deliberately not this package's
|
|
79
|
+
* v1, the same way the icon-atom migration was deliberately excluded
|
|
80
|
+
* from its own PR. A denylisted attribute value is counted and
|
|
81
|
+
* reported (see `ExcludedLiteral`), never silently dropped.
|
|
82
|
+
* 5. A fixed denylist of attribute/prop names that are structural, not
|
|
83
|
+
* textual, even though their values are strings: `className`, `class`,
|
|
84
|
+
* `id`, `key`, `htmlFor`, `role`, `type`, `rel`, `target`, `method`,
|
|
85
|
+
* `encType`, `autoComplete`, `inputMode`, `pattern`, `name`, `slot`,
|
|
86
|
+
* `variant`, `size`, `as`, `href`, `src`, `xmlns`, `viewBox`, `fill`,
|
|
87
|
+
* `stroke`, `d`, `strokeWidth`, `strokeLinecap`, `strokeLinejoin`,
|
|
88
|
+
* `testId`, `data-testid`. `className` is the canonical example this
|
|
89
|
+
* package's design brief calls out: a Tailwind utility string is not
|
|
90
|
+
* copy at all, in any sense.
|
|
91
|
+
* 6. The literal-string argument list of a class-name BUILDER call —
|
|
92
|
+
* `cx(...)` (this repository's own helper, see
|
|
93
|
+
* `packages/ui/src/atoms/internal/cx.ts`), `clsx(...)`,
|
|
94
|
+
* `classNames(...)` — even a literal that ISN'T itself passed through
|
|
95
|
+
* a `className` prop can still be pure CSS-class text if it's an
|
|
96
|
+
* argument to one of these. Detected via a bounded backward paren-
|
|
97
|
+
* balance scan to find the nearest enclosing call's identifier — see
|
|
98
|
+
* `enclosingCallName`.
|
|
99
|
+
* 7. The literal-string argument of a call this package treats as a
|
|
100
|
+
* developer diagnostic, not user copy: `console.log/warn/error/
|
|
101
|
+
* debug/info(...)`, `throw new Error(...)`, `assert(...)`,
|
|
102
|
+
* `invariant(...)`. The task brief's own words: "error messages meant
|
|
103
|
+
* for developers... look identical to a naive scanner" to real copy;
|
|
104
|
+
* this is the targeted fix for that specific confusion.
|
|
105
|
+
* 8. Literals with no letters after trimming (`"…"`, `"‹"`, `"▾"`, a bare
|
|
106
|
+
* number or punctuation glyph) — decorative or symbolic, not
|
|
107
|
+
* language-bearing text a translator or copy reviewer would ever act
|
|
108
|
+
* on. `Pagination.tsx`'s `‹`/`›` ellipsis glyphs are exactly this
|
|
109
|
+
* case.
|
|
110
|
+
* 9. A single lowercase "token" — no whitespace, matches
|
|
111
|
+
* `/^[a-z][a-z0-9-]*$/`, length <= 24 — is excluded under a heuristic
|
|
112
|
+
* reason (`enum-or-token-shaped`), NOT the type-context rule above:
|
|
113
|
+
* values like `"primary"`, `"ghost"`, `"sm"`, `"ascending"` are
|
|
114
|
+
* extremely common as component variant/size/state tokens passed as
|
|
115
|
+
* plain prop values (`<Button variant="ghost">`), a position this
|
|
116
|
+
* scanner's local-context heuristics cannot always trace back to a
|
|
117
|
+
* `type` declaration. Real visible copy in this codebase is, without
|
|
118
|
+
* exception in every example this task cites, EITHER multi-word
|
|
119
|
+
* ("No results", "Select an option", "Select all rows") OR starts
|
|
120
|
+
* with a capital letter ("Page", "Showing") — a bare, all-lowercase
|
|
121
|
+
* single word is a real, stated risk of a false NEGATIVE (a
|
|
122
|
+
* genuine one-word lowercase copy string, e.g. a placeholder
|
|
123
|
+
* literally `"search…"`, would be missed) traded deliberately against
|
|
124
|
+
* a much larger volume of false positives from variant tokens. Not
|
|
125
|
+
* silent: every such exclusion is counted and reported by reason.
|
|
126
|
+
* 10. Files matching `*.test.ts(x)`, `*.spec.ts(x)`, or `*.check.ts(x)` —
|
|
127
|
+
* test fixtures and compile-time contract assertions are not shipped
|
|
128
|
+
* copy. This is a WHOLE-FILE skip, decided in `scanCopySourceTree`,
|
|
129
|
+
* not a per-literal exclusion — see `DEFAULT_SKIP_FILE_RE`.
|
|
130
|
+
*
|
|
131
|
+
* A DIFFERENT, SIBLING mechanism — `ScanOptions.pathExclusions` — is ALSO a
|
|
132
|
+
* whole-file skip, but is consumer-CONFIGURED (this list above is fixed,
|
|
133
|
+
* shipped by this package) and answers a different question: not "is this
|
|
134
|
+
* file's CONTENT test/build machinery" but "should this file's path be
|
|
135
|
+
* looked at for copy candidates at all" (the "mention vs. use" case — a
|
|
136
|
+
* style guide documenting this voice's own banned terms trips its own
|
|
137
|
+
* rules if scanned like any other file). See `path-exclusions.ts`'s top
|
|
138
|
+
* doc comment for the full argument for why this is a NEW, separate type
|
|
139
|
+
* (`PathExclusion`/`ExcludedPath`), not an 11th `ExclusionReason` — the two
|
|
140
|
+
* mechanisms are not the same feature wearing two names.
|
|
141
|
+
*
|
|
142
|
+
* ============================================================================
|
|
143
|
+
* JSX TEXT NODES (`<span>Hello</span>`) — closing issue #37
|
|
144
|
+
* ============================================================================
|
|
145
|
+
*
|
|
146
|
+
* String/template literals are only half of what a `.tsx`/`.jsx` file can
|
|
147
|
+
* show a user: `<span>Hello</span>`'s `Hello` is neither, it is a raw JSX
|
|
148
|
+
* TEXT NODE, and a scanner that only understands literal boundaries is
|
|
149
|
+
* blind to it — a gate whose entire job is "no untracked user-facing
|
|
150
|
+
* copy" would report a clean pass over a file that is nothing but
|
|
151
|
+
* untracked copy. This is exactly what made this scanner's own top-level
|
|
152
|
+
* risk real once a JSX-heavy consumer (`@example/ui` in this same
|
|
153
|
+
* repo) existed: see issue #37.
|
|
154
|
+
*
|
|
155
|
+
* The fix stays inside "no parser library": a second, JSX-aware pass
|
|
156
|
+
* inside `tokenize()`, triggered only in a `.tsx`/`.jsx` file (`isJsxFile`
|
|
157
|
+
* — a `.ts`/`.js` file is NEVER scanned for JSX at all, so this can never
|
|
158
|
+
* fire on plain TypeScript/JavaScript). It is mutually recursive in the
|
|
159
|
+
* same style `skipInterpolation`/`skipTemplateFrom` already use for
|
|
160
|
+
* nested template interpolations:
|
|
161
|
+
*
|
|
162
|
+
* - `tryScanJsxElement` recognizes a `<` as a JSX element start ONLY
|
|
163
|
+
* when the last significant token was in "expression start" position
|
|
164
|
+
* (the exact same `lastTokenType === "other"` heuristic already used
|
|
165
|
+
* for the regex-vs-division call above) AND the next character is a
|
|
166
|
+
* valid tag-name start or `>` (a fragment `<>`). This one condition
|
|
167
|
+
* is what keeps `Map<string, string>` (preceded by the identifier
|
|
168
|
+
* `Map`, a VALUE token) and `a < b` (preceded by the identifier `a`,
|
|
169
|
+
* also a VALUE token) from ever being mistaken for JSX — a generic
|
|
170
|
+
* type argument and a comparison are structurally "value `<` ...",
|
|
171
|
+
* never "other `<` ...". A string containing `>`/`<`
|
|
172
|
+
* (`"a > b < c"`) never reaches this logic at all: the EXISTING
|
|
173
|
+
* string-literal branch above already consumes the whole literal,
|
|
174
|
+
* `<`/`>` included, before this code ever sees those characters. A
|
|
175
|
+
* `/.../ ` regex literal is the same story via the EXISTING regex
|
|
176
|
+
* branch. Once a `<` is accepted as JSX-element-shaped, this function
|
|
177
|
+
* scans the tag name and its attributes (each attribute value —
|
|
178
|
+
* quoted string or `{expression}` — skipped correctly, reusing the
|
|
179
|
+
* same string-skip and a JSX-aware sibling of `skipInterpolation`)
|
|
180
|
+
* through to the tag's own closing `>`/`/>`. Only once that full,
|
|
181
|
+
* well-formed head is found is this function COMMITTED to "this is
|
|
182
|
+
* real JSX" — a `<` that turns out to be TSX's `<T,>(x: T) => x`
|
|
183
|
+
* generic-arrow-function syntax (which also starts as `< identifier`
|
|
184
|
+
* in "other" position) fails immediately inside the attribute list,
|
|
185
|
+
* before commitment, and is silently treated as not-JSX with no
|
|
186
|
+
* `unchecked` noise at all, precisely because that failure happens
|
|
187
|
+
* too early to be good evidence either way.
|
|
188
|
+
* - `scanJsxChildren` (only reached once committed) walks the raw text
|
|
189
|
+
* between the opening tag's `>` and the next `<`, recording each
|
|
190
|
+
* non-whitespace-only run as a text-node candidate (collapsed per
|
|
191
|
+
* JSX's own whitespace rule — see `collapseJsxWhitespace` — and
|
|
192
|
+
* entity-decoded — see `decodeJsxEntities`). A `<` found here can only
|
|
193
|
+
* be a nested element (recurses back into `tryScanJsxElement`,
|
|
194
|
+
* finding that element's OWN text nodes too — this is how
|
|
195
|
+
* `{condition && <Foo>text</Foo>}`-shaped nesting inside an
|
|
196
|
+
* expression child, and plain sibling nesting like
|
|
197
|
+
* `<p>Hello <strong>there</strong> friend</p>`, both resolve: each
|
|
198
|
+
* text run between element boundaries becomes its own candidate,
|
|
199
|
+
* never concatenated across the boundary, never dropping the tail) or
|
|
200
|
+
* a closing tag (ends this element, returns to whichever caller is
|
|
201
|
+
* scanning ITS OWN children/expression one level up). A `{` starts an
|
|
202
|
+
* expression child — `{/* a JSX comment *\/}` and `{expression}` are
|
|
203
|
+
* both skipped as CODE, not text (see "WHAT THIS STILL DELIBERATELY
|
|
204
|
+
* DOES NOT CATCH" below for the one exception), via the JSX-aware
|
|
205
|
+
* sibling of `skipInterpolation` mentioned above, which is itself
|
|
206
|
+
* mutually recursive with `tryScanJsxElement`: an expression child
|
|
207
|
+
* that itself contains nested JSX (`{items.map((i) => <li
|
|
208
|
+
* key={i}>{i}</li>)}`) is walked correctly, finding ITS nested text
|
|
209
|
+
* nodes too.
|
|
210
|
+
* - Purely whitespace text runs (indentation between sibling JSX tags —
|
|
211
|
+
* the overwhelming majority of raw text a real, formatted `.tsx` file
|
|
212
|
+
* contains) are dropped at the point of collection, not even entering
|
|
213
|
+
* the candidate/exclusion pipeline. This is the one deliberate
|
|
214
|
+
* departure from "count and report every exclusion, never silently
|
|
215
|
+
* drop": that discipline exists so a REAL, AUTHORED literal a
|
|
216
|
+
* developer chose to write is never quietly reclassified without a
|
|
217
|
+
* trace. Indentation whitespace between tags was never authored
|
|
218
|
+
* content in that sense — it is the ABSENCE of a text node, exactly
|
|
219
|
+
* as invisible to a real JSX runtime as it is to this scanner, and
|
|
220
|
+
* counting it would drown `printScanAccounting`'s exclusion tally in
|
|
221
|
+
* noise proportional to a file's formatting, not its copy. A text run
|
|
222
|
+
* that IS present but decorative/symbolic (a bare `"—"` as JSX text,
|
|
223
|
+
* the direct JSX-text analogue of exclusion #8 above) still goes
|
|
224
|
+
* through the same `no-letters` exclusion as a literal would, and is
|
|
225
|
+
* still counted. The enum/token-shaped heuristic (exclusion #9) is
|
|
226
|
+
* deliberately NOT applied to JSX text: that heuristic's entire
|
|
227
|
+
* justification is "a bare lowercase word is usually a component
|
|
228
|
+
* variant/size/state PROP VALUE" — a JSX TEXT NODE can never be a
|
|
229
|
+
* prop value by construction (children and props are different
|
|
230
|
+
* syntactic positions), so the justification does not transfer, and
|
|
231
|
+
* applying it anyway would risk silently dropping exactly the short,
|
|
232
|
+
* literal words real UI badges/labels use as visible copy (`"new"`,
|
|
233
|
+
* `"beta"`, `"live"`).
|
|
234
|
+
* - Entity decoding (`decodeJsxEntities`): the common named entities
|
|
235
|
+
* (`&`, `<`, `>`, `"`, `'`, ` `, plus a
|
|
236
|
+
* small set of typographic entities real copy actually uses —
|
|
237
|
+
* `—`, `–`, `…`, `©`, `®`, `™`,
|
|
238
|
+
* curly quotes) and every numeric entity (`—`, `—`) are
|
|
239
|
+
* decoded before matching/reporting. DECODED, not "classified as
|
|
240
|
+
* unchecked": unlike a genuinely ambiguous JSX construct (see below),
|
|
241
|
+
* a named entity is a closed, enumerable vocabulary — there is no
|
|
242
|
+
* parsing risk in getting it right, only a coverage question, so this
|
|
243
|
+
* mirrors the file's own existing precedent for escape sequences (see
|
|
244
|
+
* "Full escape-sequence decoding" below): decode the common,
|
|
245
|
+
* essentially-total-coverage-in-practice set, and for a named entity
|
|
246
|
+
* outside that set, leave it verbatim (undecoded, `&whatever;`
|
|
247
|
+
* literally in the candidate's text) rather than either guessing or
|
|
248
|
+
* inventing a whole HTML entity table — an accepted, narrow
|
|
249
|
+
* simplification, not a silent one (documented here, exactly as the
|
|
250
|
+
* escape-sequence one is).
|
|
251
|
+
*
|
|
252
|
+
* WHAT THIS STILL DELIBERATELY DOES NOT CATCH, and why it is reported via
|
|
253
|
+
* `ScanResult.unchecked` instead of silently producing nothing:
|
|
254
|
+
*
|
|
255
|
+
* - Malformed/unbalanced JSX this scanner cannot resolve once it has
|
|
256
|
+
* already committed to "this is real JSX" — an opening tag with no
|
|
257
|
+
* matching closing tag before end of file, an attribute's quoted
|
|
258
|
+
* value or `{expression}` that never terminates, an expression child
|
|
259
|
+
* whose braces never balance, or JSX nesting deep enough to trip the
|
|
260
|
+
* `MAX_JSX_DEPTH` safety bound. Each of these is recorded as one
|
|
261
|
+
* `ScanResult.unchecked` entry (`kind` names the specific situation,
|
|
262
|
+
* `detail` is a human-readable explanation) rather than silently
|
|
263
|
+
* contributing zero candidates for that construct — the same
|
|
264
|
+
* "never silently narrow coverage" discipline `ParseFailure` already
|
|
265
|
+
* holds string/template literal tokenizing to, extended to the one
|
|
266
|
+
* new category (JSX) this version adds. `checkCopyTraceability` and
|
|
267
|
+
* `writer-check` (`cli.ts`) both surface a non-empty `unchecked` list —
|
|
268
|
+
* see their own doc comments for exactly where it lands in each
|
|
269
|
+
* one's contract.
|
|
270
|
+
* - A fully general regex-vs-division disambiguation for `/`. This
|
|
271
|
+
* scanner uses the same last-significant-token heuristic every
|
|
272
|
+
* hand-rolled JS lexer uses (see `isRegexAllowedHere`), which is wrong
|
|
273
|
+
* on rare, contrived inputs (e.g. `/` immediately after a `)` that
|
|
274
|
+
* closed an `if` condition, when a regex was actually intended). If
|
|
275
|
+
* that heuristic ever desyncs the scanner badly enough that quote
|
|
276
|
+
* balance goes wrong by end of file, the file is reported as a PARSE
|
|
277
|
+
* FAILURE (see `ParseFailure`) and contributes zero candidates —
|
|
278
|
+
* never silently mis-scanned.
|
|
279
|
+
* - Full escape-sequence decoding (`\u{1F600}`, `\x41`, ...). Only the
|
|
280
|
+
* common cases (`\\`, `\'`, `\"`, `` \` ``, `\n`, `\r`, `\t`, `\$`) are
|
|
281
|
+
* unescaped for the `normalized`/`raw` text used in reporting and
|
|
282
|
+
* matching; anything else has its leading backslash stripped and the
|
|
283
|
+
* rest passed through verbatim. Reporting/matching text may therefore
|
|
284
|
+
* be very slightly off for a literal using an exotic escape — an
|
|
285
|
+
* accepted, narrow simplification, not a silent one (documented here).
|
|
286
|
+
*/
|
|
287
|
+
|
|
288
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
289
|
+
import { extname, join, relative, sep } from "node:path";
|
|
290
|
+
import {
|
|
291
|
+
matchesPathExclusion,
|
|
292
|
+
validatePathExclusions,
|
|
293
|
+
type ExcludedPath,
|
|
294
|
+
type PathExclusion,
|
|
295
|
+
type PathExclusionFinding,
|
|
296
|
+
type ValidatedPathExclusion,
|
|
297
|
+
} from "./path-exclusions.js";
|
|
298
|
+
|
|
299
|
+
export type { ExcludedPath, PathExclusion, PathExclusionFinding, PathExclusionFindingRule } from "./path-exclusions.js";
|
|
300
|
+
|
|
301
|
+
// --------------------------------------------------------------- walking
|
|
302
|
+
|
|
303
|
+
export interface ScanOptions {
|
|
304
|
+
/** File extensions to read, each including the leading dot. Default: `.ts`, `.tsx`, `.js`, `.jsx`. */
|
|
305
|
+
extensions?: string[];
|
|
306
|
+
/** Directory names never descended into. Default: node_modules, .git, dist, build, coverage. */
|
|
307
|
+
skipDirs?: string[];
|
|
308
|
+
/**
|
|
309
|
+
* Files to skip entirely — never tokenized, never contribute a
|
|
310
|
+
* `CopyCandidate`/`ExcludedLiteral`/`Citation`/`UncheckedItem`. A DIFFERENT
|
|
311
|
+
* feature from `ExclusionReason` — see `path-exclusions.ts`'s top doc
|
|
312
|
+
* comment for the full "mention vs. use" case this exists for and exactly
|
|
313
|
+
* why it is not a tenth `ExclusionReason`. Defaults to `[]` (no
|
|
314
|
+
* exclusions — identical to every prior release of this package).
|
|
315
|
+
*/
|
|
316
|
+
pathExclusions?: PathExclusion[];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
320
|
+
const DEFAULT_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
321
|
+
|
|
322
|
+
/** `.test.ts(x)`, `.spec.ts(x)`, `.check.ts(x)`, and bare `.d.ts` — never shipped copy, see exclusion #10 and this file's top doc comment. */
|
|
323
|
+
const SKIP_FILE_RE = /\.(test|spec|check)\.(ts|tsx|js|jsx)$|\.d\.ts$/;
|
|
324
|
+
|
|
325
|
+
export interface CopyCandidate {
|
|
326
|
+
/** Repo-relative, `/`-joined path this candidate was found in. */
|
|
327
|
+
file: string;
|
|
328
|
+
/** 1-indexed line the literal/text node starts on. */
|
|
329
|
+
line: number;
|
|
330
|
+
/** `"jsx-text"` is a raw JSX text node (`<span>Hello</span>`'s `Hello`) — see this file's top doc comment, "JSX TEXT NODES". Unlike `"string"`/`"template"`, it has no quote/backtick delimiters at all. */
|
|
331
|
+
kind: "string" | "template" | "jsx-text";
|
|
332
|
+
/**
|
|
333
|
+
* Original source text, trimmed for display. For `"string"`/`"template"`,
|
|
334
|
+
* includes the literal's own quotes/backticks. For `"jsx-text"`, there
|
|
335
|
+
* are no delimiters to include — this is the JSX-whitespace-collapsed,
|
|
336
|
+
* entity-decoded text itself (see `collapseJsxWhitespace`/
|
|
337
|
+
* `decodeJsxEntities`).
|
|
338
|
+
*/
|
|
339
|
+
raw: string;
|
|
340
|
+
/**
|
|
341
|
+
* The literal's STATIC text with every interpolation (`${...}`)
|
|
342
|
+
* collapsed to `PLACEHOLDER_SENTINEL` — identical to `raw`'s unescaped
|
|
343
|
+
* content for a plain string (zero interpolations). This is what
|
|
344
|
+
* `copy-gate.ts` compares against a `CopyEntry.text` normalized the same
|
|
345
|
+
* way (its own `{name}` placeholders collapsed to the same sentinel) —
|
|
346
|
+
* comparing SHAPE, never a specific placeholder name, since this
|
|
347
|
+
* scanner has no way to know what name a `CopyEntry` author chose for
|
|
348
|
+
* an interpolation this source expression computes some other way.
|
|
349
|
+
*/
|
|
350
|
+
normalized: string;
|
|
351
|
+
/** Number of `${...}` interpolations found (0 for a plain string). */
|
|
352
|
+
placeholderCount: number;
|
|
353
|
+
/**
|
|
354
|
+
* Every `copy:<id>` citation found on this candidate's own source line
|
|
355
|
+
* (see `CITATION_RE`) — `copy-gate.ts`'s escape hatch, mirroring
|
|
356
|
+
* `@example/strategy`'s `fact:<key>`. Whether any of these ids
|
|
357
|
+
* actually exist in a real `CopyRecord` is `copy-gate.ts`'s question to
|
|
358
|
+
* ask, not this scanner's — this scanner only reports what text is
|
|
359
|
+
* literally present on the line.
|
|
360
|
+
*/
|
|
361
|
+
citedIds: string[];
|
|
362
|
+
/** Whether a `copy-gate:ignore` marker (see `IGNORE_MARKER_RE`) is present on this candidate's own source line. */
|
|
363
|
+
hasIgnoreMarker: boolean;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* One `copy:<id>` citation found anywhere in a file, independent of
|
|
368
|
+
* whether any candidate happens to share its line — mirrors
|
|
369
|
+
* `@example/strategy`'s `facts-gate.ts` Pass 1, which checks every
|
|
370
|
+
* line for a `fact:<key>` citation regardless of whether that line also
|
|
371
|
+
* carries a claim, so a citation to a rotted/misspelled id is caught even
|
|
372
|
+
* when it sits on a line with no traceable literal at all (e.g. a stray
|
|
373
|
+
* citation left behind after the copy it once annotated was deleted).
|
|
374
|
+
*/
|
|
375
|
+
export interface Citation {
|
|
376
|
+
file: string;
|
|
377
|
+
line: number;
|
|
378
|
+
id: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// `copy:<id>` inside an HTML comment, a block comment, a JSX comment, or a
|
|
382
|
+
// line comment — deliberately loose about the closing delimiter (only the
|
|
383
|
+
// opener plus "copy:<id>" is required), mirroring
|
|
384
|
+
// `@example/strategy`'s `FACT_CITATION_RE` so a stray missing
|
|
385
|
+
// `-->` doesn't silently defeat the citation. `id` allows the dots
|
|
386
|
+
// `CopyEntryId`'s shape requires (see `schema.ts`'s `COPY_ENTRY_ID_RE`),
|
|
387
|
+
// unlike a bare fact key.
|
|
388
|
+
const CITATION_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*copy:([a-zA-Z0-9][a-zA-Z0-9.-]*)/g;
|
|
389
|
+
const IGNORE_MARKER_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*copy-gate:ignore/i;
|
|
390
|
+
|
|
391
|
+
export type ExclusionReason =
|
|
392
|
+
| "import-or-require-specifier"
|
|
393
|
+
| "object-or-destructuring-key"
|
|
394
|
+
| "type-or-interface-context"
|
|
395
|
+
| "aria-or-data-attribute-value"
|
|
396
|
+
| "denylisted-attribute-or-prop-value"
|
|
397
|
+
| "classname-builder-argument"
|
|
398
|
+
| "developer-diagnostic-argument"
|
|
399
|
+
| "no-letters"
|
|
400
|
+
| "enum-or-token-shaped";
|
|
401
|
+
|
|
402
|
+
export interface ExcludedLiteral {
|
|
403
|
+
file: string;
|
|
404
|
+
line: number;
|
|
405
|
+
/** The literal's raw source text (quotes included), for a human auditing the exclusion list. */
|
|
406
|
+
raw: string;
|
|
407
|
+
reason: ExclusionReason;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface ParseFailure {
|
|
411
|
+
file: string;
|
|
412
|
+
/** Human-readable detail — an unterminated string/template/comment/regex, or the scanner ran off the end of the file in an inconsistent state. */
|
|
413
|
+
detail: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface SkippedFile {
|
|
417
|
+
file: string;
|
|
418
|
+
reason: "test-or-check-file";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* One construct this scanner recognized as JSX-shaped but could not
|
|
423
|
+
* reliably classify — an unclosed element, an unterminated attribute
|
|
424
|
+
* value or expression, a nested expression child whose braces never
|
|
425
|
+
* balanced, or JSX nesting deep enough to trip the depth safety bound.
|
|
426
|
+
* See this file's top doc comment, "WHAT THIS STILL DELIBERATELY DOES
|
|
427
|
+
* NOT CATCH". NEVER how a genuine blind spot gets to stay invisible: a
|
|
428
|
+
* consumer that only checks `candidates`/`excluded` and ignores this
|
|
429
|
+
* field would be exactly the failure mode issue #37 was about — a scan
|
|
430
|
+
* that narrowed its own coverage and reported the narrowed result as a
|
|
431
|
+
* pass. `checkCopyTraceability` (`copy-gate.ts`) and `writer-check`
|
|
432
|
+
* (`cli.ts`) both refuse to let a non-empty list here go unreported.
|
|
433
|
+
*/
|
|
434
|
+
export interface UncheckedItem {
|
|
435
|
+
file: string;
|
|
436
|
+
line: number;
|
|
437
|
+
/** A short, stable machine-readable name for the situation, e.g. `"unclosed-jsx-element"`, `"malformed-jsx-expression"`, `"malformed-jsx-tag"`, `"unrecognized-jsx-child"`, `"jsx-depth-exceeded"`. */
|
|
438
|
+
kind: string;
|
|
439
|
+
/** Human-readable explanation, for a report or a human auditing the list. */
|
|
440
|
+
detail: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface ScanResult {
|
|
444
|
+
/** Files whose content was successfully tokenized (whether or not it produced any candidates). This is the number `cli.ts` requires to be > 0 for a clean pass — see copy-gate.ts / cli.ts's exit-code discipline. */
|
|
445
|
+
filesScanned: number;
|
|
446
|
+
candidates: CopyCandidate[];
|
|
447
|
+
/** Every literal this scanner found and deliberately excluded, with why — never silently dropped. See exclusions #1-#9 above. */
|
|
448
|
+
excluded: ExcludedLiteral[];
|
|
449
|
+
/** Every `copy:<id>` citation found anywhere in a scanned file — see `Citation`'s own doc comment for why this is file-wide, not just per-candidate. */
|
|
450
|
+
citations: Citation[];
|
|
451
|
+
/** Files matched by extension/walk but never tokenized at all — test/check/declaration files, by design (exclusion #10). Counted, never silent. */
|
|
452
|
+
skippedByDesign: SkippedFile[];
|
|
453
|
+
/** Files that were read but could not be reliably tokenized (unbalanced quote/comment/regex state at EOF) — contribute zero candidates. Counted, never silently treated as "0 findings". */
|
|
454
|
+
parseFailures: ParseFailure[];
|
|
455
|
+
/** Constructs the scanner recognized as JSX-shaped but could not classify — see `UncheckedItem`. Empty is the ordinary, expected outcome for a well-formed file; it is never emptied to make a report look cleaner than it is. */
|
|
456
|
+
unchecked: UncheckedItem[];
|
|
457
|
+
/** Files matched by extension/walk but skipped entirely because a caller-supplied `ScanOptions.pathExclusions` entry matched their path — see `path-exclusions.ts`. Counted, never silent, and DIFFERENT from `skippedByDesign` (this package's own built-in test/check-file skip) and from `excluded` (a per-LITERAL classification inside a file that WAS scanned). */
|
|
458
|
+
excludedFiles: ExcludedPath[];
|
|
459
|
+
/** Problems with `ScanOptions.pathExclusions` itself, not with any scanned file's content — a malformed entry (`"error"`, never applied) or an entry that matched zero files this run (`"warning"`, stale but still applied as configured). See `path-exclusions.ts`'s top doc comment for the fail-closed contract. Empty is the ordinary outcome when `pathExclusions` is omitted or every entry is well-formed and matched something. */
|
|
460
|
+
pathExclusionFindings: PathExclusionFinding[];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Walks `root` recursively, reads every matching file, and extracts copy
|
|
465
|
+
* candidates from each (see `extractCopyCandidates`). FAILS CLOSED on an
|
|
466
|
+
* unreadable directory or file — throws a plain `Error`, matching
|
|
467
|
+
* `@example/strategy`'s `scanStrategyDirectory` and this
|
|
468
|
+
* repository's own `scripts/check-contamination-classes.mjs` walker: a
|
|
469
|
+
* directory this function could not read might be hiding an unknown,
|
|
470
|
+
* unbounded amount of unregistered copy, and reporting "0 files scanned"
|
|
471
|
+
* for that case would read as a clean pass when nothing was actually
|
|
472
|
+
* verified. `cli.ts` is what turns this thrown error into exit code 2.
|
|
473
|
+
*/
|
|
474
|
+
export function scanCopySourceTree(root: string, options: ScanOptions = {}): ScanResult {
|
|
475
|
+
const extensions = new Set(options.extensions ?? DEFAULT_EXTENSIONS);
|
|
476
|
+
const skipDirs = new Set(options.skipDirs ?? DEFAULT_SKIP_DIRS);
|
|
477
|
+
|
|
478
|
+
// Validated ONCE, up front — never re-validated per file. A malformed
|
|
479
|
+
// entry is reported and never applied (see path-exclusions.ts); a
|
|
480
|
+
// well-formed entry that matches zero files is reported AFTER the walk
|
|
481
|
+
// completes, once its real match count is known — see the bottom of this
|
|
482
|
+
// function.
|
|
483
|
+
const pathExclusionValidation = validatePathExclusions(options.pathExclusions);
|
|
484
|
+
const matchCounts = new Map<ValidatedPathExclusion, number>();
|
|
485
|
+
for (const exclusion of pathExclusionValidation.valid) matchCounts.set(exclusion, 0);
|
|
486
|
+
|
|
487
|
+
const result: ScanResult = {
|
|
488
|
+
filesScanned: 0,
|
|
489
|
+
candidates: [],
|
|
490
|
+
excluded: [],
|
|
491
|
+
citations: [],
|
|
492
|
+
skippedByDesign: [],
|
|
493
|
+
parseFailures: [],
|
|
494
|
+
unchecked: [],
|
|
495
|
+
excludedFiles: [],
|
|
496
|
+
pathExclusionFindings: [...pathExclusionValidation.findings],
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
function walk(dir: string): void {
|
|
500
|
+
let entries: string[];
|
|
501
|
+
try {
|
|
502
|
+
entries = readdirSync(dir);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`scanCopySourceTree: cannot read directory "${dir}": ${error instanceof Error ? error.message : String(error)}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
for (const entry of entries) {
|
|
509
|
+
if (skipDirs.has(entry)) continue;
|
|
510
|
+
const full = join(dir, entry);
|
|
511
|
+
let stat;
|
|
512
|
+
try {
|
|
513
|
+
stat = statSync(full);
|
|
514
|
+
} catch {
|
|
515
|
+
continue; // broken symlink — nothing to read, not a directory-listing failure
|
|
516
|
+
}
|
|
517
|
+
if (stat.isDirectory()) {
|
|
518
|
+
walk(full);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (!stat.isFile()) continue;
|
|
522
|
+
if (!extensions.has(extname(entry).toLowerCase())) continue;
|
|
523
|
+
|
|
524
|
+
const relPath = relative(root, full).split(sep).join("/");
|
|
525
|
+
|
|
526
|
+
// Consumer-supplied path exclusions are checked BEFORE this package's
|
|
527
|
+
// own built-in test/check-file skip, and against EVERY valid
|
|
528
|
+
// exclusion (not just the first match) so every exclusion's own match
|
|
529
|
+
// count stays accurate for the unused-exclusion check below. A file
|
|
530
|
+
// this matches is never tokenized at all — no candidates, no excluded
|
|
531
|
+
// literals, no citations, no unchecked items, exactly like a file
|
|
532
|
+
// this package could not read.
|
|
533
|
+
let matchedExclusion: ValidatedPathExclusion | undefined;
|
|
534
|
+
for (const exclusion of pathExclusionValidation.valid) {
|
|
535
|
+
if (matchesPathExclusion(relPath, exclusion)) {
|
|
536
|
+
matchCounts.set(exclusion, (matchCounts.get(exclusion) ?? 0) + 1);
|
|
537
|
+
matchedExclusion ??= exclusion;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (matchedExclusion) {
|
|
541
|
+
result.excludedFiles.push({ file: relPath, reason: matchedExclusion.reason, pattern: matchedExclusion.path });
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (SKIP_FILE_RE.test(entry)) {
|
|
546
|
+
result.skippedByDesign.push({ file: relPath, reason: "test-or-check-file" });
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let content: string;
|
|
551
|
+
try {
|
|
552
|
+
content = readFileSync(full, "utf8");
|
|
553
|
+
} catch (error) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`scanCopySourceTree: cannot read file "${full}": ${error instanceof Error ? error.message : String(error)}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const extracted = extractCopyCandidates(content, relPath);
|
|
560
|
+
if (extracted.parseFailure) {
|
|
561
|
+
result.parseFailures.push({ file: relPath, detail: extracted.parseFailure });
|
|
562
|
+
continue; // no candidates trusted from a file we could not reliably tokenize
|
|
563
|
+
}
|
|
564
|
+
result.filesScanned++;
|
|
565
|
+
result.candidates.push(...extracted.candidates);
|
|
566
|
+
result.excluded.push(...extracted.excluded);
|
|
567
|
+
result.citations.push(...extracted.citations);
|
|
568
|
+
result.unchecked.push(...extracted.unchecked);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
walk(root);
|
|
573
|
+
|
|
574
|
+
// A path exclusion that matched nothing this run is worth reporting too —
|
|
575
|
+
// a stale exclusion (the file it named was renamed/deleted) is otherwise
|
|
576
|
+
// indistinguishable from one still doing real work. See path-exclusions.ts's
|
|
577
|
+
// top doc comment.
|
|
578
|
+
for (const exclusion of pathExclusionValidation.valid) {
|
|
579
|
+
if ((matchCounts.get(exclusion) ?? 0) === 0) {
|
|
580
|
+
result.pathExclusionFindings.push({
|
|
581
|
+
rule: "path-exclusion-unused",
|
|
582
|
+
severity: "warning",
|
|
583
|
+
message: `pathExclusions entry "${exclusion.path}" (reason: "${exclusion.reason}") did not match any scanned file. Remove it, or check for a typo/renamed path.`,
|
|
584
|
+
path: exclusion.path,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ------------------------------------------------------------- tokenizer
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Collapses every interpolation in a template literal's static text to one
|
|
596
|
+
* comparable shape. `\uE000` is the first code point of the Unicode
|
|
597
|
+
* Private Use Area — reserved by the standard for exactly this kind of
|
|
598
|
+
* private, non-textual sentinel use, and something no legitimate JS/TS
|
|
599
|
+
* source file authoring real UI copy will ever contain, so it can be
|
|
600
|
+
* dropped in without risk of colliding with genuine copy text (unlike,
|
|
601
|
+
* say, a literal space or `{}`, which real copy could plausibly contain).
|
|
602
|
+
* Exported so `copy-gate.ts` normalizes `CopyEntry.text`'s `{name}`
|
|
603
|
+
* placeholders to this exact same sentinel before comparing shapes.
|
|
604
|
+
*/
|
|
605
|
+
export const PLACEHOLDER_SENTINEL = "\uE000";
|
|
606
|
+
|
|
607
|
+
interface Literal {
|
|
608
|
+
kind: "string" | "template";
|
|
609
|
+
line: number;
|
|
610
|
+
raw: string;
|
|
611
|
+
staticParts: string[];
|
|
612
|
+
placeholderCount: number;
|
|
613
|
+
/** Index into `content` where this literal's opening quote/backtick sits — used for local-context classification. */
|
|
614
|
+
start: number;
|
|
615
|
+
/** Index one past this literal's closing quote/backtick. */
|
|
616
|
+
end: number;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** One raw JSX text-node run, as found by `tokenize`'s JSX-aware pass — pre-collapse, pre-entity-decode. See `scanJsxChildren`. */
|
|
620
|
+
interface JsxTextRun {
|
|
621
|
+
/** 1-indexed line this run's first character sits on. */
|
|
622
|
+
line: number;
|
|
623
|
+
raw: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** One JSX-shaped construct `tokenize`'s JSX-aware pass could not resolve. Mirrors `UncheckedItem` minus `file`, which `extractCopyCandidates` attaches. */
|
|
627
|
+
interface RawUnchecked {
|
|
628
|
+
line: number;
|
|
629
|
+
kind: string;
|
|
630
|
+
detail: string;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
interface TokenizeResult {
|
|
634
|
+
literals: Literal[];
|
|
635
|
+
jsxTexts: JsxTextRun[];
|
|
636
|
+
unchecked: RawUnchecked[];
|
|
637
|
+
/** Set when the scanner could not reach a consistent end-of-file state (unterminated string/template/comment, or a regex heuristic that desynced quote tracking). */
|
|
638
|
+
failure?: string;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Safety bound on JSX nesting depth `tryScanJsxElement`/`scanJsxChildren` will recurse through — real component trees never come close; this exists only to keep adversarial/malformed input from recursing unboundedly. Exceeding it is reported via `UncheckedItem`, never a crash or a silent truncation. */
|
|
642
|
+
const MAX_JSX_DEPTH = 200;
|
|
643
|
+
|
|
644
|
+
const UNESCAPE_MAP: Record<string, string> = {
|
|
645
|
+
"\\": "\\",
|
|
646
|
+
"'": "'",
|
|
647
|
+
'"': '"',
|
|
648
|
+
"`": "`",
|
|
649
|
+
n: "\n",
|
|
650
|
+
r: "\r",
|
|
651
|
+
t: "\t",
|
|
652
|
+
$: "$",
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
function unescape(raw: string): string {
|
|
656
|
+
let out = "";
|
|
657
|
+
for (let i = 0; i < raw.length; i++) {
|
|
658
|
+
const c = raw[i] as string;
|
|
659
|
+
if (c === "\\" && i + 1 < raw.length) {
|
|
660
|
+
const next = raw[i + 1] as string;
|
|
661
|
+
out += next in UNESCAPE_MAP ? UNESCAPE_MAP[next] : next;
|
|
662
|
+
i++;
|
|
663
|
+
} else {
|
|
664
|
+
out += c;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return out;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Keywords after which `/` cannot be a division operator — an expression is expected next, so `/` starts a regex. */
|
|
671
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
672
|
+
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await",
|
|
673
|
+
]);
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Tokenizes `content` for string/template literals, comments, regex
|
|
677
|
+
* literals, and — when `isJsxFile` is true — JSX elements and their text
|
|
678
|
+
* nodes, tracking just enough state to know where each real boundary is.
|
|
679
|
+
* `isJsxFile` gates ALL JSX handling: a `.ts`/`.js` file (`isJsxFile ===
|
|
680
|
+
* false`) runs through exactly the same literal/comment/regex logic this
|
|
681
|
+
* function always had, completely unchanged, and can never produce a
|
|
682
|
+
* `"jsx-text"` candidate or a JSX `UncheckedItem` — see this file's top
|
|
683
|
+
* doc comment for the regex/division heuristic (reused, unmodified, to
|
|
684
|
+
* decide `<` too) and its documented limitation.
|
|
685
|
+
*/
|
|
686
|
+
function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
687
|
+
const literals: Literal[] = [];
|
|
688
|
+
const jsxTexts: JsxTextRun[] = [];
|
|
689
|
+
const unchecked: RawUnchecked[] = [];
|
|
690
|
+
const n = content.length;
|
|
691
|
+
let i = 0;
|
|
692
|
+
let line = 1;
|
|
693
|
+
// 'value' means the last significant token could end an expression (so a
|
|
694
|
+
// following `/` divides it, and a following `<` is a comparison/generic,
|
|
695
|
+
// never JSX); 'other' means an expression is expected next (so a
|
|
696
|
+
// following `/` starts a regex, and a following `<` MAY start JSX).
|
|
697
|
+
let lastTokenType: "value" | "other" = "other";
|
|
698
|
+
|
|
699
|
+
function advanceLine(from: number, to: number): void {
|
|
700
|
+
for (let k = from; k < to; k++) if (content[k] === "\n") line++;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function pushUnchecked(atLine: number, kind: string, detail: string): void {
|
|
704
|
+
unchecked.push({ line: atLine, kind, detail });
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Skips a `${...}` interpolation body, starting right after its opening
|
|
709
|
+
* `${` (i.e. `pos` is the first character of the expression, not the
|
|
710
|
+
* `$` or `{` themselves). Returns the index one past the matching `}`,
|
|
711
|
+
* or `-1` if it never closes. Brace-depth-aware, and recurses into
|
|
712
|
+
* `skipTemplateFrom` for a template literal nested inside the
|
|
713
|
+
* expression (`` `outer ${`inner`}` ``) and handles a plain `'...'`/
|
|
714
|
+
* `"..."` string and `//`/`/* *\/` comments inline — none of which can
|
|
715
|
+
* prematurely close the interpolation just because they happen to
|
|
716
|
+
* contain a `}` character. Mutually recursive with `skipTemplateFrom`
|
|
717
|
+
* so an arbitrarily deep nesting of templates-within-interpolations
|
|
718
|
+
* resolves correctly without ever re-tokenizing content this function
|
|
719
|
+
* has already accounted for (the earlier, buggier version of this
|
|
720
|
+
* function did that — see this file's history — by recursively calling
|
|
721
|
+
* the OUTER `tokenize` on the remaining file text, which re-scanned
|
|
722
|
+
* everything after the nested template as if it were a fresh file and
|
|
723
|
+
* could misinterpret the true template's own closing backtick as a new
|
|
724
|
+
* template's opening one).
|
|
725
|
+
*/
|
|
726
|
+
function skipInterpolation(pos: number): number {
|
|
727
|
+
let depth = 1;
|
|
728
|
+
let j = pos;
|
|
729
|
+
while (j < n && depth > 0) {
|
|
730
|
+
const ck = content[j] as string;
|
|
731
|
+
if (ck === "\n") {
|
|
732
|
+
line++;
|
|
733
|
+
j++;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (ck === "{") {
|
|
737
|
+
depth++;
|
|
738
|
+
j++;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (ck === "}") {
|
|
742
|
+
depth--;
|
|
743
|
+
j++;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (ck === '"' || ck === "'") {
|
|
747
|
+
const q = ck;
|
|
748
|
+
j++;
|
|
749
|
+
while (j < n && content[j] !== q) {
|
|
750
|
+
if (content[j] === "\\") j++;
|
|
751
|
+
if (content[j] === "\n") line++;
|
|
752
|
+
j++;
|
|
753
|
+
}
|
|
754
|
+
j++; // consume closing quote, or run past EOF — caught by the outer unterminated-template check
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (ck === "`") {
|
|
758
|
+
const next = skipTemplateFrom(j);
|
|
759
|
+
if (next === -1) return -1;
|
|
760
|
+
j = next;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (ck === "/" && content[j + 1] === "/") {
|
|
764
|
+
const nl = content.indexOf("\n", j);
|
|
765
|
+
j = nl === -1 ? n : nl;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (ck === "/" && content[j + 1] === "*") {
|
|
769
|
+
const close = content.indexOf("*/", j + 2);
|
|
770
|
+
if (close === -1) return -1;
|
|
771
|
+
advanceLine(j, close + 2);
|
|
772
|
+
j = close + 2;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
j++;
|
|
776
|
+
}
|
|
777
|
+
return depth === 0 ? j : -1;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Skips an entire template literal starting at `pos` (`content[pos] === "\`"`), including every interpolation it contains, recursively. Returns the index one past its closing backtick, or `-1` if unterminated. */
|
|
781
|
+
function skipTemplateFrom(pos: number): number {
|
|
782
|
+
let j = pos + 1;
|
|
783
|
+
while (j < n) {
|
|
784
|
+
const cj = content[j] as string;
|
|
785
|
+
if (cj === "\\" && j + 1 < n) {
|
|
786
|
+
j += 2;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (cj === "`") return j + 1;
|
|
790
|
+
if (cj === "\n") {
|
|
791
|
+
line++;
|
|
792
|
+
j++;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (cj === "$" && content[j + 1] === "{") {
|
|
796
|
+
const next = skipInterpolation(j + 2);
|
|
797
|
+
if (next === -1) return -1;
|
|
798
|
+
j = next;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
j++;
|
|
802
|
+
}
|
|
803
|
+
return -1;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Set by `scanStringLiteral`/`scanTemplateLiteral` immediately before either returns `-1` — the failure-message side channel a plain `number` return can't carry. Read by every caller right after a `-1` result. */
|
|
807
|
+
let literalFailure: string | undefined;
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Scans a plain string literal starting at `pos`
|
|
811
|
+
* (`content[pos] === '"' | "'"`), pushes it to `literals`, and returns
|
|
812
|
+
* the index one past its closing quote — or `-1` (with `literalFailure`
|
|
813
|
+
* set) if unterminated. SHARED by the main loop and every JSX
|
|
814
|
+
* attribute-value / expression-container path below (`tryScanJsxElement`,
|
|
815
|
+
* `skipJsxExpressionContainer`) — this is deliberate, not incidental
|
|
816
|
+
* reuse: a string literal found ANYWHERE in a `.tsx` file, whether it
|
|
817
|
+
* is ordinary JS code or a JSX attribute value or an expression child,
|
|
818
|
+
* must produce the exact same `Literal` shape and flow through
|
|
819
|
+
* `extractCopyCandidates`'s existing classification exactly the same
|
|
820
|
+
* way. `precedingAssignmentName`/`enclosingCallName`/`isInTypeContext`
|
|
821
|
+
* all re-derive their answer from `content` and a literal's own
|
|
822
|
+
* `start` index, independent of which code path found it, so this is
|
|
823
|
+
* enough to keep e.g. `<Button aria-label="Previous page">`'s
|
|
824
|
+
* `"Previous page"` correctly excluded as an aria value — exactly as
|
|
825
|
+
* it was before this file understood JSX at all — rather than being
|
|
826
|
+
* silently swallowed as opaque tag-head text the moment JSX-awareness
|
|
827
|
+
* was added. (One accepted simplification: real JSX attribute strings
|
|
828
|
+
* have no backslash-escape mechanism at all, unlike a JS string; this
|
|
829
|
+
* function applies JS's escape rules everywhere, including inside a
|
|
830
|
+
* JSX attribute. Indistinguishable in practice — nobody writes a
|
|
831
|
+
* backslash immediately before the closing quote of a JSX attribute —
|
|
832
|
+
* so not worth a second, JSX-specific string scanner.)
|
|
833
|
+
*/
|
|
834
|
+
function scanStringLiteral(pos: number): number {
|
|
835
|
+
const quote = content[pos] as string;
|
|
836
|
+
const startLine = line;
|
|
837
|
+
const start = pos;
|
|
838
|
+
let j = pos + 1;
|
|
839
|
+
let value = "";
|
|
840
|
+
let closed = false;
|
|
841
|
+
while (j < n) {
|
|
842
|
+
const cj = content[j] as string;
|
|
843
|
+
if (cj === "\\" && j + 1 < n) {
|
|
844
|
+
value += cj + content[j + 1];
|
|
845
|
+
j += 2;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (cj === quote) {
|
|
849
|
+
closed = true;
|
|
850
|
+
j++;
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
if (cj === "\n") break; // unterminated on this line — a real JS/JSX string never spans a raw newline
|
|
854
|
+
value += cj;
|
|
855
|
+
j++;
|
|
856
|
+
}
|
|
857
|
+
if (!closed) {
|
|
858
|
+
literalFailure = `unterminated string starting at line ${startLine}`;
|
|
859
|
+
return -1;
|
|
860
|
+
}
|
|
861
|
+
literals.push({
|
|
862
|
+
kind: "string",
|
|
863
|
+
line: startLine,
|
|
864
|
+
raw: content.slice(start, j),
|
|
865
|
+
staticParts: [unescape(value)],
|
|
866
|
+
placeholderCount: 0,
|
|
867
|
+
start,
|
|
868
|
+
end: j,
|
|
869
|
+
});
|
|
870
|
+
return j;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Scans a template literal starting at `pos` (`content[pos] === "\`"`),
|
|
875
|
+
* collecting its static text segments and delegating every
|
|
876
|
+
* `${...}` interpolation to `skipInterpolation` — exactly like the
|
|
877
|
+
* main loop's own former inline template handling, now shared with
|
|
878
|
+
* `tryScanJsxElement`/`skipJsxExpressionContainer` for the identical
|
|
879
|
+
* reason `scanStringLiteral` is shared (see its own doc comment).
|
|
880
|
+
* Pushes a `Literal` and returns the index one past the closing
|
|
881
|
+
* backtick, or `-1` (with `literalFailure` set) if unterminated. NOT
|
|
882
|
+
* used for a template nested inside another template's own
|
|
883
|
+
* interpolation — that case still goes through the non-collecting
|
|
884
|
+
* `skipTemplateFrom` above, unchanged pre-existing behavior this
|
|
885
|
+
* change does not touch (see that function's own doc comment).
|
|
886
|
+
*/
|
|
887
|
+
function scanTemplateLiteral(pos: number): number {
|
|
888
|
+
const startLine = line;
|
|
889
|
+
const start = pos;
|
|
890
|
+
const staticParts: string[] = [];
|
|
891
|
+
let placeholderCount = 0;
|
|
892
|
+
let j = pos + 1;
|
|
893
|
+
let current = "";
|
|
894
|
+
let closed = false;
|
|
895
|
+
while (j < n) {
|
|
896
|
+
const cj = content[j] as string;
|
|
897
|
+
if (cj === "\\" && j + 1 < n) {
|
|
898
|
+
current += cj + content[j + 1];
|
|
899
|
+
j += 2;
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (cj === "`") {
|
|
903
|
+
closed = true;
|
|
904
|
+
j++;
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
if (cj === "$" && content[j + 1] === "{") {
|
|
908
|
+
staticParts.push(unescape(current));
|
|
909
|
+
current = "";
|
|
910
|
+
placeholderCount++;
|
|
911
|
+
// THIS interpolation's own `${`, not the enclosing template
|
|
912
|
+
// literal's `startLine` — a multi-line template can carry the
|
|
913
|
+
// `${` many lines past its own opening backtick, and a message
|
|
914
|
+
// claiming "starting at line X" must report where the thing it
|
|
915
|
+
// names actually starts.
|
|
916
|
+
const interpolationStartLine = line;
|
|
917
|
+
const next = skipInterpolation(j + 2);
|
|
918
|
+
if (next === -1) {
|
|
919
|
+
literalFailure = `unterminated \${...} interpolation starting at line ${interpolationStartLine}`;
|
|
920
|
+
return -1;
|
|
921
|
+
}
|
|
922
|
+
j = next;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (cj === "\n") line++;
|
|
926
|
+
current += cj;
|
|
927
|
+
j++;
|
|
928
|
+
}
|
|
929
|
+
if (!closed) {
|
|
930
|
+
literalFailure = `unterminated template literal starting at line ${startLine}`;
|
|
931
|
+
return -1;
|
|
932
|
+
}
|
|
933
|
+
staticParts.push(unescape(current));
|
|
934
|
+
literals.push({
|
|
935
|
+
kind: "template",
|
|
936
|
+
line: startLine,
|
|
937
|
+
raw: content.slice(start, j),
|
|
938
|
+
staticParts,
|
|
939
|
+
placeholderCount,
|
|
940
|
+
start,
|
|
941
|
+
end: j,
|
|
942
|
+
});
|
|
943
|
+
return j;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Skips a JSX `{...}` container — a spread attribute (`{...props}`), an
|
|
948
|
+
* attribute value (`name={expr}`), or a children expression
|
|
949
|
+
* (`{expression}`, including a JSX comment `{/* ... *\/}`, which is
|
|
950
|
+
* just a block comment that this function's own comment-skipping
|
|
951
|
+
* already consumes as opaque text — no special case needed). `pos` is
|
|
952
|
+
* the index right after the opening `{`. Brace-depth-aware and
|
|
953
|
+
* string/template/comment-safe, exactly like `skipInterpolation`
|
|
954
|
+
* above, with ONE addition: a `<` encountered here in expression-start
|
|
955
|
+
* position may open a NESTED JSX element (`{condition && <Foo>text
|
|
956
|
+
* </Foo>}` is an ordinary React pattern), resolved via
|
|
957
|
+
* `tryScanJsxElement` — which is what makes this function and
|
|
958
|
+
* `tryScanJsxElement`/`scanJsxChildren` mutually recursive, the same
|
|
959
|
+
* shape `skipInterpolation`/`skipTemplateFrom` already use one level
|
|
960
|
+
* up. Deliberately NOT a call to `skipInterpolation` itself: that
|
|
961
|
+
* function has no JSX awareness (by design — a `${...}` template
|
|
962
|
+
* interpolation containing JSX is exotic enough not to be worth
|
|
963
|
+
* teaching it), so this is its own, JSX-aware sibling rather than a
|
|
964
|
+
* shared implementation. Returns the index one past the matching `}`,
|
|
965
|
+
* or `-1` if it never closes.
|
|
966
|
+
*/
|
|
967
|
+
function skipJsxExpressionContainer(pos: number, depth: number): number {
|
|
968
|
+
let braceDepth = 1;
|
|
969
|
+
let j = pos;
|
|
970
|
+
let localLast: "value" | "other" = "other";
|
|
971
|
+
while (j < n && braceDepth > 0) {
|
|
972
|
+
const cj = content[j] as string;
|
|
973
|
+
if (cj === "\n") {
|
|
974
|
+
line++;
|
|
975
|
+
j++;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (cj === " " || cj === "\t" || cj === "\r") {
|
|
979
|
+
j++;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (cj === "/" && content[j + 1] === "/") {
|
|
983
|
+
const nl = content.indexOf("\n", j);
|
|
984
|
+
j = nl === -1 ? n : nl;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (cj === "/" && content[j + 1] === "*") {
|
|
988
|
+
const close = content.indexOf("*/", j + 2);
|
|
989
|
+
if (close === -1) return -1;
|
|
990
|
+
advanceLine(j, close + 2);
|
|
991
|
+
j = close + 2;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if (cj === '"' || cj === "'") {
|
|
995
|
+
const end = scanStringLiteral(j);
|
|
996
|
+
if (end === -1) return -1;
|
|
997
|
+
j = end;
|
|
998
|
+
localLast = "value";
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (cj === "`") {
|
|
1002
|
+
const end = scanTemplateLiteral(j);
|
|
1003
|
+
if (end === -1) return -1;
|
|
1004
|
+
j = end;
|
|
1005
|
+
localLast = "value";
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (cj === "<" && isJsxFile && localLast === "other") {
|
|
1009
|
+
const elEnd = tryScanJsxElement(j, depth + 1);
|
|
1010
|
+
if (elEnd !== null) {
|
|
1011
|
+
j = elEnd;
|
|
1012
|
+
localLast = "value";
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
// Not JSX after all — fall through and treat `<` as an ordinary
|
|
1016
|
+
// operator character (comparison/generic), same as the top-level
|
|
1017
|
+
// loop does.
|
|
1018
|
+
}
|
|
1019
|
+
if (cj === "{") {
|
|
1020
|
+
braceDepth++;
|
|
1021
|
+
j++;
|
|
1022
|
+
localLast = "other";
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (cj === "}") {
|
|
1026
|
+
braceDepth--;
|
|
1027
|
+
j++;
|
|
1028
|
+
localLast = "value";
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (cj === "(" || cj === "[") {
|
|
1032
|
+
j++;
|
|
1033
|
+
localLast = "other";
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (cj === ")" || cj === "]") {
|
|
1037
|
+
j++;
|
|
1038
|
+
localLast = "value";
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (/[A-Za-z_$]/.test(cj)) {
|
|
1042
|
+
let k = j;
|
|
1043
|
+
while (k < n && /[A-Za-z0-9_$]/.test(content[k] as string)) k++;
|
|
1044
|
+
const word = content.slice(j, k);
|
|
1045
|
+
localLast = REGEX_PRECEDING_KEYWORDS.has(word) ? "other" : "value";
|
|
1046
|
+
j = k;
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (/[0-9]/.test(cj)) {
|
|
1050
|
+
let k = j;
|
|
1051
|
+
while (k < n && /[0-9a-fA-Fx.]/.test(content[k] as string)) k++;
|
|
1052
|
+
j = k;
|
|
1053
|
+
localLast = "value";
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (cj === "/") {
|
|
1057
|
+
if (localLast === "other") {
|
|
1058
|
+
const lineEnd = (() => {
|
|
1059
|
+
const nl = content.indexOf("\n", j);
|
|
1060
|
+
return nl === -1 ? n : nl;
|
|
1061
|
+
})();
|
|
1062
|
+
let k = j + 1;
|
|
1063
|
+
let inClass = false;
|
|
1064
|
+
let found = -1;
|
|
1065
|
+
while (k < lineEnd) {
|
|
1066
|
+
const ck = content[k] as string;
|
|
1067
|
+
if (ck === "\\") {
|
|
1068
|
+
k += 2;
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (ck === "[") inClass = true;
|
|
1072
|
+
else if (ck === "]") inClass = false;
|
|
1073
|
+
else if (ck === "/" && !inClass) {
|
|
1074
|
+
found = k;
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
k++;
|
|
1078
|
+
}
|
|
1079
|
+
if (found !== -1) {
|
|
1080
|
+
let m = found + 1;
|
|
1081
|
+
while (m < n && /[a-z]/i.test(content[m] as string)) m++;
|
|
1082
|
+
j = m;
|
|
1083
|
+
localLast = "value";
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
localLast = "other";
|
|
1088
|
+
j++;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
localLast = "other";
|
|
1092
|
+
j++;
|
|
1093
|
+
}
|
|
1094
|
+
return braceDepth === 0 ? j : -1;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Walks the raw text between a JSX opening tag's `>` and its matching
|
|
1099
|
+
* closing tag, starting at `pos` (the index right after that `>`).
|
|
1100
|
+
* `tagName` is used only for `unchecked` messages (`""` for a
|
|
1101
|
+
* fragment). Every non-whitespace-only run of plain text between
|
|
1102
|
+
* element/expression boundaries becomes one `jsxTexts` entry (still
|
|
1103
|
+
* raw — collapsing per JSX's whitespace rule and entity decoding
|
|
1104
|
+
* happen once in `extractCopyCandidates`, alongside every other
|
|
1105
|
+
* candidate's classification). A purely-whitespace run (ordinary
|
|
1106
|
+
* indentation between sibling tags) is dropped here, silently, on
|
|
1107
|
+
* purpose — see this file's top doc comment for why that is the one
|
|
1108
|
+
* deliberate exception to "never silently drop". Returns the index one
|
|
1109
|
+
* past the matching closing tag's `>`.
|
|
1110
|
+
*/
|
|
1111
|
+
function scanJsxChildren(pos: number, tagName: string, depth: number, openLine: number): number {
|
|
1112
|
+
let j = pos;
|
|
1113
|
+
let runStart = j;
|
|
1114
|
+
let runStartLine = line;
|
|
1115
|
+
|
|
1116
|
+
const flush = (): void => {
|
|
1117
|
+
if (j > runStart) {
|
|
1118
|
+
const raw = content.slice(runStart, j);
|
|
1119
|
+
if (raw.trim().length > 0) jsxTexts.push({ line: runStartLine, raw });
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
while (j < n) {
|
|
1124
|
+
const cj = content[j] as string;
|
|
1125
|
+
if (cj === "<") {
|
|
1126
|
+
flush();
|
|
1127
|
+
if (content[j + 1] === "/") {
|
|
1128
|
+
let k = j + 2;
|
|
1129
|
+
while (k < n && content[k] !== ">") {
|
|
1130
|
+
if (content[k] === "\n") line++;
|
|
1131
|
+
k++;
|
|
1132
|
+
}
|
|
1133
|
+
if (k >= n) {
|
|
1134
|
+
// `openLine` — the line of THIS element's own opening `<`,
|
|
1135
|
+
// threaded in from `tryScanJsxElement` — not `runStartLine`
|
|
1136
|
+
// (wherever the last child text run happened to start,
|
|
1137
|
+
// which drifts arbitrarily far from the element's actual
|
|
1138
|
+
// start the more children it has). A message claiming a
|
|
1139
|
+
// position it did not compute is worse than no message.
|
|
1140
|
+
pushUnchecked(
|
|
1141
|
+
openLine,
|
|
1142
|
+
"unclosed-jsx-element",
|
|
1143
|
+
`"<${tagName}>" opened at line ${openLine} has no matching closing tag before end of file`,
|
|
1144
|
+
);
|
|
1145
|
+
return n;
|
|
1146
|
+
}
|
|
1147
|
+
return k + 1;
|
|
1148
|
+
}
|
|
1149
|
+
const nestedEnd = tryScanJsxElement(j, depth + 1);
|
|
1150
|
+
if (nestedEnd === null) {
|
|
1151
|
+
// A `<` inside an already-committed JSX children region can
|
|
1152
|
+
// only be a nested element or a closing tag by JSX's own
|
|
1153
|
+
// grammar — unlike the top-level case, there is no legitimate
|
|
1154
|
+
// "actually this was TypeScript, not JSX" explanation here, so
|
|
1155
|
+
// this genuinely is worth surfacing. `line` here is accurate —
|
|
1156
|
+
// `tryScanJsxElement` restores it to this exact position
|
|
1157
|
+
// before returning null (see its own `backtrack`) — so this
|
|
1158
|
+
// is the TRUE line of this `<`, not wherever its failed
|
|
1159
|
+
// attempt gave up.
|
|
1160
|
+
pushUnchecked(
|
|
1161
|
+
line,
|
|
1162
|
+
"unrecognized-jsx-child",
|
|
1163
|
+
`"<" at line ${line}, inside the children of "<${tagName}>", did not parse as a nested element or a closing tag`,
|
|
1164
|
+
);
|
|
1165
|
+
j++;
|
|
1166
|
+
runStart = j;
|
|
1167
|
+
runStartLine = line;
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
j = nestedEnd;
|
|
1171
|
+
runStart = j;
|
|
1172
|
+
runStartLine = line;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (cj === "{") {
|
|
1176
|
+
flush();
|
|
1177
|
+
const exprStartLine = line; // THIS expression child's own `{`, not wherever the failed scan below gives up
|
|
1178
|
+
const end = skipJsxExpressionContainer(j + 1, depth);
|
|
1179
|
+
if (end === -1) {
|
|
1180
|
+
pushUnchecked(
|
|
1181
|
+
exprStartLine,
|
|
1182
|
+
"malformed-jsx-expression",
|
|
1183
|
+
`"{...}" expression child starting at line ${exprStartLine}, inside "<${tagName}>", never closed`,
|
|
1184
|
+
);
|
|
1185
|
+
return n;
|
|
1186
|
+
}
|
|
1187
|
+
j = end;
|
|
1188
|
+
runStart = j;
|
|
1189
|
+
runStartLine = line;
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (cj === "\n") {
|
|
1193
|
+
line++;
|
|
1194
|
+
j++;
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
j++;
|
|
1198
|
+
}
|
|
1199
|
+
// Reached EOF still inside this element's children — flush whatever
|
|
1200
|
+
// text was collected (this element WAS committed as real JSX, so that
|
|
1201
|
+
// text is trustworthy) and report the missing closing tag, at ITS
|
|
1202
|
+
// OWN opening line (`openLine`), not wherever the scan happened to
|
|
1203
|
+
// reach EOF.
|
|
1204
|
+
flush();
|
|
1205
|
+
pushUnchecked(
|
|
1206
|
+
openLine,
|
|
1207
|
+
"unclosed-jsx-element",
|
|
1208
|
+
`"<${tagName}>" opened at line ${openLine} has no matching closing tag before end of file`,
|
|
1209
|
+
);
|
|
1210
|
+
return n;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Attempts to parse a JSX element (or fragment) starting at `pos`
|
|
1215
|
+
* (`content[pos] === "<"`). Returns `null` if this position does not
|
|
1216
|
+
* look like a valid JSX opening tag AT ALL — the caller (the main loop,
|
|
1217
|
+
* or `skipJsxExpressionContainer` for a nested element) then treats `<`
|
|
1218
|
+
* as an ordinary character and continues normal JS scanning from
|
|
1219
|
+
* `pos + 1`. This is what keeps TSX's generic-arrow-function syntax
|
|
1220
|
+
* (`` <T,>(x: T) => x ``, which also starts as `< identifier` in
|
|
1221
|
+
* expression-start position) from misfiring: it fails immediately,
|
|
1222
|
+
* inside the attribute list, before a valid `>`/`/>` was ever found —
|
|
1223
|
+
* BEFORE this function commits to "this is real JSX" — so it returns
|
|
1224
|
+
* `null` with no `unchecked` note at all. Once a complete, well-formed
|
|
1225
|
+
* opening-tag HEAD is found (a valid `>` or `/>`), this function is
|
|
1226
|
+
* committed: any failure from that point on (an unterminated attribute
|
|
1227
|
+
* value, an unclosed element) is real, reportable ambiguity, recorded
|
|
1228
|
+
* via `pushUnchecked` — see `scanJsxChildren` and the `pushUnchecked`
|
|
1229
|
+
* call sites below.
|
|
1230
|
+
*
|
|
1231
|
+
* BACKTRACKING IS STATEFUL, so `backtrack()` restores every bit of
|
|
1232
|
+
* shared state a failed attempt may have mutated before returning
|
|
1233
|
+
* `null` — `line`, plus the shared `literals`/`jsxTexts`/`unchecked`
|
|
1234
|
+
* arrays, truncated back to their lengths at entry. This matters for
|
|
1235
|
+
* two independent reasons, both found in review (see this package's
|
|
1236
|
+
* PR history for the concrete repro):
|
|
1237
|
+
*
|
|
1238
|
+
* 1. LINE NUMBERS. Once this function has consumed any whitespace
|
|
1239
|
+
* containing a newline (or delegated to `skipJsxExpressionContainer`
|
|
1240
|
+
* for an attribute value, which can span many lines), the shared
|
|
1241
|
+
* `line` counter has advanced past this element's own start. A
|
|
1242
|
+
* caller that reads `line` AFTER such a failure — to build an
|
|
1243
|
+
* `unchecked` message, or because the caller (the main loop) is
|
|
1244
|
+
* about to reprocess `pos + 1` onward as ordinary JS — would
|
|
1245
|
+
* either report the WRONG line (the failure/give-up position, not
|
|
1246
|
+
* the construct's actual start — the bug this comment exists to
|
|
1247
|
+
* prevent from coming back) or DOUBLE-COUNT every newline this
|
|
1248
|
+
* attempt already saw once (the main loop's own `\n` handling
|
|
1249
|
+
* would increment `line` again for the exact same characters when
|
|
1250
|
+
* it re-walks them from `pos + 1`), corrupting every subsequent
|
|
1251
|
+
* line number for the REST OF THE FILE.
|
|
1252
|
+
* 2. DUPLICATE CANDIDATES. An attribute string/template value that
|
|
1253
|
+
* parses successfully (via `scanStringLiteral`/
|
|
1254
|
+
* `skipJsxExpressionContainer`) before a LATER attribute in the
|
|
1255
|
+
* same tag fails pushes a real entry onto `literals`/`jsxTexts`.
|
|
1256
|
+
* Backtracking without removing it, then letting the caller
|
|
1257
|
+
* reprocess the same source text as ordinary JS, would rediscover
|
|
1258
|
+
* that same literal a second time — a silent duplicate candidate.
|
|
1259
|
+
*
|
|
1260
|
+
* Restoring all four pieces of state makes "return null and let the
|
|
1261
|
+
* caller reprocess from `pos + 1`" fully safe regardless of how far
|
|
1262
|
+
* into a tag this function got before failing — the `unchecked` entry
|
|
1263
|
+
* for the failure itself is pushed AFTER `backtrack()` runs, so it
|
|
1264
|
+
* alone survives.
|
|
1265
|
+
*/
|
|
1266
|
+
function tryScanJsxElement(pos: number, depth: number): number | null {
|
|
1267
|
+
const entryLine = line;
|
|
1268
|
+
const entryLiteralsCount = literals.length;
|
|
1269
|
+
const entryJsxTextsCount = jsxTexts.length;
|
|
1270
|
+
const entryUncheckedCount = unchecked.length;
|
|
1271
|
+
|
|
1272
|
+
function backtrack(): null {
|
|
1273
|
+
line = entryLine;
|
|
1274
|
+
literals.length = entryLiteralsCount;
|
|
1275
|
+
jsxTexts.length = entryJsxTextsCount;
|
|
1276
|
+
unchecked.length = entryUncheckedCount;
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (depth > MAX_JSX_DEPTH) {
|
|
1281
|
+
// backtrack() FIRST (a no-op here — nothing has been consumed yet
|
|
1282
|
+
// at this depth-exceeded check, which runs before any parsing —
|
|
1283
|
+
// but ordering it this way, identically to every other call site
|
|
1284
|
+
// below, means this can never again accidentally truncate its OWN
|
|
1285
|
+
// pushUnchecked call the way an earlier version of this fix did:
|
|
1286
|
+
// backtrack() resets `unchecked.length` to its value at function
|
|
1287
|
+
// entry, so pushing BEFORE calling it would silently erase the
|
|
1288
|
+
// very entry just pushed).
|
|
1289
|
+
backtrack();
|
|
1290
|
+
pushUnchecked(
|
|
1291
|
+
entryLine,
|
|
1292
|
+
"jsx-depth-exceeded",
|
|
1293
|
+
`JSX nesting exceeded ${MAX_JSX_DEPTH} levels near line ${entryLine} — scanning of this subtree was abandoned`,
|
|
1294
|
+
);
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
let j = pos + 1;
|
|
1299
|
+
let isFragment = false;
|
|
1300
|
+
let tagName = "";
|
|
1301
|
+
if (content[j] === ">") {
|
|
1302
|
+
isFragment = true;
|
|
1303
|
+
j++;
|
|
1304
|
+
} else if (j < n && /[A-Za-z_$]/.test(content[j] as string)) {
|
|
1305
|
+
const start = j;
|
|
1306
|
+
while (j < n && /[A-Za-z0-9_$.:-]/.test(content[j] as string)) j++;
|
|
1307
|
+
tagName = content.slice(start, j);
|
|
1308
|
+
} else {
|
|
1309
|
+
return backtrack(); // `<=`, `< `, `<<`, a stray `<` — not JSX
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
let selfClosing = false;
|
|
1313
|
+
if (!isFragment) {
|
|
1314
|
+
for (;;) {
|
|
1315
|
+
while (j < n && /\s/.test(content[j] as string)) {
|
|
1316
|
+
if (content[j] === "\n") line++;
|
|
1317
|
+
j++;
|
|
1318
|
+
}
|
|
1319
|
+
if (j >= n) return backtrack(); // ran off the end before the head ever closed — not committed, silently not-JSX
|
|
1320
|
+
|
|
1321
|
+
const cj = content[j] as string;
|
|
1322
|
+
if (cj === "/" && content[j + 1] === ">") {
|
|
1323
|
+
selfClosing = true;
|
|
1324
|
+
j += 2;
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
1327
|
+
if (cj === ">") {
|
|
1328
|
+
j++;
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
if (cj === "{") {
|
|
1332
|
+
// spread attribute: {...expr}. `spreadStartLine` — THIS
|
|
1333
|
+
// attribute's own `{`, captured before the (possibly
|
|
1334
|
+
// multi-line) scan below — is what gets reported, never
|
|
1335
|
+
// wherever that scan gave up.
|
|
1336
|
+
const spreadStartLine = line;
|
|
1337
|
+
const end = skipJsxExpressionContainer(j + 1, depth);
|
|
1338
|
+
if (end === -1) {
|
|
1339
|
+
backtrack();
|
|
1340
|
+
pushUnchecked(
|
|
1341
|
+
spreadStartLine,
|
|
1342
|
+
"malformed-jsx-tag",
|
|
1343
|
+
`spread attribute on "<${tagName}>" starting at line ${spreadStartLine} never closed`,
|
|
1344
|
+
);
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
j = end;
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (/[A-Za-z_$]/.test(cj)) {
|
|
1351
|
+
while (j < n && /[A-Za-z0-9_$:-]/.test(content[j] as string)) j++;
|
|
1352
|
+
let k = j;
|
|
1353
|
+
while (k < n && /[ \t]/.test(content[k] as string)) k++;
|
|
1354
|
+
if (content[k] === "=") {
|
|
1355
|
+
k++;
|
|
1356
|
+
while (k < n && /\s/.test(content[k] as string)) {
|
|
1357
|
+
if (content[k] === "\n") line++;
|
|
1358
|
+
k++;
|
|
1359
|
+
}
|
|
1360
|
+
const vc = content[k];
|
|
1361
|
+
// `valueStartLine` — THIS attribute value's own opening
|
|
1362
|
+
// quote/`{`, not wherever a multi-line scan below gives up.
|
|
1363
|
+
const valueStartLine = line;
|
|
1364
|
+
if (vc === '"' || vc === "'") {
|
|
1365
|
+
// Scanned via `scanStringLiteral` (not skipped inline) so
|
|
1366
|
+
// this attribute value produces a real `Literal` — see
|
|
1367
|
+
// that function's doc comment for why: an attribute
|
|
1368
|
+
// string must flow through `extractCopyCandidates`'s
|
|
1369
|
+
// classification exactly like every other literal
|
|
1370
|
+
// (`aria-or-data-attribute-value`,
|
|
1371
|
+
// `denylisted-attribute-or-prop-value`, ...), never be
|
|
1372
|
+
// silently swallowed as opaque tag-head text.
|
|
1373
|
+
const end = scanStringLiteral(k);
|
|
1374
|
+
if (end === -1) {
|
|
1375
|
+
backtrack();
|
|
1376
|
+
pushUnchecked(
|
|
1377
|
+
valueStartLine,
|
|
1378
|
+
"malformed-jsx-tag",
|
|
1379
|
+
`an attribute value on "<${tagName}>" starting at line ${valueStartLine} was never terminated`,
|
|
1380
|
+
);
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
j = end;
|
|
1384
|
+
} else if (vc === "{") {
|
|
1385
|
+
const end = skipJsxExpressionContainer(k + 1, depth);
|
|
1386
|
+
if (end === -1) {
|
|
1387
|
+
backtrack();
|
|
1388
|
+
pushUnchecked(
|
|
1389
|
+
valueStartLine,
|
|
1390
|
+
"malformed-jsx-tag",
|
|
1391
|
+
`an attribute expression value on "<${tagName}>" starting at line ${valueStartLine} never closed`,
|
|
1392
|
+
);
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
j = end;
|
|
1396
|
+
} else {
|
|
1397
|
+
return backtrack(); // `name=` followed by neither a quote nor `{` — not valid JSX attribute syntax
|
|
1398
|
+
}
|
|
1399
|
+
} else {
|
|
1400
|
+
j = k; // boolean attribute, no value
|
|
1401
|
+
}
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
return backtrack(); // e.g. the `,` in TSX's `<T,>(x: T) => x` — not committed, silently not-JSX
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (selfClosing) return j;
|
|
1409
|
+
return scanJsxChildren(j, isFragment ? "" : tagName, depth, entryLine);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
while (i < n) {
|
|
1413
|
+
const c = content[i] as string;
|
|
1414
|
+
|
|
1415
|
+
if (c === "\n") {
|
|
1416
|
+
line++;
|
|
1417
|
+
i++;
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
1421
|
+
i++;
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// line comment
|
|
1426
|
+
if (c === "/" && content[i + 1] === "/") {
|
|
1427
|
+
const nl = content.indexOf("\n", i);
|
|
1428
|
+
i = nl === -1 ? n : nl;
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// block comment
|
|
1433
|
+
if (c === "/" && content[i + 1] === "*") {
|
|
1434
|
+
const close = content.indexOf("*/", i + 2);
|
|
1435
|
+
if (close === -1) return { literals, jsxTexts, unchecked, failure: `unterminated block comment starting at line ${line}` };
|
|
1436
|
+
advanceLine(i, close + 2);
|
|
1437
|
+
i = close + 2;
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// string literal
|
|
1442
|
+
if (c === '"' || c === "'") {
|
|
1443
|
+
const end = scanStringLiteral(i);
|
|
1444
|
+
if (end === -1) return { literals, jsxTexts, unchecked, failure: literalFailure };
|
|
1445
|
+
lastTokenType = "value";
|
|
1446
|
+
i = end;
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// template literal
|
|
1451
|
+
if (c === "`") {
|
|
1452
|
+
const end = scanTemplateLiteral(i);
|
|
1453
|
+
if (end === -1) return { literals, jsxTexts, unchecked, failure: literalFailure };
|
|
1454
|
+
lastTokenType = "value";
|
|
1455
|
+
i = end;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// possible JSX element start — see this file's top doc comment, "JSX
|
|
1460
|
+
// TEXT NODES". Gated on `isJsxFile` (a .ts/.js file never reaches
|
|
1461
|
+
// `tryScanJsxElement` at all) and on `lastTokenType === "other"` —
|
|
1462
|
+
// the exact same "expression expected next" heuristic the regex
|
|
1463
|
+
// branch below uses for `/`, which is what keeps `Map<string,
|
|
1464
|
+
// string>` and `a < b` (both preceded by an identifier, a VALUE
|
|
1465
|
+
// token) from ever being tried as JSX.
|
|
1466
|
+
if (c === "<") {
|
|
1467
|
+
if (isJsxFile && lastTokenType === "other") {
|
|
1468
|
+
const end = tryScanJsxElement(i, 0);
|
|
1469
|
+
if (end !== null) {
|
|
1470
|
+
i = end;
|
|
1471
|
+
lastTokenType = "value";
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
lastTokenType = "other";
|
|
1476
|
+
i++;
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// possible regex literal
|
|
1481
|
+
if (c === "/") {
|
|
1482
|
+
const regexAllowed = lastTokenType === "other";
|
|
1483
|
+
if (regexAllowed) {
|
|
1484
|
+
// Regex literals never contain a raw newline — bounded to the
|
|
1485
|
+
// current line. If no closing `/` is found before the line ends,
|
|
1486
|
+
// this wasn't a regex after all; fall through and treat `/` as a
|
|
1487
|
+
// single punctuation character instead of failing the whole file.
|
|
1488
|
+
const lineEnd = (() => {
|
|
1489
|
+
const nl = content.indexOf("\n", i);
|
|
1490
|
+
return nl === -1 ? n : nl;
|
|
1491
|
+
})();
|
|
1492
|
+
let j = i + 1;
|
|
1493
|
+
let inClass = false;
|
|
1494
|
+
let found = -1;
|
|
1495
|
+
while (j < lineEnd) {
|
|
1496
|
+
const cj = content[j] as string;
|
|
1497
|
+
if (cj === "\\") {
|
|
1498
|
+
j += 2;
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
if (cj === "[") inClass = true;
|
|
1502
|
+
else if (cj === "]") inClass = false;
|
|
1503
|
+
else if (cj === "/" && !inClass) {
|
|
1504
|
+
found = j;
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
j++;
|
|
1508
|
+
}
|
|
1509
|
+
if (found !== -1) {
|
|
1510
|
+
let k = found + 1;
|
|
1511
|
+
while (k < n && /[a-z]/i.test(content[k] as string)) k++;
|
|
1512
|
+
i = k;
|
|
1513
|
+
lastTokenType = "value";
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
// not a regex — fall through
|
|
1517
|
+
}
|
|
1518
|
+
lastTokenType = "other";
|
|
1519
|
+
i++;
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// identifiers / keywords — only tracked to feed the regex heuristic
|
|
1524
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
1525
|
+
let j = i;
|
|
1526
|
+
while (j < n && /[A-Za-z0-9_$]/.test(content[j] as string)) j++;
|
|
1527
|
+
const word = content.slice(i, j);
|
|
1528
|
+
lastTokenType = REGEX_PRECEDING_KEYWORDS.has(word) ? "other" : "value";
|
|
1529
|
+
i = j;
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
if (/[0-9]/.test(c)) {
|
|
1533
|
+
let j = i;
|
|
1534
|
+
while (j < n && /[0-9a-fA-Fx.]/.test(content[j] as string)) j++;
|
|
1535
|
+
i = j;
|
|
1536
|
+
lastTokenType = "value";
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (c === ")" || c === "]") {
|
|
1541
|
+
lastTokenType = "value";
|
|
1542
|
+
i++;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
// `}` is ambiguous (block end vs. object-literal end); treat as
|
|
1546
|
+
// "other" (regex allowed after) — the more common case for a `}` that
|
|
1547
|
+
// precedes more code is a closed block, not an object literal used as
|
|
1548
|
+
// a value. Documented limitation, see this file's top doc comment.
|
|
1549
|
+
lastTokenType = "other";
|
|
1550
|
+
i++;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return { literals, jsxTexts, unchecked };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// ---------------------------------------------------------- classification
|
|
1557
|
+
|
|
1558
|
+
const ARIA_DATA_ATTR_RE = /^(aria|data)[-A-Z]/i;
|
|
1559
|
+
|
|
1560
|
+
const DENYLISTED_ATTRS = new Set(
|
|
1561
|
+
[
|
|
1562
|
+
"classname", "class", "id", "key", "htmlfor", "role", "type", "rel", "target", "method", "enctype",
|
|
1563
|
+
"autocomplete", "inputmode", "pattern", "name", "slot", "variant", "size", "as", "href", "src", "xmlns",
|
|
1564
|
+
"viewbox", "fill", "stroke", "d", "strokewidth", "strokelinecap", "strokelinejoin", "testid", "datatestid",
|
|
1565
|
+
].map((s) => s.replace(/-/g, "")),
|
|
1566
|
+
);
|
|
1567
|
+
|
|
1568
|
+
const CLASSNAME_BUILDER_CALLS = new Set(["cx", "clsx", "classnames"]);
|
|
1569
|
+
const DIAGNOSTIC_CALLS = new Set(["log", "warn", "error", "debug", "info", "assert", "invariant", "Error"]);
|
|
1570
|
+
|
|
1571
|
+
/** Converts `ariaLabel` -> `arialabel`, `aria-label` -> `arialabel`, for a single case/hyphen-insensitive comparison. */
|
|
1572
|
+
function normalizeIdentifier(name: string): string {
|
|
1573
|
+
return name
|
|
1574
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
1575
|
+
.toLowerCase()
|
|
1576
|
+
.replace(/-/g, "");
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Finds the identifier immediately before the nearest unmatched `(` enclosing `pos`, bounded to avoid an unbounded backward scan on a huge file. */
|
|
1580
|
+
function enclosingCallName(content: string, pos: number): string | undefined {
|
|
1581
|
+
let depth = 0;
|
|
1582
|
+
const floor = Math.max(0, pos - 2000);
|
|
1583
|
+
for (let j = pos - 1; j >= floor; j--) {
|
|
1584
|
+
const ch = content[j] as string;
|
|
1585
|
+
if (ch === ")") {
|
|
1586
|
+
depth++;
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
if (ch === "(") {
|
|
1590
|
+
if (depth === 0) {
|
|
1591
|
+
let k = j - 1;
|
|
1592
|
+
while (k >= 0 && /\s/.test(content[k] as string)) k--;
|
|
1593
|
+
const end = k + 1;
|
|
1594
|
+
while (k >= 0 && /[A-Za-z0-9_$.]/.test(content[k] as string)) k--;
|
|
1595
|
+
const name = content.slice(k + 1, end);
|
|
1596
|
+
const parts = name.split(".");
|
|
1597
|
+
return parts[parts.length - 1] || undefined;
|
|
1598
|
+
}
|
|
1599
|
+
depth--;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return undefined;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/** The identifier/attribute name immediately before `pos` in a `name=` or `name: ` position, or `undefined` if none is found within a short lookback. */
|
|
1607
|
+
function precedingAssignmentName(content: string, pos: number): string | undefined {
|
|
1608
|
+
const from = Math.max(0, pos - 80);
|
|
1609
|
+
const before = content.slice(from, pos);
|
|
1610
|
+
const m = /([A-Za-z_$][A-Za-z0-9_$-]*)\s*(?::|=)\s*\{?\s*$/.exec(before);
|
|
1611
|
+
return m?.[1];
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Whether `pos` (a literal's start index) sits inside a `type X = ...;` or
|
|
1616
|
+
* `interface X ... { ... }` declaration — a rough, line-bounded scan, not
|
|
1617
|
+
* a real parser; see this file's top doc comment. An `interface`'s HERITAGE
|
|
1618
|
+
* clause (`interface Foo extends Omit<Bar, "className" | "style"> { ... }`)
|
|
1619
|
+
* counts as type context too, not just the body between `{`/`}` — a real,
|
|
1620
|
+
* common pattern in this repository's own `packages/ui` (every block/atom
|
|
1621
|
+
* that extends `Omit<SomeAriaProps, "className" | "children" | ...>`), and
|
|
1622
|
+
* missing it was a real bug this scanner's own calibration run against
|
|
1623
|
+
* `Pagination.tsx`/`Table.tsx` caught: `"onChange"`, `"className"`,
|
|
1624
|
+
* `"style"`, and `"children"` were all initially misreported as copy
|
|
1625
|
+
* candidates because they sit BEFORE the interface's opening `{`.
|
|
1626
|
+
*/
|
|
1627
|
+
function isInTypeContext(content: string, pos: number): boolean {
|
|
1628
|
+
// interface: find the nearest preceding `interface` keyword whose `{`
|
|
1629
|
+
// has not yet been closed by `pos` — the type-context region runs from
|
|
1630
|
+
// the keyword itself (covering the heritage clause) through that `}`.
|
|
1631
|
+
const before = content.slice(0, pos);
|
|
1632
|
+
let idx = before.lastIndexOf("interface ");
|
|
1633
|
+
while (idx !== -1) {
|
|
1634
|
+
const braceOpen = content.indexOf("{", idx);
|
|
1635
|
+
if (braceOpen !== -1) {
|
|
1636
|
+
if (pos >= idx && pos < braceOpen) return true; // heritage/generic clause, before the body opens
|
|
1637
|
+
if (pos >= braceOpen) {
|
|
1638
|
+
let depth = 1;
|
|
1639
|
+
let k = braceOpen + 1;
|
|
1640
|
+
while (k < content.length && depth > 0) {
|
|
1641
|
+
if (content[k] === "{") depth++;
|
|
1642
|
+
else if (content[k] === "}") depth--;
|
|
1643
|
+
k++;
|
|
1644
|
+
}
|
|
1645
|
+
if (pos < k) return true;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
idx = before.lastIndexOf("interface ", idx - 1);
|
|
1649
|
+
}
|
|
1650
|
+
// type alias: find the nearest preceding `type X =` whose terminating
|
|
1651
|
+
// top-level `;` has not yet been reached by `pos`.
|
|
1652
|
+
const typeRe = /\btype\s+[A-Za-z_$][A-Za-z0-9_$]*(<[^=]*>)?\s*=/g;
|
|
1653
|
+
let match: RegExpExecArray | null;
|
|
1654
|
+
let lastStart = -1;
|
|
1655
|
+
let lastEqEnd = -1;
|
|
1656
|
+
while ((match = typeRe.exec(before))) {
|
|
1657
|
+
lastStart = match.index;
|
|
1658
|
+
lastEqEnd = match.index + match[0].length;
|
|
1659
|
+
}
|
|
1660
|
+
if (lastStart !== -1 && lastEqEnd <= pos) {
|
|
1661
|
+
let depth = 0;
|
|
1662
|
+
let k = lastEqEnd;
|
|
1663
|
+
let terminator = -1;
|
|
1664
|
+
while (k < content.length) {
|
|
1665
|
+
const ck = content[k] as string;
|
|
1666
|
+
if (ck === "(" || ck === "[" || ck === "{" || ck === "<") depth++;
|
|
1667
|
+
else if (ck === ")" || ck === "]" || ck === "}" || ck === ">") depth--;
|
|
1668
|
+
else if (ck === ";" && depth <= 0) {
|
|
1669
|
+
terminator = k;
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
k++;
|
|
1673
|
+
}
|
|
1674
|
+
const end = terminator === -1 ? lastEqEnd + 2000 : terminator;
|
|
1675
|
+
if (pos >= lastStart && pos < end) return true;
|
|
1676
|
+
}
|
|
1677
|
+
return false;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function hasNoLetters(text: string): boolean {
|
|
1681
|
+
return !/\p{L}/u.test(text.trim());
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const ENUM_TOKEN_RE = /^[a-z][a-z0-9-]*$/;
|
|
1685
|
+
|
|
1686
|
+
function isEnumOrTokenShaped(text: string): boolean {
|
|
1687
|
+
return text.length > 0 && text.length <= 24 && !/\s/.test(text) && ENUM_TOKEN_RE.test(text);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ----------------------------------------------------------- JSX text nodes
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Collapses a raw JSX text-node run onto JSX's own whitespace rule: each
|
|
1694
|
+
* line is trimmed, blank lines are dropped, and the remaining lines are
|
|
1695
|
+
* joined with a single space. For a single-line run (the common case —
|
|
1696
|
+
* `<p>Hello world</p>`) this is exactly `.trim()`. A multi-line run
|
|
1697
|
+
* (`<p>\n Hello\n world\n</p>`, which prettier produces routinely for
|
|
1698
|
+
* wrapped text) collapses to `"Hello world"` rather than keeping the
|
|
1699
|
+
* source's own indentation/newlines as if they were part of the copy —
|
|
1700
|
+
* matching what a real JSX runtime actually renders (Babel's
|
|
1701
|
+
* `cleanJSXElementLiteralChild`), not a byte-for-byte slice of the
|
|
1702
|
+
* source. A simplified version of that algorithm, not a byte-for-byte
|
|
1703
|
+
* port: real JSX also treats a tab specially and preserves a required
|
|
1704
|
+
* single space between some line pairs slightly differently. Good enough
|
|
1705
|
+
* for what this scanner needs it for (a stable, readable candidate
|
|
1706
|
+
* string to match/report), not a JSX-whitespace reference implementation.
|
|
1707
|
+
*/
|
|
1708
|
+
function collapseJsxWhitespace(raw: string): string {
|
|
1709
|
+
const lines = raw.split(/\r\n|\r|\n/);
|
|
1710
|
+
if (lines.length === 1) return (lines[0] as string).trim();
|
|
1711
|
+
return lines
|
|
1712
|
+
.map((l) => l.trim())
|
|
1713
|
+
.filter((l) => l.length > 0)
|
|
1714
|
+
.join(" ");
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* The common named HTML/JSX entities real UI copy actually uses, plus
|
|
1719
|
+
* every numeric entity (`—`, `—`) — decoded before a JSX
|
|
1720
|
+
* text candidate's `raw`/`normalized` is built. See this file's top doc
|
|
1721
|
+
* comment, "JSX TEXT NODES", for why decoding (not `unchecked`) is the
|
|
1722
|
+
* right call here, and why a named entity outside this set is left
|
|
1723
|
+
* verbatim rather than guessed at.
|
|
1724
|
+
*
|
|
1725
|
+
* `nbsp` decodes to an ORDINARY space (U+0020), not the "real" non-
|
|
1726
|
+
* breaking space (U+00A0) — a deliberate departure from strict HTML
|
|
1727
|
+
* semantics, in favor of `checkCopyTraceability`'s matching working the
|
|
1728
|
+
* way a human registering copy would expect: a `CopyEntry.text` author
|
|
1729
|
+
* overwhelmingly writes a normal space, never literally types U+00A0,
|
|
1730
|
+
* and ` ` in JSX is almost always a rendering choice (keep two
|
|
1731
|
+
* words from wrapping), not a claim that the text itself differs. A
|
|
1732
|
+
* literal U+00A0 in the decoded candidate would silently make an
|
|
1733
|
+
* otherwise-correct registration fail to match on nothing but that one
|
|
1734
|
+
* invisible codepoint — exactly the kind of spurious finding this gate
|
|
1735
|
+
* exists to avoid, not manufacture.
|
|
1736
|
+
*/
|
|
1737
|
+
const NAMED_JSX_ENTITIES: Record<string, string> = {
|
|
1738
|
+
amp: "&",
|
|
1739
|
+
lt: "<",
|
|
1740
|
+
gt: ">",
|
|
1741
|
+
quot: '"',
|
|
1742
|
+
apos: "'",
|
|
1743
|
+
nbsp: " ", // an ORDINARY space (U+0020) -- spelled out via escape, not a literal space character, precisely so this can never again silently regress to an actual U+00A0 the way this line once did (caught by this file own test suite, not by inspection)
|
|
1744
|
+
mdash: "—",
|
|
1745
|
+
ndash: "–",
|
|
1746
|
+
hellip: "…",
|
|
1747
|
+
copy: "©",
|
|
1748
|
+
reg: "®",
|
|
1749
|
+
trade: "™",
|
|
1750
|
+
lsquo: "‘",
|
|
1751
|
+
rsquo: "’",
|
|
1752
|
+
ldquo: "“",
|
|
1753
|
+
rdquo: "”",
|
|
1754
|
+
};
|
|
1755
|
+
|
|
1756
|
+
const JSX_ENTITY_RE = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g;
|
|
1757
|
+
|
|
1758
|
+
function decodeJsxEntities(text: string): string {
|
|
1759
|
+
return text.replace(JSX_ENTITY_RE, (whole, body: string) => {
|
|
1760
|
+
if (body[0] === "#") {
|
|
1761
|
+
const isHex = body[1] === "x" || body[1] === "X";
|
|
1762
|
+
const code = parseInt(isHex ? body.slice(2) : body.slice(1), isHex ? 16 : 10);
|
|
1763
|
+
if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) {
|
|
1764
|
+
try {
|
|
1765
|
+
return String.fromCodePoint(code);
|
|
1766
|
+
} catch {
|
|
1767
|
+
return whole;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return whole;
|
|
1771
|
+
}
|
|
1772
|
+
return NAMED_JSX_ENTITIES[body] ?? whole; // unrecognized named entity — left verbatim, documented above
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
interface ExtractResult {
|
|
1777
|
+
candidates: CopyCandidate[];
|
|
1778
|
+
excluded: ExcludedLiteral[];
|
|
1779
|
+
citations: Citation[];
|
|
1780
|
+
/** See `ScanResult.unchecked` — this is the per-file slice `scanCopySourceTree` aggregates into it. */
|
|
1781
|
+
unchecked: UncheckedItem[];
|
|
1782
|
+
parseFailure?: string;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Every `copy:<id>` citation and every `copy-gate:ignore` marker in
|
|
1787
|
+
* `content`, keyed by 1-indexed line number. A plain raw-line regex scan —
|
|
1788
|
+
* deliberately NOT tokenizer-aware, mirroring
|
|
1789
|
+
* `@example/strategy`'s `facts-gate.ts`, which scans every line
|
|
1790
|
+
* for `fact:<key>`/`facts-gate:ignore` regardless of fence/comment state.
|
|
1791
|
+
* A citation only makes sense written inside a comment by construction
|
|
1792
|
+
* (the regex itself requires a comment-opening delimiter immediately
|
|
1793
|
+
* before it), so there is nothing a real tokenizer pass would add here
|
|
1794
|
+
* that a line scan doesn't already get right.
|
|
1795
|
+
*/
|
|
1796
|
+
function scanLineMarkers(content: string, filePath: string): { citations: Citation[]; ignoreLines: Set<number> } {
|
|
1797
|
+
const citations: Citation[] = [];
|
|
1798
|
+
const ignoreLines = new Set<number>();
|
|
1799
|
+
const lines = content.split("\n");
|
|
1800
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1801
|
+
const line = lines[i] as string;
|
|
1802
|
+
for (const m of line.matchAll(CITATION_RE)) {
|
|
1803
|
+
citations.push({ file: filePath, line: i + 1, id: m[1] as string });
|
|
1804
|
+
}
|
|
1805
|
+
if (IGNORE_MARKER_RE.test(line)) ignoreLines.add(i + 1);
|
|
1806
|
+
}
|
|
1807
|
+
return { citations, ignoreLines };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** `.tsx`/`.jsx`, case-insensitive — the only extensions `tokenize` is ever asked to run its JSX-aware pass against. See this file's top doc comment, "JSX TEXT NODES". */
|
|
1811
|
+
const JSX_FILE_RE = /\.(tsx|jsx)$/i;
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Pure — no I/O. Tokenizes `content` and classifies every string/template
|
|
1815
|
+
* literal AND (in a `.tsx`/`.jsx` file) every JSX text node found as
|
|
1816
|
+
* either a copy candidate or an excluded literal (with a stated reason),
|
|
1817
|
+
* per "THE BOUNDARY" in this file's top doc comment. Exported so a test
|
|
1818
|
+
* (or a future consumer wanting extraction without a real directory
|
|
1819
|
+
* walk) can exercise the classification logic directly against a
|
|
1820
|
+
* source-text fixture.
|
|
1821
|
+
*/
|
|
1822
|
+
export function extractCopyCandidates(content: string, filePath: string): ExtractResult {
|
|
1823
|
+
const isJsxFile = JSX_FILE_RE.test(filePath);
|
|
1824
|
+
const { literals, jsxTexts, unchecked: rawUnchecked, failure } = tokenize(content, isJsxFile);
|
|
1825
|
+
if (failure) return { candidates: [], excluded: [], citations: [], unchecked: [], parseFailure: failure };
|
|
1826
|
+
|
|
1827
|
+
const { citations, ignoreLines } = scanLineMarkers(content, filePath);
|
|
1828
|
+
const citationsByLine = new Map<number, string[]>();
|
|
1829
|
+
for (const c of citations) {
|
|
1830
|
+
const list = citationsByLine.get(c.line);
|
|
1831
|
+
if (list) list.push(c.id);
|
|
1832
|
+
else citationsByLine.set(c.line, [c.id]);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const candidates: CopyCandidate[] = [];
|
|
1836
|
+
const excluded: ExcludedLiteral[] = [];
|
|
1837
|
+
|
|
1838
|
+
for (const lit of literals) {
|
|
1839
|
+
const raw = lit.raw;
|
|
1840
|
+
const normalized = lit.staticParts.join(PLACEHOLDER_SENTINEL);
|
|
1841
|
+
const fullText = lit.staticParts.join(""); // for shape checks (letters/enum-shape) — placeholders don't count as "the text"
|
|
1842
|
+
|
|
1843
|
+
const before = content.slice(Math.max(0, lit.start - 80), lit.start);
|
|
1844
|
+
const afterStart = lit.end;
|
|
1845
|
+
const after = content.slice(afterStart, Math.min(content.length, afterStart + 20));
|
|
1846
|
+
|
|
1847
|
+
const exclude = (reason: ExclusionReason): void => {
|
|
1848
|
+
excluded.push({ file: filePath, line: lit.line, raw, reason });
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
// 1. import/export/require specifier
|
|
1852
|
+
if (/\bfrom\s*$/.test(before) || /\brequire\s*\(\s*$/.test(before) || /\bimport\s*\(\s*$/.test(before)) {
|
|
1853
|
+
exclude("import-or-require-specifier");
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// 2. object-literal / destructuring key. Requiring the PRECEDING
|
|
1858
|
+
// character to be `{`/`,` (an object-literal position) — rather than
|
|
1859
|
+
// just checking "is this literal followed by `:`" — is exactly what
|
|
1860
|
+
// keeps a ternary correctly classified as copy: in
|
|
1861
|
+
// `totalItems === 0 ? "No results" : ...` (a real line from
|
|
1862
|
+
// `Pagination.tsx`), "No results" IS followed by `:`, but its
|
|
1863
|
+
// preceding character is `?`, not `{`/`,`, so it never reaches this
|
|
1864
|
+
// branch. A ternary's FALSE branch (preceded by `:`, not `{`/`,`)
|
|
1865
|
+
// is excluded from this branch the same way.
|
|
1866
|
+
const precedingNonWs = [...before].reverse().find((ch) => !/\s/.test(ch));
|
|
1867
|
+
const followsWithColon = /^\s*:/.test(after);
|
|
1868
|
+
if (followsWithColon && (precedingNonWs === "{" || precedingNonWs === ",")) {
|
|
1869
|
+
exclude("object-or-destructuring-key");
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// 3. type/interface context
|
|
1874
|
+
if (isInTypeContext(content, lit.start)) {
|
|
1875
|
+
exclude("type-or-interface-context");
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// 4/5. attribute/prop name immediately before this literal
|
|
1880
|
+
const attrName = precedingAssignmentName(content, lit.start);
|
|
1881
|
+
if (attrName !== undefined) {
|
|
1882
|
+
const normalizedAttr = normalizeIdentifier(attrName);
|
|
1883
|
+
if (ARIA_DATA_ATTR_RE.test(attrName)) {
|
|
1884
|
+
exclude("aria-or-data-attribute-value");
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
if (DENYLISTED_ATTRS.has(normalizedAttr)) {
|
|
1888
|
+
exclude("denylisted-attribute-or-prop-value");
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// 6/7. enclosing call name — classname builder or developer diagnostic
|
|
1894
|
+
const callName = enclosingCallName(content, lit.start);
|
|
1895
|
+
if (callName !== undefined) {
|
|
1896
|
+
if (CLASSNAME_BUILDER_CALLS.has(callName.toLowerCase())) {
|
|
1897
|
+
exclude("classname-builder-argument");
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
if (DIAGNOSTIC_CALLS.has(callName)) {
|
|
1901
|
+
exclude("developer-diagnostic-argument");
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// 8. no letters at all — decorative/symbolic
|
|
1907
|
+
if (hasNoLetters(fullText)) {
|
|
1908
|
+
exclude("no-letters");
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// 9. bare lowercase single-token — likely a variant/size/state enum value
|
|
1913
|
+
if (isEnumOrTokenShaped(fullText)) {
|
|
1914
|
+
exclude("enum-or-token-shaped");
|
|
1915
|
+
continue;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
candidates.push({
|
|
1919
|
+
file: filePath,
|
|
1920
|
+
line: lit.line,
|
|
1921
|
+
kind: lit.kind,
|
|
1922
|
+
raw: raw.length > 200 ? `${raw.slice(0, 200)}…` : raw,
|
|
1923
|
+
normalized,
|
|
1924
|
+
placeholderCount: lit.placeholderCount,
|
|
1925
|
+
citedIds: citationsByLine.get(lit.line) ?? [],
|
|
1926
|
+
hasIgnoreMarker: ignoreLines.has(lit.line),
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// JSX text nodes — same citation/ignore-marker attribution (by line) as
|
|
1931
|
+
// every literal candidate above, via the SAME `citationsByLine`/
|
|
1932
|
+
// `ignoreLines` maps. Only `no-letters` applies here, not the
|
|
1933
|
+
// enum/token-shaped heuristic — see this file's top doc comment, "JSX
|
|
1934
|
+
// TEXT NODES", for why that heuristic does not transfer to a text-node
|
|
1935
|
+
// position.
|
|
1936
|
+
for (const run of jsxTexts) {
|
|
1937
|
+
const text = decodeJsxEntities(collapseJsxWhitespace(run.raw));
|
|
1938
|
+
|
|
1939
|
+
if (hasNoLetters(text)) {
|
|
1940
|
+
excluded.push({ file: filePath, line: run.line, raw: text, reason: "no-letters" });
|
|
1941
|
+
continue;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
candidates.push({
|
|
1945
|
+
file: filePath,
|
|
1946
|
+
line: run.line,
|
|
1947
|
+
kind: "jsx-text",
|
|
1948
|
+
raw: text,
|
|
1949
|
+
normalized: text,
|
|
1950
|
+
placeholderCount: 0,
|
|
1951
|
+
citedIds: citationsByLine.get(run.line) ?? [],
|
|
1952
|
+
hasIgnoreMarker: ignoreLines.has(run.line),
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const unchecked: UncheckedItem[] = rawUnchecked.map((u) => ({ file: filePath, line: u.line, kind: u.kind, detail: u.detail }));
|
|
1957
|
+
|
|
1958
|
+
return { candidates, excluded, citations, unchecked };
|
|
1959
|
+
}
|