@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE +21 -0
  3. package/README.md +593 -0
  4. package/dist/addressability.d.ts +245 -0
  5. package/dist/addressability.d.ts.map +1 -0
  6. package/dist/addressability.js +567 -0
  7. package/dist/addressability.js.map +1 -0
  8. package/dist/checker.d.ts +159 -0
  9. package/dist/checker.d.ts.map +1 -0
  10. package/dist/checker.js +177 -0
  11. package/dist/checker.js.map +1 -0
  12. package/dist/cli.d.ts +149 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +1049 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/copy-gate.d.ts +107 -0
  17. package/dist/copy-gate.d.ts.map +1 -0
  18. package/dist/copy-gate.js +132 -0
  19. package/dist/copy-gate.js.map +1 -0
  20. package/dist/fingerprint.d.ts +61 -0
  21. package/dist/fingerprint.d.ts.map +1 -0
  22. package/dist/fingerprint.js +64 -0
  23. package/dist/fingerprint.js.map +1 -0
  24. package/dist/index.d.ts +47 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +63 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/locale-coverage.d.ts +227 -0
  29. package/dist/locale-coverage.d.ts.map +1 -0
  30. package/dist/locale-coverage.js +414 -0
  31. package/dist/locale-coverage.js.map +1 -0
  32. package/dist/passage.d.ts +294 -0
  33. package/dist/passage.d.ts.map +1 -0
  34. package/dist/passage.js +463 -0
  35. package/dist/passage.js.map +1 -0
  36. package/dist/path-exclusions.d.ts +114 -0
  37. package/dist/path-exclusions.d.ts.map +1 -0
  38. package/dist/path-exclusions.js +213 -0
  39. package/dist/path-exclusions.js.map +1 -0
  40. package/dist/registry.d.ts +61 -0
  41. package/dist/registry.d.ts.map +1 -0
  42. package/dist/registry.js +64 -0
  43. package/dist/registry.js.map +1 -0
  44. package/dist/resolve.d.ts +33 -0
  45. package/dist/resolve.d.ts.map +1 -0
  46. package/dist/resolve.js +133 -0
  47. package/dist/resolve.js.map +1 -0
  48. package/dist/scan.d.ts +457 -0
  49. package/dist/scan.d.ts.map +1 -0
  50. package/dist/scan.js +1698 -0
  51. package/dist/scan.js.map +1 -0
  52. package/dist/schema.d.ts +81 -0
  53. package/dist/schema.d.ts.map +1 -0
  54. package/dist/schema.js +482 -0
  55. package/dist/schema.js.map +1 -0
  56. package/dist/types.d.ts +268 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +40 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/voice/checker.d.ts +186 -0
  61. package/dist/voice/checker.d.ts.map +1 -0
  62. package/dist/voice/checker.js +483 -0
  63. package/dist/voice/checker.js.map +1 -0
  64. package/dist/voice/derivation-coverage.d.ts +168 -0
  65. package/dist/voice/derivation-coverage.d.ts.map +1 -0
  66. package/dist/voice/derivation-coverage.js +181 -0
  67. package/dist/voice/derivation-coverage.js.map +1 -0
  68. package/dist/voice/fields.d.ts +107 -0
  69. package/dist/voice/fields.d.ts.map +1 -0
  70. package/dist/voice/fields.js +144 -0
  71. package/dist/voice/fields.js.map +1 -0
  72. package/dist/voice/index.d.ts +19 -0
  73. package/dist/voice/index.d.ts.map +1 -0
  74. package/dist/voice/index.js +24 -0
  75. package/dist/voice/index.js.map +1 -0
  76. package/dist/voice/internal/parse-template.d.ts +27 -0
  77. package/dist/voice/internal/parse-template.d.ts.map +1 -0
  78. package/dist/voice/internal/parse-template.js +111 -0
  79. package/dist/voice/internal/parse-template.js.map +1 -0
  80. package/dist/voice/internal/pattern-safety.d.ts +111 -0
  81. package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
  82. package/dist/voice/internal/pattern-safety.js +278 -0
  83. package/dist/voice/internal/pattern-safety.js.map +1 -0
  84. package/dist/voice/schema.d.ts +65 -0
  85. package/dist/voice/schema.d.ts.map +1 -0
  86. package/dist/voice/schema.js +535 -0
  87. package/dist/voice/schema.js.map +1 -0
  88. package/dist/voice/types.d.ts +282 -0
  89. package/dist/voice/types.d.ts.map +1 -0
  90. package/dist/voice/types.js +37 -0
  91. package/dist/voice/types.js.map +1 -0
  92. package/package.json +72 -0
  93. package/src/addressability.ts +664 -0
  94. package/src/checker.ts +293 -0
  95. package/src/cli.ts +1166 -0
  96. package/src/copy-gate.ts +197 -0
  97. package/src/fingerprint.ts +66 -0
  98. package/src/index.ts +163 -0
  99. package/src/locale-coverage.ts +512 -0
  100. package/src/passage.ts +644 -0
  101. package/src/path-exclusions.ts +265 -0
  102. package/src/registry.ts +103 -0
  103. package/src/resolve.ts +172 -0
  104. package/src/scan.ts +1959 -0
  105. package/src/schema.ts +528 -0
  106. package/src/types.ts +301 -0
  107. package/src/voice/checker.ts +590 -0
  108. package/src/voice/derivation-coverage.ts +217 -0
  109. package/src/voice/fields.ts +160 -0
  110. package/src/voice/index.ts +55 -0
  111. package/src/voice/internal/parse-template.ts +114 -0
  112. package/src/voice/internal/pattern-safety.ts +336 -0
  113. package/src/voice/schema.ts +585 -0
  114. package/src/voice/types.ts +329 -0
  115. 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
+ * (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, `&nbsp;`, plus a
236
+ * small set of typographic entities real copy actually uses —
237
+ * `&mdash;`, `&ndash;`, `&hellip;`, `&copy;`, `&reg;`, `&trade;`,
238
+ * curly quotes) and every numeric entity (`&#8212;`, `&#x2014;`) 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 (`&#8212;`, `&#x2014;`) — 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 `&nbsp;` 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
+ }