@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/dist/scan.js ADDED
@@ -0,0 +1,1698 @@
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
+ import { readdirSync, readFileSync, statSync } from "node:fs";
288
+ import { extname, join, relative, sep } from "node:path";
289
+ import { matchesPathExclusion, validatePathExclusions, } from "./path-exclusions.js";
290
+ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
291
+ const DEFAULT_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
292
+ /** `.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. */
293
+ const SKIP_FILE_RE = /\.(test|spec|check)\.(ts|tsx|js|jsx)$|\.d\.ts$/;
294
+ // `copy:<id>` inside an HTML comment, a block comment, a JSX comment, or a
295
+ // line comment — deliberately loose about the closing delimiter (only the
296
+ // opener plus "copy:<id>" is required), mirroring
297
+ // `@example/strategy`'s `FACT_CITATION_RE` so a stray missing
298
+ // `-->` doesn't silently defeat the citation. `id` allows the dots
299
+ // `CopyEntryId`'s shape requires (see `schema.ts`'s `COPY_ENTRY_ID_RE`),
300
+ // unlike a bare fact key.
301
+ const CITATION_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*copy:([a-zA-Z0-9][a-zA-Z0-9.-]*)/g;
302
+ const IGNORE_MARKER_RE = /(?:<!--|\/\*|\{\/\*|\/\/)\s*copy-gate:ignore/i;
303
+ /**
304
+ * Walks `root` recursively, reads every matching file, and extracts copy
305
+ * candidates from each (see `extractCopyCandidates`). FAILS CLOSED on an
306
+ * unreadable directory or file — throws a plain `Error`, matching
307
+ * `@example/strategy`'s `scanStrategyDirectory` and this
308
+ * repository's own `scripts/check-contamination-classes.mjs` walker: a
309
+ * directory this function could not read might be hiding an unknown,
310
+ * unbounded amount of unregistered copy, and reporting "0 files scanned"
311
+ * for that case would read as a clean pass when nothing was actually
312
+ * verified. `cli.ts` is what turns this thrown error into exit code 2.
313
+ */
314
+ export function scanCopySourceTree(root, options = {}) {
315
+ const extensions = new Set(options.extensions ?? DEFAULT_EXTENSIONS);
316
+ const skipDirs = new Set(options.skipDirs ?? DEFAULT_SKIP_DIRS);
317
+ // Validated ONCE, up front — never re-validated per file. A malformed
318
+ // entry is reported and never applied (see path-exclusions.ts); a
319
+ // well-formed entry that matches zero files is reported AFTER the walk
320
+ // completes, once its real match count is known — see the bottom of this
321
+ // function.
322
+ const pathExclusionValidation = validatePathExclusions(options.pathExclusions);
323
+ const matchCounts = new Map();
324
+ for (const exclusion of pathExclusionValidation.valid)
325
+ matchCounts.set(exclusion, 0);
326
+ const result = {
327
+ filesScanned: 0,
328
+ candidates: [],
329
+ excluded: [],
330
+ citations: [],
331
+ skippedByDesign: [],
332
+ parseFailures: [],
333
+ unchecked: [],
334
+ excludedFiles: [],
335
+ pathExclusionFindings: [...pathExclusionValidation.findings],
336
+ };
337
+ function walk(dir) {
338
+ let entries;
339
+ try {
340
+ entries = readdirSync(dir);
341
+ }
342
+ catch (error) {
343
+ throw new Error(`scanCopySourceTree: cannot read directory "${dir}": ${error instanceof Error ? error.message : String(error)}`);
344
+ }
345
+ for (const entry of entries) {
346
+ if (skipDirs.has(entry))
347
+ continue;
348
+ const full = join(dir, entry);
349
+ let stat;
350
+ try {
351
+ stat = statSync(full);
352
+ }
353
+ catch {
354
+ continue; // broken symlink — nothing to read, not a directory-listing failure
355
+ }
356
+ if (stat.isDirectory()) {
357
+ walk(full);
358
+ continue;
359
+ }
360
+ if (!stat.isFile())
361
+ continue;
362
+ if (!extensions.has(extname(entry).toLowerCase()))
363
+ continue;
364
+ const relPath = relative(root, full).split(sep).join("/");
365
+ // Consumer-supplied path exclusions are checked BEFORE this package's
366
+ // own built-in test/check-file skip, and against EVERY valid
367
+ // exclusion (not just the first match) so every exclusion's own match
368
+ // count stays accurate for the unused-exclusion check below. A file
369
+ // this matches is never tokenized at all — no candidates, no excluded
370
+ // literals, no citations, no unchecked items, exactly like a file
371
+ // this package could not read.
372
+ let matchedExclusion;
373
+ for (const exclusion of pathExclusionValidation.valid) {
374
+ if (matchesPathExclusion(relPath, exclusion)) {
375
+ matchCounts.set(exclusion, (matchCounts.get(exclusion) ?? 0) + 1);
376
+ matchedExclusion ??= exclusion;
377
+ }
378
+ }
379
+ if (matchedExclusion) {
380
+ result.excludedFiles.push({ file: relPath, reason: matchedExclusion.reason, pattern: matchedExclusion.path });
381
+ continue;
382
+ }
383
+ if (SKIP_FILE_RE.test(entry)) {
384
+ result.skippedByDesign.push({ file: relPath, reason: "test-or-check-file" });
385
+ continue;
386
+ }
387
+ let content;
388
+ try {
389
+ content = readFileSync(full, "utf8");
390
+ }
391
+ catch (error) {
392
+ throw new Error(`scanCopySourceTree: cannot read file "${full}": ${error instanceof Error ? error.message : String(error)}`);
393
+ }
394
+ const extracted = extractCopyCandidates(content, relPath);
395
+ if (extracted.parseFailure) {
396
+ result.parseFailures.push({ file: relPath, detail: extracted.parseFailure });
397
+ continue; // no candidates trusted from a file we could not reliably tokenize
398
+ }
399
+ result.filesScanned++;
400
+ result.candidates.push(...extracted.candidates);
401
+ result.excluded.push(...extracted.excluded);
402
+ result.citations.push(...extracted.citations);
403
+ result.unchecked.push(...extracted.unchecked);
404
+ }
405
+ }
406
+ walk(root);
407
+ // A path exclusion that matched nothing this run is worth reporting too —
408
+ // a stale exclusion (the file it named was renamed/deleted) is otherwise
409
+ // indistinguishable from one still doing real work. See path-exclusions.ts's
410
+ // top doc comment.
411
+ for (const exclusion of pathExclusionValidation.valid) {
412
+ if ((matchCounts.get(exclusion) ?? 0) === 0) {
413
+ result.pathExclusionFindings.push({
414
+ rule: "path-exclusion-unused",
415
+ severity: "warning",
416
+ message: `pathExclusions entry "${exclusion.path}" (reason: "${exclusion.reason}") did not match any scanned file. Remove it, or check for a typo/renamed path.`,
417
+ path: exclusion.path,
418
+ });
419
+ }
420
+ }
421
+ return result;
422
+ }
423
+ // ------------------------------------------------------------- tokenizer
424
+ /**
425
+ * Collapses every interpolation in a template literal's static text to one
426
+ * comparable shape. `\uE000` is the first code point of the Unicode
427
+ * Private Use Area — reserved by the standard for exactly this kind of
428
+ * private, non-textual sentinel use, and something no legitimate JS/TS
429
+ * source file authoring real UI copy will ever contain, so it can be
430
+ * dropped in without risk of colliding with genuine copy text (unlike,
431
+ * say, a literal space or `{}`, which real copy could plausibly contain).
432
+ * Exported so `copy-gate.ts` normalizes `CopyEntry.text`'s `{name}`
433
+ * placeholders to this exact same sentinel before comparing shapes.
434
+ */
435
+ export const PLACEHOLDER_SENTINEL = "\uE000";
436
+ /** 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. */
437
+ const MAX_JSX_DEPTH = 200;
438
+ const UNESCAPE_MAP = {
439
+ "\\": "\\",
440
+ "'": "'",
441
+ '"': '"',
442
+ "`": "`",
443
+ n: "\n",
444
+ r: "\r",
445
+ t: "\t",
446
+ $: "$",
447
+ };
448
+ function unescape(raw) {
449
+ let out = "";
450
+ for (let i = 0; i < raw.length; i++) {
451
+ const c = raw[i];
452
+ if (c === "\\" && i + 1 < raw.length) {
453
+ const next = raw[i + 1];
454
+ out += next in UNESCAPE_MAP ? UNESCAPE_MAP[next] : next;
455
+ i++;
456
+ }
457
+ else {
458
+ out += c;
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+ /** Keywords after which `/` cannot be a division operator — an expression is expected next, so `/` starts a regex. */
464
+ const REGEX_PRECEDING_KEYWORDS = new Set([
465
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await",
466
+ ]);
467
+ /**
468
+ * Tokenizes `content` for string/template literals, comments, regex
469
+ * literals, and — when `isJsxFile` is true — JSX elements and their text
470
+ * nodes, tracking just enough state to know where each real boundary is.
471
+ * `isJsxFile` gates ALL JSX handling: a `.ts`/`.js` file (`isJsxFile ===
472
+ * false`) runs through exactly the same literal/comment/regex logic this
473
+ * function always had, completely unchanged, and can never produce a
474
+ * `"jsx-text"` candidate or a JSX `UncheckedItem` — see this file's top
475
+ * doc comment for the regex/division heuristic (reused, unmodified, to
476
+ * decide `<` too) and its documented limitation.
477
+ */
478
+ function tokenize(content, isJsxFile) {
479
+ const literals = [];
480
+ const jsxTexts = [];
481
+ const unchecked = [];
482
+ const n = content.length;
483
+ let i = 0;
484
+ let line = 1;
485
+ // 'value' means the last significant token could end an expression (so a
486
+ // following `/` divides it, and a following `<` is a comparison/generic,
487
+ // never JSX); 'other' means an expression is expected next (so a
488
+ // following `/` starts a regex, and a following `<` MAY start JSX).
489
+ let lastTokenType = "other";
490
+ function advanceLine(from, to) {
491
+ for (let k = from; k < to; k++)
492
+ if (content[k] === "\n")
493
+ line++;
494
+ }
495
+ function pushUnchecked(atLine, kind, detail) {
496
+ unchecked.push({ line: atLine, kind, detail });
497
+ }
498
+ /**
499
+ * Skips a `${...}` interpolation body, starting right after its opening
500
+ * `${` (i.e. `pos` is the first character of the expression, not the
501
+ * `$` or `{` themselves). Returns the index one past the matching `}`,
502
+ * or `-1` if it never closes. Brace-depth-aware, and recurses into
503
+ * `skipTemplateFrom` for a template literal nested inside the
504
+ * expression (`` `outer ${`inner`}` ``) and handles a plain `'...'`/
505
+ * `"..."` string and `//`/`/* *\/` comments inline — none of which can
506
+ * prematurely close the interpolation just because they happen to
507
+ * contain a `}` character. Mutually recursive with `skipTemplateFrom`
508
+ * so an arbitrarily deep nesting of templates-within-interpolations
509
+ * resolves correctly without ever re-tokenizing content this function
510
+ * has already accounted for (the earlier, buggier version of this
511
+ * function did that — see this file's history — by recursively calling
512
+ * the OUTER `tokenize` on the remaining file text, which re-scanned
513
+ * everything after the nested template as if it were a fresh file and
514
+ * could misinterpret the true template's own closing backtick as a new
515
+ * template's opening one).
516
+ */
517
+ function skipInterpolation(pos) {
518
+ let depth = 1;
519
+ let j = pos;
520
+ while (j < n && depth > 0) {
521
+ const ck = content[j];
522
+ if (ck === "\n") {
523
+ line++;
524
+ j++;
525
+ continue;
526
+ }
527
+ if (ck === "{") {
528
+ depth++;
529
+ j++;
530
+ continue;
531
+ }
532
+ if (ck === "}") {
533
+ depth--;
534
+ j++;
535
+ continue;
536
+ }
537
+ if (ck === '"' || ck === "'") {
538
+ const q = ck;
539
+ j++;
540
+ while (j < n && content[j] !== q) {
541
+ if (content[j] === "\\")
542
+ j++;
543
+ if (content[j] === "\n")
544
+ line++;
545
+ j++;
546
+ }
547
+ j++; // consume closing quote, or run past EOF — caught by the outer unterminated-template check
548
+ continue;
549
+ }
550
+ if (ck === "`") {
551
+ const next = skipTemplateFrom(j);
552
+ if (next === -1)
553
+ return -1;
554
+ j = next;
555
+ continue;
556
+ }
557
+ if (ck === "/" && content[j + 1] === "/") {
558
+ const nl = content.indexOf("\n", j);
559
+ j = nl === -1 ? n : nl;
560
+ continue;
561
+ }
562
+ if (ck === "/" && content[j + 1] === "*") {
563
+ const close = content.indexOf("*/", j + 2);
564
+ if (close === -1)
565
+ return -1;
566
+ advanceLine(j, close + 2);
567
+ j = close + 2;
568
+ continue;
569
+ }
570
+ j++;
571
+ }
572
+ return depth === 0 ? j : -1;
573
+ }
574
+ /** 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. */
575
+ function skipTemplateFrom(pos) {
576
+ let j = pos + 1;
577
+ while (j < n) {
578
+ const cj = content[j];
579
+ if (cj === "\\" && j + 1 < n) {
580
+ j += 2;
581
+ continue;
582
+ }
583
+ if (cj === "`")
584
+ return j + 1;
585
+ if (cj === "\n") {
586
+ line++;
587
+ j++;
588
+ continue;
589
+ }
590
+ if (cj === "$" && content[j + 1] === "{") {
591
+ const next = skipInterpolation(j + 2);
592
+ if (next === -1)
593
+ return -1;
594
+ j = next;
595
+ continue;
596
+ }
597
+ j++;
598
+ }
599
+ return -1;
600
+ }
601
+ /** 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. */
602
+ let literalFailure;
603
+ /**
604
+ * Scans a plain string literal starting at `pos`
605
+ * (`content[pos] === '"' | "'"`), pushes it to `literals`, and returns
606
+ * the index one past its closing quote — or `-1` (with `literalFailure`
607
+ * set) if unterminated. SHARED by the main loop and every JSX
608
+ * attribute-value / expression-container path below (`tryScanJsxElement`,
609
+ * `skipJsxExpressionContainer`) — this is deliberate, not incidental
610
+ * reuse: a string literal found ANYWHERE in a `.tsx` file, whether it
611
+ * is ordinary JS code or a JSX attribute value or an expression child,
612
+ * must produce the exact same `Literal` shape and flow through
613
+ * `extractCopyCandidates`'s existing classification exactly the same
614
+ * way. `precedingAssignmentName`/`enclosingCallName`/`isInTypeContext`
615
+ * all re-derive their answer from `content` and a literal's own
616
+ * `start` index, independent of which code path found it, so this is
617
+ * enough to keep e.g. `<Button aria-label="Previous page">`'s
618
+ * `"Previous page"` correctly excluded as an aria value — exactly as
619
+ * it was before this file understood JSX at all — rather than being
620
+ * silently swallowed as opaque tag-head text the moment JSX-awareness
621
+ * was added. (One accepted simplification: real JSX attribute strings
622
+ * have no backslash-escape mechanism at all, unlike a JS string; this
623
+ * function applies JS's escape rules everywhere, including inside a
624
+ * JSX attribute. Indistinguishable in practice — nobody writes a
625
+ * backslash immediately before the closing quote of a JSX attribute —
626
+ * so not worth a second, JSX-specific string scanner.)
627
+ */
628
+ function scanStringLiteral(pos) {
629
+ const quote = content[pos];
630
+ const startLine = line;
631
+ const start = pos;
632
+ let j = pos + 1;
633
+ let value = "";
634
+ let closed = false;
635
+ while (j < n) {
636
+ const cj = content[j];
637
+ if (cj === "\\" && j + 1 < n) {
638
+ value += cj + content[j + 1];
639
+ j += 2;
640
+ continue;
641
+ }
642
+ if (cj === quote) {
643
+ closed = true;
644
+ j++;
645
+ break;
646
+ }
647
+ if (cj === "\n")
648
+ break; // unterminated on this line — a real JS/JSX string never spans a raw newline
649
+ value += cj;
650
+ j++;
651
+ }
652
+ if (!closed) {
653
+ literalFailure = `unterminated string starting at line ${startLine}`;
654
+ return -1;
655
+ }
656
+ literals.push({
657
+ kind: "string",
658
+ line: startLine,
659
+ raw: content.slice(start, j),
660
+ staticParts: [unescape(value)],
661
+ placeholderCount: 0,
662
+ start,
663
+ end: j,
664
+ });
665
+ return j;
666
+ }
667
+ /**
668
+ * Scans a template literal starting at `pos` (`content[pos] === "\`"`),
669
+ * collecting its static text segments and delegating every
670
+ * `${...}` interpolation to `skipInterpolation` — exactly like the
671
+ * main loop's own former inline template handling, now shared with
672
+ * `tryScanJsxElement`/`skipJsxExpressionContainer` for the identical
673
+ * reason `scanStringLiteral` is shared (see its own doc comment).
674
+ * Pushes a `Literal` and returns the index one past the closing
675
+ * backtick, or `-1` (with `literalFailure` set) if unterminated. NOT
676
+ * used for a template nested inside another template's own
677
+ * interpolation — that case still goes through the non-collecting
678
+ * `skipTemplateFrom` above, unchanged pre-existing behavior this
679
+ * change does not touch (see that function's own doc comment).
680
+ */
681
+ function scanTemplateLiteral(pos) {
682
+ const startLine = line;
683
+ const start = pos;
684
+ const staticParts = [];
685
+ let placeholderCount = 0;
686
+ let j = pos + 1;
687
+ let current = "";
688
+ let closed = false;
689
+ while (j < n) {
690
+ const cj = content[j];
691
+ if (cj === "\\" && j + 1 < n) {
692
+ current += cj + content[j + 1];
693
+ j += 2;
694
+ continue;
695
+ }
696
+ if (cj === "`") {
697
+ closed = true;
698
+ j++;
699
+ break;
700
+ }
701
+ if (cj === "$" && content[j + 1] === "{") {
702
+ staticParts.push(unescape(current));
703
+ current = "";
704
+ placeholderCount++;
705
+ // THIS interpolation's own `${`, not the enclosing template
706
+ // literal's `startLine` — a multi-line template can carry the
707
+ // `${` many lines past its own opening backtick, and a message
708
+ // claiming "starting at line X" must report where the thing it
709
+ // names actually starts.
710
+ const interpolationStartLine = line;
711
+ const next = skipInterpolation(j + 2);
712
+ if (next === -1) {
713
+ literalFailure = `unterminated \${...} interpolation starting at line ${interpolationStartLine}`;
714
+ return -1;
715
+ }
716
+ j = next;
717
+ continue;
718
+ }
719
+ if (cj === "\n")
720
+ line++;
721
+ current += cj;
722
+ j++;
723
+ }
724
+ if (!closed) {
725
+ literalFailure = `unterminated template literal starting at line ${startLine}`;
726
+ return -1;
727
+ }
728
+ staticParts.push(unescape(current));
729
+ literals.push({
730
+ kind: "template",
731
+ line: startLine,
732
+ raw: content.slice(start, j),
733
+ staticParts,
734
+ placeholderCount,
735
+ start,
736
+ end: j,
737
+ });
738
+ return j;
739
+ }
740
+ /**
741
+ * Skips a JSX `{...}` container — a spread attribute (`{...props}`), an
742
+ * attribute value (`name={expr}`), or a children expression
743
+ * (`{expression}`, including a JSX comment `{/* ... *\/}`, which is
744
+ * just a block comment that this function's own comment-skipping
745
+ * already consumes as opaque text — no special case needed). `pos` is
746
+ * the index right after the opening `{`. Brace-depth-aware and
747
+ * string/template/comment-safe, exactly like `skipInterpolation`
748
+ * above, with ONE addition: a `<` encountered here in expression-start
749
+ * position may open a NESTED JSX element (`{condition && <Foo>text
750
+ * </Foo>}` is an ordinary React pattern), resolved via
751
+ * `tryScanJsxElement` — which is what makes this function and
752
+ * `tryScanJsxElement`/`scanJsxChildren` mutually recursive, the same
753
+ * shape `skipInterpolation`/`skipTemplateFrom` already use one level
754
+ * up. Deliberately NOT a call to `skipInterpolation` itself: that
755
+ * function has no JSX awareness (by design — a `${...}` template
756
+ * interpolation containing JSX is exotic enough not to be worth
757
+ * teaching it), so this is its own, JSX-aware sibling rather than a
758
+ * shared implementation. Returns the index one past the matching `}`,
759
+ * or `-1` if it never closes.
760
+ */
761
+ function skipJsxExpressionContainer(pos, depth) {
762
+ let braceDepth = 1;
763
+ let j = pos;
764
+ let localLast = "other";
765
+ while (j < n && braceDepth > 0) {
766
+ const cj = content[j];
767
+ if (cj === "\n") {
768
+ line++;
769
+ j++;
770
+ continue;
771
+ }
772
+ if (cj === " " || cj === "\t" || cj === "\r") {
773
+ j++;
774
+ continue;
775
+ }
776
+ if (cj === "/" && content[j + 1] === "/") {
777
+ const nl = content.indexOf("\n", j);
778
+ j = nl === -1 ? n : nl;
779
+ continue;
780
+ }
781
+ if (cj === "/" && content[j + 1] === "*") {
782
+ const close = content.indexOf("*/", j + 2);
783
+ if (close === -1)
784
+ return -1;
785
+ advanceLine(j, close + 2);
786
+ j = close + 2;
787
+ continue;
788
+ }
789
+ if (cj === '"' || cj === "'") {
790
+ const end = scanStringLiteral(j);
791
+ if (end === -1)
792
+ return -1;
793
+ j = end;
794
+ localLast = "value";
795
+ continue;
796
+ }
797
+ if (cj === "`") {
798
+ const end = scanTemplateLiteral(j);
799
+ if (end === -1)
800
+ return -1;
801
+ j = end;
802
+ localLast = "value";
803
+ continue;
804
+ }
805
+ if (cj === "<" && isJsxFile && localLast === "other") {
806
+ const elEnd = tryScanJsxElement(j, depth + 1);
807
+ if (elEnd !== null) {
808
+ j = elEnd;
809
+ localLast = "value";
810
+ continue;
811
+ }
812
+ // Not JSX after all — fall through and treat `<` as an ordinary
813
+ // operator character (comparison/generic), same as the top-level
814
+ // loop does.
815
+ }
816
+ if (cj === "{") {
817
+ braceDepth++;
818
+ j++;
819
+ localLast = "other";
820
+ continue;
821
+ }
822
+ if (cj === "}") {
823
+ braceDepth--;
824
+ j++;
825
+ localLast = "value";
826
+ continue;
827
+ }
828
+ if (cj === "(" || cj === "[") {
829
+ j++;
830
+ localLast = "other";
831
+ continue;
832
+ }
833
+ if (cj === ")" || cj === "]") {
834
+ j++;
835
+ localLast = "value";
836
+ continue;
837
+ }
838
+ if (/[A-Za-z_$]/.test(cj)) {
839
+ let k = j;
840
+ while (k < n && /[A-Za-z0-9_$]/.test(content[k]))
841
+ k++;
842
+ const word = content.slice(j, k);
843
+ localLast = REGEX_PRECEDING_KEYWORDS.has(word) ? "other" : "value";
844
+ j = k;
845
+ continue;
846
+ }
847
+ if (/[0-9]/.test(cj)) {
848
+ let k = j;
849
+ while (k < n && /[0-9a-fA-Fx.]/.test(content[k]))
850
+ k++;
851
+ j = k;
852
+ localLast = "value";
853
+ continue;
854
+ }
855
+ if (cj === "/") {
856
+ if (localLast === "other") {
857
+ const lineEnd = (() => {
858
+ const nl = content.indexOf("\n", j);
859
+ return nl === -1 ? n : nl;
860
+ })();
861
+ let k = j + 1;
862
+ let inClass = false;
863
+ let found = -1;
864
+ while (k < lineEnd) {
865
+ const ck = content[k];
866
+ if (ck === "\\") {
867
+ k += 2;
868
+ continue;
869
+ }
870
+ if (ck === "[")
871
+ inClass = true;
872
+ else if (ck === "]")
873
+ inClass = false;
874
+ else if (ck === "/" && !inClass) {
875
+ found = k;
876
+ break;
877
+ }
878
+ k++;
879
+ }
880
+ if (found !== -1) {
881
+ let m = found + 1;
882
+ while (m < n && /[a-z]/i.test(content[m]))
883
+ m++;
884
+ j = m;
885
+ localLast = "value";
886
+ continue;
887
+ }
888
+ }
889
+ localLast = "other";
890
+ j++;
891
+ continue;
892
+ }
893
+ localLast = "other";
894
+ j++;
895
+ }
896
+ return braceDepth === 0 ? j : -1;
897
+ }
898
+ /**
899
+ * Walks the raw text between a JSX opening tag's `>` and its matching
900
+ * closing tag, starting at `pos` (the index right after that `>`).
901
+ * `tagName` is used only for `unchecked` messages (`""` for a
902
+ * fragment). Every non-whitespace-only run of plain text between
903
+ * element/expression boundaries becomes one `jsxTexts` entry (still
904
+ * raw — collapsing per JSX's whitespace rule and entity decoding
905
+ * happen once in `extractCopyCandidates`, alongside every other
906
+ * candidate's classification). A purely-whitespace run (ordinary
907
+ * indentation between sibling tags) is dropped here, silently, on
908
+ * purpose — see this file's top doc comment for why that is the one
909
+ * deliberate exception to "never silently drop". Returns the index one
910
+ * past the matching closing tag's `>`.
911
+ */
912
+ function scanJsxChildren(pos, tagName, depth, openLine) {
913
+ let j = pos;
914
+ let runStart = j;
915
+ let runStartLine = line;
916
+ const flush = () => {
917
+ if (j > runStart) {
918
+ const raw = content.slice(runStart, j);
919
+ if (raw.trim().length > 0)
920
+ jsxTexts.push({ line: runStartLine, raw });
921
+ }
922
+ };
923
+ while (j < n) {
924
+ const cj = content[j];
925
+ if (cj === "<") {
926
+ flush();
927
+ if (content[j + 1] === "/") {
928
+ let k = j + 2;
929
+ while (k < n && content[k] !== ">") {
930
+ if (content[k] === "\n")
931
+ line++;
932
+ k++;
933
+ }
934
+ if (k >= n) {
935
+ // `openLine` — the line of THIS element's own opening `<`,
936
+ // threaded in from `tryScanJsxElement` — not `runStartLine`
937
+ // (wherever the last child text run happened to start,
938
+ // which drifts arbitrarily far from the element's actual
939
+ // start the more children it has). A message claiming a
940
+ // position it did not compute is worse than no message.
941
+ pushUnchecked(openLine, "unclosed-jsx-element", `"<${tagName}>" opened at line ${openLine} has no matching closing tag before end of file`);
942
+ return n;
943
+ }
944
+ return k + 1;
945
+ }
946
+ const nestedEnd = tryScanJsxElement(j, depth + 1);
947
+ if (nestedEnd === null) {
948
+ // A `<` inside an already-committed JSX children region can
949
+ // only be a nested element or a closing tag by JSX's own
950
+ // grammar — unlike the top-level case, there is no legitimate
951
+ // "actually this was TypeScript, not JSX" explanation here, so
952
+ // this genuinely is worth surfacing. `line` here is accurate —
953
+ // `tryScanJsxElement` restores it to this exact position
954
+ // before returning null (see its own `backtrack`) — so this
955
+ // is the TRUE line of this `<`, not wherever its failed
956
+ // attempt gave up.
957
+ pushUnchecked(line, "unrecognized-jsx-child", `"<" at line ${line}, inside the children of "<${tagName}>", did not parse as a nested element or a closing tag`);
958
+ j++;
959
+ runStart = j;
960
+ runStartLine = line;
961
+ continue;
962
+ }
963
+ j = nestedEnd;
964
+ runStart = j;
965
+ runStartLine = line;
966
+ continue;
967
+ }
968
+ if (cj === "{") {
969
+ flush();
970
+ const exprStartLine = line; // THIS expression child's own `{`, not wherever the failed scan below gives up
971
+ const end = skipJsxExpressionContainer(j + 1, depth);
972
+ if (end === -1) {
973
+ pushUnchecked(exprStartLine, "malformed-jsx-expression", `"{...}" expression child starting at line ${exprStartLine}, inside "<${tagName}>", never closed`);
974
+ return n;
975
+ }
976
+ j = end;
977
+ runStart = j;
978
+ runStartLine = line;
979
+ continue;
980
+ }
981
+ if (cj === "\n") {
982
+ line++;
983
+ j++;
984
+ continue;
985
+ }
986
+ j++;
987
+ }
988
+ // Reached EOF still inside this element's children — flush whatever
989
+ // text was collected (this element WAS committed as real JSX, so that
990
+ // text is trustworthy) and report the missing closing tag, at ITS
991
+ // OWN opening line (`openLine`), not wherever the scan happened to
992
+ // reach EOF.
993
+ flush();
994
+ pushUnchecked(openLine, "unclosed-jsx-element", `"<${tagName}>" opened at line ${openLine} has no matching closing tag before end of file`);
995
+ return n;
996
+ }
997
+ /**
998
+ * Attempts to parse a JSX element (or fragment) starting at `pos`
999
+ * (`content[pos] === "<"`). Returns `null` if this position does not
1000
+ * look like a valid JSX opening tag AT ALL — the caller (the main loop,
1001
+ * or `skipJsxExpressionContainer` for a nested element) then treats `<`
1002
+ * as an ordinary character and continues normal JS scanning from
1003
+ * `pos + 1`. This is what keeps TSX's generic-arrow-function syntax
1004
+ * (`` <T,>(x: T) => x ``, which also starts as `< identifier` in
1005
+ * expression-start position) from misfiring: it fails immediately,
1006
+ * inside the attribute list, before a valid `>`/`/>` was ever found —
1007
+ * BEFORE this function commits to "this is real JSX" — so it returns
1008
+ * `null` with no `unchecked` note at all. Once a complete, well-formed
1009
+ * opening-tag HEAD is found (a valid `>` or `/>`), this function is
1010
+ * committed: any failure from that point on (an unterminated attribute
1011
+ * value, an unclosed element) is real, reportable ambiguity, recorded
1012
+ * via `pushUnchecked` — see `scanJsxChildren` and the `pushUnchecked`
1013
+ * call sites below.
1014
+ *
1015
+ * BACKTRACKING IS STATEFUL, so `backtrack()` restores every bit of
1016
+ * shared state a failed attempt may have mutated before returning
1017
+ * `null` — `line`, plus the shared `literals`/`jsxTexts`/`unchecked`
1018
+ * arrays, truncated back to their lengths at entry. This matters for
1019
+ * two independent reasons, both found in review (see this package's
1020
+ * PR history for the concrete repro):
1021
+ *
1022
+ * 1. LINE NUMBERS. Once this function has consumed any whitespace
1023
+ * containing a newline (or delegated to `skipJsxExpressionContainer`
1024
+ * for an attribute value, which can span many lines), the shared
1025
+ * `line` counter has advanced past this element's own start. A
1026
+ * caller that reads `line` AFTER such a failure — to build an
1027
+ * `unchecked` message, or because the caller (the main loop) is
1028
+ * about to reprocess `pos + 1` onward as ordinary JS — would
1029
+ * either report the WRONG line (the failure/give-up position, not
1030
+ * the construct's actual start — the bug this comment exists to
1031
+ * prevent from coming back) or DOUBLE-COUNT every newline this
1032
+ * attempt already saw once (the main loop's own `\n` handling
1033
+ * would increment `line` again for the exact same characters when
1034
+ * it re-walks them from `pos + 1`), corrupting every subsequent
1035
+ * line number for the REST OF THE FILE.
1036
+ * 2. DUPLICATE CANDIDATES. An attribute string/template value that
1037
+ * parses successfully (via `scanStringLiteral`/
1038
+ * `skipJsxExpressionContainer`) before a LATER attribute in the
1039
+ * same tag fails pushes a real entry onto `literals`/`jsxTexts`.
1040
+ * Backtracking without removing it, then letting the caller
1041
+ * reprocess the same source text as ordinary JS, would rediscover
1042
+ * that same literal a second time — a silent duplicate candidate.
1043
+ *
1044
+ * Restoring all four pieces of state makes "return null and let the
1045
+ * caller reprocess from `pos + 1`" fully safe regardless of how far
1046
+ * into a tag this function got before failing — the `unchecked` entry
1047
+ * for the failure itself is pushed AFTER `backtrack()` runs, so it
1048
+ * alone survives.
1049
+ */
1050
+ function tryScanJsxElement(pos, depth) {
1051
+ const entryLine = line;
1052
+ const entryLiteralsCount = literals.length;
1053
+ const entryJsxTextsCount = jsxTexts.length;
1054
+ const entryUncheckedCount = unchecked.length;
1055
+ function backtrack() {
1056
+ line = entryLine;
1057
+ literals.length = entryLiteralsCount;
1058
+ jsxTexts.length = entryJsxTextsCount;
1059
+ unchecked.length = entryUncheckedCount;
1060
+ return null;
1061
+ }
1062
+ if (depth > MAX_JSX_DEPTH) {
1063
+ // backtrack() FIRST (a no-op here — nothing has been consumed yet
1064
+ // at this depth-exceeded check, which runs before any parsing —
1065
+ // but ordering it this way, identically to every other call site
1066
+ // below, means this can never again accidentally truncate its OWN
1067
+ // pushUnchecked call the way an earlier version of this fix did:
1068
+ // backtrack() resets `unchecked.length` to its value at function
1069
+ // entry, so pushing BEFORE calling it would silently erase the
1070
+ // very entry just pushed).
1071
+ backtrack();
1072
+ pushUnchecked(entryLine, "jsx-depth-exceeded", `JSX nesting exceeded ${MAX_JSX_DEPTH} levels near line ${entryLine} — scanning of this subtree was abandoned`);
1073
+ return null;
1074
+ }
1075
+ let j = pos + 1;
1076
+ let isFragment = false;
1077
+ let tagName = "";
1078
+ if (content[j] === ">") {
1079
+ isFragment = true;
1080
+ j++;
1081
+ }
1082
+ else if (j < n && /[A-Za-z_$]/.test(content[j])) {
1083
+ const start = j;
1084
+ while (j < n && /[A-Za-z0-9_$.:-]/.test(content[j]))
1085
+ j++;
1086
+ tagName = content.slice(start, j);
1087
+ }
1088
+ else {
1089
+ return backtrack(); // `<=`, `< `, `<<`, a stray `<` — not JSX
1090
+ }
1091
+ let selfClosing = false;
1092
+ if (!isFragment) {
1093
+ for (;;) {
1094
+ while (j < n && /\s/.test(content[j])) {
1095
+ if (content[j] === "\n")
1096
+ line++;
1097
+ j++;
1098
+ }
1099
+ if (j >= n)
1100
+ return backtrack(); // ran off the end before the head ever closed — not committed, silently not-JSX
1101
+ const cj = content[j];
1102
+ if (cj === "/" && content[j + 1] === ">") {
1103
+ selfClosing = true;
1104
+ j += 2;
1105
+ break;
1106
+ }
1107
+ if (cj === ">") {
1108
+ j++;
1109
+ break;
1110
+ }
1111
+ if (cj === "{") {
1112
+ // spread attribute: {...expr}. `spreadStartLine` — THIS
1113
+ // attribute's own `{`, captured before the (possibly
1114
+ // multi-line) scan below — is what gets reported, never
1115
+ // wherever that scan gave up.
1116
+ const spreadStartLine = line;
1117
+ const end = skipJsxExpressionContainer(j + 1, depth);
1118
+ if (end === -1) {
1119
+ backtrack();
1120
+ pushUnchecked(spreadStartLine, "malformed-jsx-tag", `spread attribute on "<${tagName}>" starting at line ${spreadStartLine} never closed`);
1121
+ return null;
1122
+ }
1123
+ j = end;
1124
+ continue;
1125
+ }
1126
+ if (/[A-Za-z_$]/.test(cj)) {
1127
+ while (j < n && /[A-Za-z0-9_$:-]/.test(content[j]))
1128
+ j++;
1129
+ let k = j;
1130
+ while (k < n && /[ \t]/.test(content[k]))
1131
+ k++;
1132
+ if (content[k] === "=") {
1133
+ k++;
1134
+ while (k < n && /\s/.test(content[k])) {
1135
+ if (content[k] === "\n")
1136
+ line++;
1137
+ k++;
1138
+ }
1139
+ const vc = content[k];
1140
+ // `valueStartLine` — THIS attribute value's own opening
1141
+ // quote/`{`, not wherever a multi-line scan below gives up.
1142
+ const valueStartLine = line;
1143
+ if (vc === '"' || vc === "'") {
1144
+ // Scanned via `scanStringLiteral` (not skipped inline) so
1145
+ // this attribute value produces a real `Literal` — see
1146
+ // that function's doc comment for why: an attribute
1147
+ // string must flow through `extractCopyCandidates`'s
1148
+ // classification exactly like every other literal
1149
+ // (`aria-or-data-attribute-value`,
1150
+ // `denylisted-attribute-or-prop-value`, ...), never be
1151
+ // silently swallowed as opaque tag-head text.
1152
+ const end = scanStringLiteral(k);
1153
+ if (end === -1) {
1154
+ backtrack();
1155
+ pushUnchecked(valueStartLine, "malformed-jsx-tag", `an attribute value on "<${tagName}>" starting at line ${valueStartLine} was never terminated`);
1156
+ return null;
1157
+ }
1158
+ j = end;
1159
+ }
1160
+ else if (vc === "{") {
1161
+ const end = skipJsxExpressionContainer(k + 1, depth);
1162
+ if (end === -1) {
1163
+ backtrack();
1164
+ pushUnchecked(valueStartLine, "malformed-jsx-tag", `an attribute expression value on "<${tagName}>" starting at line ${valueStartLine} never closed`);
1165
+ return null;
1166
+ }
1167
+ j = end;
1168
+ }
1169
+ else {
1170
+ return backtrack(); // `name=` followed by neither a quote nor `{` — not valid JSX attribute syntax
1171
+ }
1172
+ }
1173
+ else {
1174
+ j = k; // boolean attribute, no value
1175
+ }
1176
+ continue;
1177
+ }
1178
+ return backtrack(); // e.g. the `,` in TSX's `<T,>(x: T) => x` — not committed, silently not-JSX
1179
+ }
1180
+ }
1181
+ if (selfClosing)
1182
+ return j;
1183
+ return scanJsxChildren(j, isFragment ? "" : tagName, depth, entryLine);
1184
+ }
1185
+ while (i < n) {
1186
+ const c = content[i];
1187
+ if (c === "\n") {
1188
+ line++;
1189
+ i++;
1190
+ continue;
1191
+ }
1192
+ if (c === " " || c === "\t" || c === "\r") {
1193
+ i++;
1194
+ continue;
1195
+ }
1196
+ // line comment
1197
+ if (c === "/" && content[i + 1] === "/") {
1198
+ const nl = content.indexOf("\n", i);
1199
+ i = nl === -1 ? n : nl;
1200
+ continue;
1201
+ }
1202
+ // block comment
1203
+ if (c === "/" && content[i + 1] === "*") {
1204
+ const close = content.indexOf("*/", i + 2);
1205
+ if (close === -1)
1206
+ return { literals, jsxTexts, unchecked, failure: `unterminated block comment starting at line ${line}` };
1207
+ advanceLine(i, close + 2);
1208
+ i = close + 2;
1209
+ continue;
1210
+ }
1211
+ // string literal
1212
+ if (c === '"' || c === "'") {
1213
+ const end = scanStringLiteral(i);
1214
+ if (end === -1)
1215
+ return { literals, jsxTexts, unchecked, failure: literalFailure };
1216
+ lastTokenType = "value";
1217
+ i = end;
1218
+ continue;
1219
+ }
1220
+ // template literal
1221
+ if (c === "`") {
1222
+ const end = scanTemplateLiteral(i);
1223
+ if (end === -1)
1224
+ return { literals, jsxTexts, unchecked, failure: literalFailure };
1225
+ lastTokenType = "value";
1226
+ i = end;
1227
+ continue;
1228
+ }
1229
+ // possible JSX element start — see this file's top doc comment, "JSX
1230
+ // TEXT NODES". Gated on `isJsxFile` (a .ts/.js file never reaches
1231
+ // `tryScanJsxElement` at all) and on `lastTokenType === "other"` —
1232
+ // the exact same "expression expected next" heuristic the regex
1233
+ // branch below uses for `/`, which is what keeps `Map<string,
1234
+ // string>` and `a < b` (both preceded by an identifier, a VALUE
1235
+ // token) from ever being tried as JSX.
1236
+ if (c === "<") {
1237
+ if (isJsxFile && lastTokenType === "other") {
1238
+ const end = tryScanJsxElement(i, 0);
1239
+ if (end !== null) {
1240
+ i = end;
1241
+ lastTokenType = "value";
1242
+ continue;
1243
+ }
1244
+ }
1245
+ lastTokenType = "other";
1246
+ i++;
1247
+ continue;
1248
+ }
1249
+ // possible regex literal
1250
+ if (c === "/") {
1251
+ const regexAllowed = lastTokenType === "other";
1252
+ if (regexAllowed) {
1253
+ // Regex literals never contain a raw newline — bounded to the
1254
+ // current line. If no closing `/` is found before the line ends,
1255
+ // this wasn't a regex after all; fall through and treat `/` as a
1256
+ // single punctuation character instead of failing the whole file.
1257
+ const lineEnd = (() => {
1258
+ const nl = content.indexOf("\n", i);
1259
+ return nl === -1 ? n : nl;
1260
+ })();
1261
+ let j = i + 1;
1262
+ let inClass = false;
1263
+ let found = -1;
1264
+ while (j < lineEnd) {
1265
+ const cj = content[j];
1266
+ if (cj === "\\") {
1267
+ j += 2;
1268
+ continue;
1269
+ }
1270
+ if (cj === "[")
1271
+ inClass = true;
1272
+ else if (cj === "]")
1273
+ inClass = false;
1274
+ else if (cj === "/" && !inClass) {
1275
+ found = j;
1276
+ break;
1277
+ }
1278
+ j++;
1279
+ }
1280
+ if (found !== -1) {
1281
+ let k = found + 1;
1282
+ while (k < n && /[a-z]/i.test(content[k]))
1283
+ k++;
1284
+ i = k;
1285
+ lastTokenType = "value";
1286
+ continue;
1287
+ }
1288
+ // not a regex — fall through
1289
+ }
1290
+ lastTokenType = "other";
1291
+ i++;
1292
+ continue;
1293
+ }
1294
+ // identifiers / keywords — only tracked to feed the regex heuristic
1295
+ if (/[A-Za-z_$]/.test(c)) {
1296
+ let j = i;
1297
+ while (j < n && /[A-Za-z0-9_$]/.test(content[j]))
1298
+ j++;
1299
+ const word = content.slice(i, j);
1300
+ lastTokenType = REGEX_PRECEDING_KEYWORDS.has(word) ? "other" : "value";
1301
+ i = j;
1302
+ continue;
1303
+ }
1304
+ if (/[0-9]/.test(c)) {
1305
+ let j = i;
1306
+ while (j < n && /[0-9a-fA-Fx.]/.test(content[j]))
1307
+ j++;
1308
+ i = j;
1309
+ lastTokenType = "value";
1310
+ continue;
1311
+ }
1312
+ if (c === ")" || c === "]") {
1313
+ lastTokenType = "value";
1314
+ i++;
1315
+ continue;
1316
+ }
1317
+ // `}` is ambiguous (block end vs. object-literal end); treat as
1318
+ // "other" (regex allowed after) — the more common case for a `}` that
1319
+ // precedes more code is a closed block, not an object literal used as
1320
+ // a value. Documented limitation, see this file's top doc comment.
1321
+ lastTokenType = "other";
1322
+ i++;
1323
+ }
1324
+ return { literals, jsxTexts, unchecked };
1325
+ }
1326
+ // ---------------------------------------------------------- classification
1327
+ const ARIA_DATA_ATTR_RE = /^(aria|data)[-A-Z]/i;
1328
+ const DENYLISTED_ATTRS = new Set([
1329
+ "classname", "class", "id", "key", "htmlfor", "role", "type", "rel", "target", "method", "enctype",
1330
+ "autocomplete", "inputmode", "pattern", "name", "slot", "variant", "size", "as", "href", "src", "xmlns",
1331
+ "viewbox", "fill", "stroke", "d", "strokewidth", "strokelinecap", "strokelinejoin", "testid", "datatestid",
1332
+ ].map((s) => s.replace(/-/g, "")));
1333
+ const CLASSNAME_BUILDER_CALLS = new Set(["cx", "clsx", "classnames"]);
1334
+ const DIAGNOSTIC_CALLS = new Set(["log", "warn", "error", "debug", "info", "assert", "invariant", "Error"]);
1335
+ /** Converts `ariaLabel` -> `arialabel`, `aria-label` -> `arialabel`, for a single case/hyphen-insensitive comparison. */
1336
+ function normalizeIdentifier(name) {
1337
+ return name
1338
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
1339
+ .toLowerCase()
1340
+ .replace(/-/g, "");
1341
+ }
1342
+ /** Finds the identifier immediately before the nearest unmatched `(` enclosing `pos`, bounded to avoid an unbounded backward scan on a huge file. */
1343
+ function enclosingCallName(content, pos) {
1344
+ let depth = 0;
1345
+ const floor = Math.max(0, pos - 2000);
1346
+ for (let j = pos - 1; j >= floor; j--) {
1347
+ const ch = content[j];
1348
+ if (ch === ")") {
1349
+ depth++;
1350
+ continue;
1351
+ }
1352
+ if (ch === "(") {
1353
+ if (depth === 0) {
1354
+ let k = j - 1;
1355
+ while (k >= 0 && /\s/.test(content[k]))
1356
+ k--;
1357
+ const end = k + 1;
1358
+ while (k >= 0 && /[A-Za-z0-9_$.]/.test(content[k]))
1359
+ k--;
1360
+ const name = content.slice(k + 1, end);
1361
+ const parts = name.split(".");
1362
+ return parts[parts.length - 1] || undefined;
1363
+ }
1364
+ depth--;
1365
+ continue;
1366
+ }
1367
+ }
1368
+ return undefined;
1369
+ }
1370
+ /** The identifier/attribute name immediately before `pos` in a `name=` or `name: ` position, or `undefined` if none is found within a short lookback. */
1371
+ function precedingAssignmentName(content, pos) {
1372
+ const from = Math.max(0, pos - 80);
1373
+ const before = content.slice(from, pos);
1374
+ const m = /([A-Za-z_$][A-Za-z0-9_$-]*)\s*(?::|=)\s*\{?\s*$/.exec(before);
1375
+ return m?.[1];
1376
+ }
1377
+ /**
1378
+ * Whether `pos` (a literal's start index) sits inside a `type X = ...;` or
1379
+ * `interface X ... { ... }` declaration — a rough, line-bounded scan, not
1380
+ * a real parser; see this file's top doc comment. An `interface`'s HERITAGE
1381
+ * clause (`interface Foo extends Omit<Bar, "className" | "style"> { ... }`)
1382
+ * counts as type context too, not just the body between `{`/`}` — a real,
1383
+ * common pattern in this repository's own `packages/ui` (every block/atom
1384
+ * that extends `Omit<SomeAriaProps, "className" | "children" | ...>`), and
1385
+ * missing it was a real bug this scanner's own calibration run against
1386
+ * `Pagination.tsx`/`Table.tsx` caught: `"onChange"`, `"className"`,
1387
+ * `"style"`, and `"children"` were all initially misreported as copy
1388
+ * candidates because they sit BEFORE the interface's opening `{`.
1389
+ */
1390
+ function isInTypeContext(content, pos) {
1391
+ // interface: find the nearest preceding `interface` keyword whose `{`
1392
+ // has not yet been closed by `pos` — the type-context region runs from
1393
+ // the keyword itself (covering the heritage clause) through that `}`.
1394
+ const before = content.slice(0, pos);
1395
+ let idx = before.lastIndexOf("interface ");
1396
+ while (idx !== -1) {
1397
+ const braceOpen = content.indexOf("{", idx);
1398
+ if (braceOpen !== -1) {
1399
+ if (pos >= idx && pos < braceOpen)
1400
+ return true; // heritage/generic clause, before the body opens
1401
+ if (pos >= braceOpen) {
1402
+ let depth = 1;
1403
+ let k = braceOpen + 1;
1404
+ while (k < content.length && depth > 0) {
1405
+ if (content[k] === "{")
1406
+ depth++;
1407
+ else if (content[k] === "}")
1408
+ depth--;
1409
+ k++;
1410
+ }
1411
+ if (pos < k)
1412
+ return true;
1413
+ }
1414
+ }
1415
+ idx = before.lastIndexOf("interface ", idx - 1);
1416
+ }
1417
+ // type alias: find the nearest preceding `type X =` whose terminating
1418
+ // top-level `;` has not yet been reached by `pos`.
1419
+ const typeRe = /\btype\s+[A-Za-z_$][A-Za-z0-9_$]*(<[^=]*>)?\s*=/g;
1420
+ let match;
1421
+ let lastStart = -1;
1422
+ let lastEqEnd = -1;
1423
+ while ((match = typeRe.exec(before))) {
1424
+ lastStart = match.index;
1425
+ lastEqEnd = match.index + match[0].length;
1426
+ }
1427
+ if (lastStart !== -1 && lastEqEnd <= pos) {
1428
+ let depth = 0;
1429
+ let k = lastEqEnd;
1430
+ let terminator = -1;
1431
+ while (k < content.length) {
1432
+ const ck = content[k];
1433
+ if (ck === "(" || ck === "[" || ck === "{" || ck === "<")
1434
+ depth++;
1435
+ else if (ck === ")" || ck === "]" || ck === "}" || ck === ">")
1436
+ depth--;
1437
+ else if (ck === ";" && depth <= 0) {
1438
+ terminator = k;
1439
+ break;
1440
+ }
1441
+ k++;
1442
+ }
1443
+ const end = terminator === -1 ? lastEqEnd + 2000 : terminator;
1444
+ if (pos >= lastStart && pos < end)
1445
+ return true;
1446
+ }
1447
+ return false;
1448
+ }
1449
+ function hasNoLetters(text) {
1450
+ return !/\p{L}/u.test(text.trim());
1451
+ }
1452
+ const ENUM_TOKEN_RE = /^[a-z][a-z0-9-]*$/;
1453
+ function isEnumOrTokenShaped(text) {
1454
+ return text.length > 0 && text.length <= 24 && !/\s/.test(text) && ENUM_TOKEN_RE.test(text);
1455
+ }
1456
+ // ----------------------------------------------------------- JSX text nodes
1457
+ /**
1458
+ * Collapses a raw JSX text-node run onto JSX's own whitespace rule: each
1459
+ * line is trimmed, blank lines are dropped, and the remaining lines are
1460
+ * joined with a single space. For a single-line run (the common case —
1461
+ * `<p>Hello world</p>`) this is exactly `.trim()`. A multi-line run
1462
+ * (`<p>\n Hello\n world\n</p>`, which prettier produces routinely for
1463
+ * wrapped text) collapses to `"Hello world"` rather than keeping the
1464
+ * source's own indentation/newlines as if they were part of the copy —
1465
+ * matching what a real JSX runtime actually renders (Babel's
1466
+ * `cleanJSXElementLiteralChild`), not a byte-for-byte slice of the
1467
+ * source. A simplified version of that algorithm, not a byte-for-byte
1468
+ * port: real JSX also treats a tab specially and preserves a required
1469
+ * single space between some line pairs slightly differently. Good enough
1470
+ * for what this scanner needs it for (a stable, readable candidate
1471
+ * string to match/report), not a JSX-whitespace reference implementation.
1472
+ */
1473
+ function collapseJsxWhitespace(raw) {
1474
+ const lines = raw.split(/\r\n|\r|\n/);
1475
+ if (lines.length === 1)
1476
+ return lines[0].trim();
1477
+ return lines
1478
+ .map((l) => l.trim())
1479
+ .filter((l) => l.length > 0)
1480
+ .join(" ");
1481
+ }
1482
+ /**
1483
+ * The common named HTML/JSX entities real UI copy actually uses, plus
1484
+ * every numeric entity (`&#8212;`, `&#x2014;`) — decoded before a JSX
1485
+ * text candidate's `raw`/`normalized` is built. See this file's top doc
1486
+ * comment, "JSX TEXT NODES", for why decoding (not `unchecked`) is the
1487
+ * right call here, and why a named entity outside this set is left
1488
+ * verbatim rather than guessed at.
1489
+ *
1490
+ * `nbsp` decodes to an ORDINARY space (U+0020), not the "real" non-
1491
+ * breaking space (U+00A0) — a deliberate departure from strict HTML
1492
+ * semantics, in favor of `checkCopyTraceability`'s matching working the
1493
+ * way a human registering copy would expect: a `CopyEntry.text` author
1494
+ * overwhelmingly writes a normal space, never literally types U+00A0,
1495
+ * and `&nbsp;` in JSX is almost always a rendering choice (keep two
1496
+ * words from wrapping), not a claim that the text itself differs. A
1497
+ * literal U+00A0 in the decoded candidate would silently make an
1498
+ * otherwise-correct registration fail to match on nothing but that one
1499
+ * invisible codepoint — exactly the kind of spurious finding this gate
1500
+ * exists to avoid, not manufacture.
1501
+ */
1502
+ const NAMED_JSX_ENTITIES = {
1503
+ amp: "&",
1504
+ lt: "<",
1505
+ gt: ">",
1506
+ quot: '"',
1507
+ apos: "'",
1508
+ 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)
1509
+ mdash: "—",
1510
+ ndash: "–",
1511
+ hellip: "…",
1512
+ copy: "©",
1513
+ reg: "®",
1514
+ trade: "™",
1515
+ lsquo: "‘",
1516
+ rsquo: "’",
1517
+ ldquo: "“",
1518
+ rdquo: "”",
1519
+ };
1520
+ const JSX_ENTITY_RE = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g;
1521
+ function decodeJsxEntities(text) {
1522
+ return text.replace(JSX_ENTITY_RE, (whole, body) => {
1523
+ if (body[0] === "#") {
1524
+ const isHex = body[1] === "x" || body[1] === "X";
1525
+ const code = parseInt(isHex ? body.slice(2) : body.slice(1), isHex ? 16 : 10);
1526
+ if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) {
1527
+ try {
1528
+ return String.fromCodePoint(code);
1529
+ }
1530
+ catch {
1531
+ return whole;
1532
+ }
1533
+ }
1534
+ return whole;
1535
+ }
1536
+ return NAMED_JSX_ENTITIES[body] ?? whole; // unrecognized named entity — left verbatim, documented above
1537
+ });
1538
+ }
1539
+ /**
1540
+ * Every `copy:<id>` citation and every `copy-gate:ignore` marker in
1541
+ * `content`, keyed by 1-indexed line number. A plain raw-line regex scan —
1542
+ * deliberately NOT tokenizer-aware, mirroring
1543
+ * `@example/strategy`'s `facts-gate.ts`, which scans every line
1544
+ * for `fact:<key>`/`facts-gate:ignore` regardless of fence/comment state.
1545
+ * A citation only makes sense written inside a comment by construction
1546
+ * (the regex itself requires a comment-opening delimiter immediately
1547
+ * before it), so there is nothing a real tokenizer pass would add here
1548
+ * that a line scan doesn't already get right.
1549
+ */
1550
+ function scanLineMarkers(content, filePath) {
1551
+ const citations = [];
1552
+ const ignoreLines = new Set();
1553
+ const lines = content.split("\n");
1554
+ for (let i = 0; i < lines.length; i++) {
1555
+ const line = lines[i];
1556
+ for (const m of line.matchAll(CITATION_RE)) {
1557
+ citations.push({ file: filePath, line: i + 1, id: m[1] });
1558
+ }
1559
+ if (IGNORE_MARKER_RE.test(line))
1560
+ ignoreLines.add(i + 1);
1561
+ }
1562
+ return { citations, ignoreLines };
1563
+ }
1564
+ /** `.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". */
1565
+ const JSX_FILE_RE = /\.(tsx|jsx)$/i;
1566
+ /**
1567
+ * Pure — no I/O. Tokenizes `content` and classifies every string/template
1568
+ * literal AND (in a `.tsx`/`.jsx` file) every JSX text node found as
1569
+ * either a copy candidate or an excluded literal (with a stated reason),
1570
+ * per "THE BOUNDARY" in this file's top doc comment. Exported so a test
1571
+ * (or a future consumer wanting extraction without a real directory
1572
+ * walk) can exercise the classification logic directly against a
1573
+ * source-text fixture.
1574
+ */
1575
+ export function extractCopyCandidates(content, filePath) {
1576
+ const isJsxFile = JSX_FILE_RE.test(filePath);
1577
+ const { literals, jsxTexts, unchecked: rawUnchecked, failure } = tokenize(content, isJsxFile);
1578
+ if (failure)
1579
+ return { candidates: [], excluded: [], citations: [], unchecked: [], parseFailure: failure };
1580
+ const { citations, ignoreLines } = scanLineMarkers(content, filePath);
1581
+ const citationsByLine = new Map();
1582
+ for (const c of citations) {
1583
+ const list = citationsByLine.get(c.line);
1584
+ if (list)
1585
+ list.push(c.id);
1586
+ else
1587
+ citationsByLine.set(c.line, [c.id]);
1588
+ }
1589
+ const candidates = [];
1590
+ const excluded = [];
1591
+ for (const lit of literals) {
1592
+ const raw = lit.raw;
1593
+ const normalized = lit.staticParts.join(PLACEHOLDER_SENTINEL);
1594
+ const fullText = lit.staticParts.join(""); // for shape checks (letters/enum-shape) — placeholders don't count as "the text"
1595
+ const before = content.slice(Math.max(0, lit.start - 80), lit.start);
1596
+ const afterStart = lit.end;
1597
+ const after = content.slice(afterStart, Math.min(content.length, afterStart + 20));
1598
+ const exclude = (reason) => {
1599
+ excluded.push({ file: filePath, line: lit.line, raw, reason });
1600
+ };
1601
+ // 1. import/export/require specifier
1602
+ if (/\bfrom\s*$/.test(before) || /\brequire\s*\(\s*$/.test(before) || /\bimport\s*\(\s*$/.test(before)) {
1603
+ exclude("import-or-require-specifier");
1604
+ continue;
1605
+ }
1606
+ // 2. object-literal / destructuring key. Requiring the PRECEDING
1607
+ // character to be `{`/`,` (an object-literal position) — rather than
1608
+ // just checking "is this literal followed by `:`" — is exactly what
1609
+ // keeps a ternary correctly classified as copy: in
1610
+ // `totalItems === 0 ? "No results" : ...` (a real line from
1611
+ // `Pagination.tsx`), "No results" IS followed by `:`, but its
1612
+ // preceding character is `?`, not `{`/`,`, so it never reaches this
1613
+ // branch. A ternary's FALSE branch (preceded by `:`, not `{`/`,`)
1614
+ // is excluded from this branch the same way.
1615
+ const precedingNonWs = [...before].reverse().find((ch) => !/\s/.test(ch));
1616
+ const followsWithColon = /^\s*:/.test(after);
1617
+ if (followsWithColon && (precedingNonWs === "{" || precedingNonWs === ",")) {
1618
+ exclude("object-or-destructuring-key");
1619
+ continue;
1620
+ }
1621
+ // 3. type/interface context
1622
+ if (isInTypeContext(content, lit.start)) {
1623
+ exclude("type-or-interface-context");
1624
+ continue;
1625
+ }
1626
+ // 4/5. attribute/prop name immediately before this literal
1627
+ const attrName = precedingAssignmentName(content, lit.start);
1628
+ if (attrName !== undefined) {
1629
+ const normalizedAttr = normalizeIdentifier(attrName);
1630
+ if (ARIA_DATA_ATTR_RE.test(attrName)) {
1631
+ exclude("aria-or-data-attribute-value");
1632
+ continue;
1633
+ }
1634
+ if (DENYLISTED_ATTRS.has(normalizedAttr)) {
1635
+ exclude("denylisted-attribute-or-prop-value");
1636
+ continue;
1637
+ }
1638
+ }
1639
+ // 6/7. enclosing call name — classname builder or developer diagnostic
1640
+ const callName = enclosingCallName(content, lit.start);
1641
+ if (callName !== undefined) {
1642
+ if (CLASSNAME_BUILDER_CALLS.has(callName.toLowerCase())) {
1643
+ exclude("classname-builder-argument");
1644
+ continue;
1645
+ }
1646
+ if (DIAGNOSTIC_CALLS.has(callName)) {
1647
+ exclude("developer-diagnostic-argument");
1648
+ continue;
1649
+ }
1650
+ }
1651
+ // 8. no letters at all — decorative/symbolic
1652
+ if (hasNoLetters(fullText)) {
1653
+ exclude("no-letters");
1654
+ continue;
1655
+ }
1656
+ // 9. bare lowercase single-token — likely a variant/size/state enum value
1657
+ if (isEnumOrTokenShaped(fullText)) {
1658
+ exclude("enum-or-token-shaped");
1659
+ continue;
1660
+ }
1661
+ candidates.push({
1662
+ file: filePath,
1663
+ line: lit.line,
1664
+ kind: lit.kind,
1665
+ raw: raw.length > 200 ? `${raw.slice(0, 200)}…` : raw,
1666
+ normalized,
1667
+ placeholderCount: lit.placeholderCount,
1668
+ citedIds: citationsByLine.get(lit.line) ?? [],
1669
+ hasIgnoreMarker: ignoreLines.has(lit.line),
1670
+ });
1671
+ }
1672
+ // JSX text nodes — same citation/ignore-marker attribution (by line) as
1673
+ // every literal candidate above, via the SAME `citationsByLine`/
1674
+ // `ignoreLines` maps. Only `no-letters` applies here, not the
1675
+ // enum/token-shaped heuristic — see this file's top doc comment, "JSX
1676
+ // TEXT NODES", for why that heuristic does not transfer to a text-node
1677
+ // position.
1678
+ for (const run of jsxTexts) {
1679
+ const text = decodeJsxEntities(collapseJsxWhitespace(run.raw));
1680
+ if (hasNoLetters(text)) {
1681
+ excluded.push({ file: filePath, line: run.line, raw: text, reason: "no-letters" });
1682
+ continue;
1683
+ }
1684
+ candidates.push({
1685
+ file: filePath,
1686
+ line: run.line,
1687
+ kind: "jsx-text",
1688
+ raw: text,
1689
+ normalized: text,
1690
+ placeholderCount: 0,
1691
+ citedIds: citationsByLine.get(run.line) ?? [],
1692
+ hasIgnoreMarker: ignoreLines.has(run.line),
1693
+ });
1694
+ }
1695
+ const unchecked = rawUnchecked.map((u) => ({ file: filePath, line: u.line, kind: u.kind, detail: u.detail }));
1696
+ return { candidates, excluded, citations, unchecked };
1697
+ }
1698
+ //# sourceMappingURL=scan.js.map