@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.d.ts ADDED
@@ -0,0 +1,457 @@
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 { type ExcludedPath, type PathExclusion, type PathExclusionFinding } from "./path-exclusions.js";
288
+ export type { ExcludedPath, PathExclusion, PathExclusionFinding, PathExclusionFindingRule } from "./path-exclusions.js";
289
+ export interface ScanOptions {
290
+ /** File extensions to read, each including the leading dot. Default: `.ts`, `.tsx`, `.js`, `.jsx`. */
291
+ extensions?: string[];
292
+ /** Directory names never descended into. Default: node_modules, .git, dist, build, coverage. */
293
+ skipDirs?: string[];
294
+ /**
295
+ * Files to skip entirely — never tokenized, never contribute a
296
+ * `CopyCandidate`/`ExcludedLiteral`/`Citation`/`UncheckedItem`. A DIFFERENT
297
+ * feature from `ExclusionReason` — see `path-exclusions.ts`'s top doc
298
+ * comment for the full "mention vs. use" case this exists for and exactly
299
+ * why it is not a tenth `ExclusionReason`. Defaults to `[]` (no
300
+ * exclusions — identical to every prior release of this package).
301
+ */
302
+ pathExclusions?: PathExclusion[];
303
+ }
304
+ export interface CopyCandidate {
305
+ /** Repo-relative, `/`-joined path this candidate was found in. */
306
+ file: string;
307
+ /** 1-indexed line the literal/text node starts on. */
308
+ line: number;
309
+ /** `"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. */
310
+ kind: "string" | "template" | "jsx-text";
311
+ /**
312
+ * Original source text, trimmed for display. For `"string"`/`"template"`,
313
+ * includes the literal's own quotes/backticks. For `"jsx-text"`, there
314
+ * are no delimiters to include — this is the JSX-whitespace-collapsed,
315
+ * entity-decoded text itself (see `collapseJsxWhitespace`/
316
+ * `decodeJsxEntities`).
317
+ */
318
+ raw: string;
319
+ /**
320
+ * The literal's STATIC text with every interpolation (`${...}`)
321
+ * collapsed to `PLACEHOLDER_SENTINEL` — identical to `raw`'s unescaped
322
+ * content for a plain string (zero interpolations). This is what
323
+ * `copy-gate.ts` compares against a `CopyEntry.text` normalized the same
324
+ * way (its own `{name}` placeholders collapsed to the same sentinel) —
325
+ * comparing SHAPE, never a specific placeholder name, since this
326
+ * scanner has no way to know what name a `CopyEntry` author chose for
327
+ * an interpolation this source expression computes some other way.
328
+ */
329
+ normalized: string;
330
+ /** Number of `${...}` interpolations found (0 for a plain string). */
331
+ placeholderCount: number;
332
+ /**
333
+ * Every `copy:<id>` citation found on this candidate's own source line
334
+ * (see `CITATION_RE`) — `copy-gate.ts`'s escape hatch, mirroring
335
+ * `@example/strategy`'s `fact:<key>`. Whether any of these ids
336
+ * actually exist in a real `CopyRecord` is `copy-gate.ts`'s question to
337
+ * ask, not this scanner's — this scanner only reports what text is
338
+ * literally present on the line.
339
+ */
340
+ citedIds: string[];
341
+ /** Whether a `copy-gate:ignore` marker (see `IGNORE_MARKER_RE`) is present on this candidate's own source line. */
342
+ hasIgnoreMarker: boolean;
343
+ }
344
+ /**
345
+ * One `copy:<id>` citation found anywhere in a file, independent of
346
+ * whether any candidate happens to share its line — mirrors
347
+ * `@example/strategy`'s `facts-gate.ts` Pass 1, which checks every
348
+ * line for a `fact:<key>` citation regardless of whether that line also
349
+ * carries a claim, so a citation to a rotted/misspelled id is caught even
350
+ * when it sits on a line with no traceable literal at all (e.g. a stray
351
+ * citation left behind after the copy it once annotated was deleted).
352
+ */
353
+ export interface Citation {
354
+ file: string;
355
+ line: number;
356
+ id: string;
357
+ }
358
+ export type ExclusionReason = "import-or-require-specifier" | "object-or-destructuring-key" | "type-or-interface-context" | "aria-or-data-attribute-value" | "denylisted-attribute-or-prop-value" | "classname-builder-argument" | "developer-diagnostic-argument" | "no-letters" | "enum-or-token-shaped";
359
+ export interface ExcludedLiteral {
360
+ file: string;
361
+ line: number;
362
+ /** The literal's raw source text (quotes included), for a human auditing the exclusion list. */
363
+ raw: string;
364
+ reason: ExclusionReason;
365
+ }
366
+ export interface ParseFailure {
367
+ file: string;
368
+ /** Human-readable detail — an unterminated string/template/comment/regex, or the scanner ran off the end of the file in an inconsistent state. */
369
+ detail: string;
370
+ }
371
+ export interface SkippedFile {
372
+ file: string;
373
+ reason: "test-or-check-file";
374
+ }
375
+ /**
376
+ * One construct this scanner recognized as JSX-shaped but could not
377
+ * reliably classify — an unclosed element, an unterminated attribute
378
+ * value or expression, a nested expression child whose braces never
379
+ * balanced, or JSX nesting deep enough to trip the depth safety bound.
380
+ * See this file's top doc comment, "WHAT THIS STILL DELIBERATELY DOES
381
+ * NOT CATCH". NEVER how a genuine blind spot gets to stay invisible: a
382
+ * consumer that only checks `candidates`/`excluded` and ignores this
383
+ * field would be exactly the failure mode issue #37 was about — a scan
384
+ * that narrowed its own coverage and reported the narrowed result as a
385
+ * pass. `checkCopyTraceability` (`copy-gate.ts`) and `writer-check`
386
+ * (`cli.ts`) both refuse to let a non-empty list here go unreported.
387
+ */
388
+ export interface UncheckedItem {
389
+ file: string;
390
+ line: number;
391
+ /** 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"`. */
392
+ kind: string;
393
+ /** Human-readable explanation, for a report or a human auditing the list. */
394
+ detail: string;
395
+ }
396
+ export interface ScanResult {
397
+ /** 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. */
398
+ filesScanned: number;
399
+ candidates: CopyCandidate[];
400
+ /** Every literal this scanner found and deliberately excluded, with why — never silently dropped. See exclusions #1-#9 above. */
401
+ excluded: ExcludedLiteral[];
402
+ /** 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. */
403
+ citations: Citation[];
404
+ /** Files matched by extension/walk but never tokenized at all — test/check/declaration files, by design (exclusion #10). Counted, never silent. */
405
+ skippedByDesign: SkippedFile[];
406
+ /** 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". */
407
+ parseFailures: ParseFailure[];
408
+ /** 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. */
409
+ unchecked: UncheckedItem[];
410
+ /** 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). */
411
+ excludedFiles: ExcludedPath[];
412
+ /** 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. */
413
+ pathExclusionFindings: PathExclusionFinding[];
414
+ }
415
+ /**
416
+ * Walks `root` recursively, reads every matching file, and extracts copy
417
+ * candidates from each (see `extractCopyCandidates`). FAILS CLOSED on an
418
+ * unreadable directory or file — throws a plain `Error`, matching
419
+ * `@example/strategy`'s `scanStrategyDirectory` and this
420
+ * repository's own `scripts/check-contamination-classes.mjs` walker: a
421
+ * directory this function could not read might be hiding an unknown,
422
+ * unbounded amount of unregistered copy, and reporting "0 files scanned"
423
+ * for that case would read as a clean pass when nothing was actually
424
+ * verified. `cli.ts` is what turns this thrown error into exit code 2.
425
+ */
426
+ export declare function scanCopySourceTree(root: string, options?: ScanOptions): ScanResult;
427
+ /**
428
+ * Collapses every interpolation in a template literal's static text to one
429
+ * comparable shape. `\uE000` is the first code point of the Unicode
430
+ * Private Use Area — reserved by the standard for exactly this kind of
431
+ * private, non-textual sentinel use, and something no legitimate JS/TS
432
+ * source file authoring real UI copy will ever contain, so it can be
433
+ * dropped in without risk of colliding with genuine copy text (unlike,
434
+ * say, a literal space or `{}`, which real copy could plausibly contain).
435
+ * Exported so `copy-gate.ts` normalizes `CopyEntry.text`'s `{name}`
436
+ * placeholders to this exact same sentinel before comparing shapes.
437
+ */
438
+ export declare const PLACEHOLDER_SENTINEL = "\uE000";
439
+ interface ExtractResult {
440
+ candidates: CopyCandidate[];
441
+ excluded: ExcludedLiteral[];
442
+ citations: Citation[];
443
+ /** See `ScanResult.unchecked` — this is the per-file slice `scanCopySourceTree` aggregates into it. */
444
+ unchecked: UncheckedItem[];
445
+ parseFailure?: string;
446
+ }
447
+ /**
448
+ * Pure — no I/O. Tokenizes `content` and classifies every string/template
449
+ * literal AND (in a `.tsx`/`.jsx` file) every JSX text node found as
450
+ * either a copy candidate or an excluded literal (with a stated reason),
451
+ * per "THE BOUNDARY" in this file's top doc comment. Exported so a test
452
+ * (or a future consumer wanting extraction without a real directory
453
+ * walk) can exercise the classification logic directly against a
454
+ * source-text fixture.
455
+ */
456
+ export declare function extractCopyCandidates(content: string, filePath: string): ExtractResult;
457
+ //# sourceMappingURL=scan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"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"}