@clossys/writer 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +196 -0
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/addressability.d.ts +245 -0
- package/dist/addressability.d.ts.map +1 -0
- package/dist/addressability.js +567 -0
- package/dist/addressability.js.map +1 -0
- package/dist/checker.d.ts +159 -0
- package/dist/checker.d.ts.map +1 -0
- package/dist/checker.js +177 -0
- package/dist/checker.js.map +1 -0
- package/dist/cli.d.ts +149 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1049 -0
- package/dist/cli.js.map +1 -0
- package/dist/copy-gate.d.ts +107 -0
- package/dist/copy-gate.d.ts.map +1 -0
- package/dist/copy-gate.js +132 -0
- package/dist/copy-gate.js.map +1 -0
- package/dist/fingerprint.d.ts +61 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +64 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-coverage.d.ts +227 -0
- package/dist/locale-coverage.d.ts.map +1 -0
- package/dist/locale-coverage.js +414 -0
- package/dist/locale-coverage.js.map +1 -0
- package/dist/passage.d.ts +294 -0
- package/dist/passage.d.ts.map +1 -0
- package/dist/passage.js +463 -0
- package/dist/passage.js.map +1 -0
- package/dist/path-exclusions.d.ts +114 -0
- package/dist/path-exclusions.d.ts.map +1 -0
- package/dist/path-exclusions.js +213 -0
- package/dist/path-exclusions.js.map +1 -0
- package/dist/registry.d.ts +61 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +64 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +33 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +133 -0
- package/dist/resolve.js.map +1 -0
- package/dist/scan.d.ts +457 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +1698 -0
- package/dist/scan.js.map +1 -0
- package/dist/schema.d.ts +81 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +482 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +40 -0
- package/dist/types.js.map +1 -0
- package/dist/voice/checker.d.ts +186 -0
- package/dist/voice/checker.d.ts.map +1 -0
- package/dist/voice/checker.js +483 -0
- package/dist/voice/checker.js.map +1 -0
- package/dist/voice/derivation-coverage.d.ts +168 -0
- package/dist/voice/derivation-coverage.d.ts.map +1 -0
- package/dist/voice/derivation-coverage.js +181 -0
- package/dist/voice/derivation-coverage.js.map +1 -0
- package/dist/voice/fields.d.ts +107 -0
- package/dist/voice/fields.d.ts.map +1 -0
- package/dist/voice/fields.js +144 -0
- package/dist/voice/fields.js.map +1 -0
- package/dist/voice/index.d.ts +19 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +24 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/internal/parse-template.d.ts +27 -0
- package/dist/voice/internal/parse-template.d.ts.map +1 -0
- package/dist/voice/internal/parse-template.js +111 -0
- package/dist/voice/internal/parse-template.js.map +1 -0
- package/dist/voice/internal/pattern-safety.d.ts +111 -0
- package/dist/voice/internal/pattern-safety.d.ts.map +1 -0
- package/dist/voice/internal/pattern-safety.js +278 -0
- package/dist/voice/internal/pattern-safety.js.map +1 -0
- package/dist/voice/schema.d.ts +65 -0
- package/dist/voice/schema.d.ts.map +1 -0
- package/dist/voice/schema.js +535 -0
- package/dist/voice/schema.js.map +1 -0
- package/dist/voice/types.d.ts +282 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +37 -0
- package/dist/voice/types.js.map +1 -0
- package/package.json +72 -0
- package/src/addressability.ts +664 -0
- package/src/checker.ts +293 -0
- package/src/cli.ts +1166 -0
- package/src/copy-gate.ts +197 -0
- package/src/fingerprint.ts +66 -0
- package/src/index.ts +163 -0
- package/src/locale-coverage.ts +512 -0
- package/src/passage.ts +644 -0
- package/src/path-exclusions.ts +265 -0
- package/src/registry.ts +103 -0
- package/src/resolve.ts +172 -0
- package/src/scan.ts +1959 -0
- package/src/schema.ts +528 -0
- package/src/types.ts +301 -0
- package/src/voice/checker.ts +590 -0
- package/src/voice/derivation-coverage.ts +217 -0
- package/src/voice/fields.ts +160 -0
- package/src/voice/index.ts +55 -0
- package/src/voice/internal/parse-template.ts +114 -0
- package/src/voice/internal/pattern-safety.ts +336 -0
- package/src/voice/schema.ts +585 -0
- package/src/voice/types.ts +329 -0
- package/templates/voice-record.template.jsonc +135 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scanAddressabilitySources` and `checkAddressability` — the
|
|
3
|
+
* copy-ADDRESSABILITY gate: is user-facing prose resolved from a copy
|
|
4
|
+
* registry BY ID, or typed directly into a component? A DIFFERENT, STRICTER
|
|
5
|
+
* question than `copy-gate.ts`'s traceability check. Traceability accepts a
|
|
6
|
+
* literal that happens to match a registered `CopyEntry.text`'s SHAPE, or
|
|
7
|
+
* that carries a `copy:<id>` citation comment — both of which still leave
|
|
8
|
+
* the actual sentence sitting in the component's own source. That is cheap
|
|
9
|
+
* to verify but NOT cheap to change: marketing renaming a CTA means editing
|
|
10
|
+
* every component that typed it, not one registry entry. Addressability is
|
|
11
|
+
* the property that keeps that rename cheap: the component's own source
|
|
12
|
+
* contains an id, never the sentence. This file does not accept a citation
|
|
13
|
+
* or a text-shape match as satisfying — see `extractAddressabilityCandidates`
|
|
14
|
+
* below, which has no `CopyRecord` input at all and therefore nothing to
|
|
15
|
+
* match against; a literal is either not present as authored prose in a
|
|
16
|
+
* position this gate can see, or it is a violation.
|
|
17
|
+
*
|
|
18
|
+
* Measured across four real sites at the time this gate was written: 2
|
|
19
|
+
* inline prose strings on one, 4 attribute-borne on another, 0 elsewhere —
|
|
20
|
+
* so the property currently holds, by luck rather than enforcement. This
|
|
21
|
+
* file is what turns that measurement into something that stays true.
|
|
22
|
+
*
|
|
23
|
+
* NO PARSER LIBRARY, no new dependency — `packages/copy` ships zero runtime
|
|
24
|
+
* dependencies and this file adds none. It reuses `scan.ts`'s
|
|
25
|
+
* `extractCopyCandidates` for the genuinely hard part (finding every
|
|
26
|
+
* string/template literal and JSX text-node BOUNDARY correctly — escaped
|
|
27
|
+
* quotes, nested interpolation, comments, regex literals, and JSX itself),
|
|
28
|
+
* and `path-exclusions.ts`'s `validatePathExclusions`/`matchesPathExclusion`
|
|
29
|
+
* for the file-scope exclusion mechanism, exactly the way `scan.ts` itself
|
|
30
|
+
* does. This file's own walker (`scanAddressabilitySources`) mirrors
|
|
31
|
+
* `scan.ts`'s `scanCopySourceTree` and `@example/ui`'s
|
|
32
|
+
* `style-scan.ts`'s `scanStyleSources` closely (same extensions/skipDirs
|
|
33
|
+
* defaults, same `SKIP_FILE_RE`, same fail-closed-on-unreadable-directory
|
|
34
|
+
* contract) — a thin per-file dispatch loop, not a second tokenizer: the
|
|
35
|
+
* actual literal/JSX-boundary parsing is ENTIRELY delegated to `scan.ts`.
|
|
36
|
+
*
|
|
37
|
+
* ============================================================================
|
|
38
|
+
* THREE POSITIONS, THREE CLASSIFICATIONS
|
|
39
|
+
* ============================================================================
|
|
40
|
+
*
|
|
41
|
+
* 1. MARKUP TEXT NODES (`<span>Hello</span>`'s `Hello`) — `scan.ts`'s own
|
|
42
|
+
* `kind: "jsx-text"` candidates. `scan.ts` already dropped
|
|
43
|
+
* whitespace-only and no-letters runs before these ever reach this
|
|
44
|
+
* file, so every one remaining is real, rendered prose authored
|
|
45
|
+
* directly in markup — never resolved through an id, by construction
|
|
46
|
+
* (a JSX text node IS the literal text; an id-resolved child would be
|
|
47
|
+
* an EXPRESSION child, `{copy(id)}`, which `scan.ts`'s tokenizer treats
|
|
48
|
+
* as code, never a text-node candidate at all — see `scan.ts`'s own
|
|
49
|
+
* top doc comment, "JSX TEXT NODES"). ALWAYS a violation.
|
|
50
|
+
*
|
|
51
|
+
* 2. THE FOUR USER-FACING ATTRIBUTES — `aria-label`, `placeholder`, `alt`,
|
|
52
|
+
* `title`. These carry prose a person reads (an accessible name, a
|
|
53
|
+
* hint, alt text, a tooltip) and are NOT text nodes; a scanner that
|
|
54
|
+
* only understands text nodes reports zero on a real component whose
|
|
55
|
+
* ENTIRE user-facing surface is `<input aria-label="..." placeholder=
|
|
56
|
+
* "..." />` and calls that a clean pass. `scan.ts` itself already
|
|
57
|
+
* excludes `aria-label` from its own candidate list (bundled under
|
|
58
|
+
* `"aria-or-data-attribute-value"` together with `aria-hidden`,
|
|
59
|
+
* `aria-describedby`, every `data-*` attribute — a deliberate SCOPE
|
|
60
|
+
* decision for traceability, see `scan.ts`'s exclusion #4) and leaves
|
|
61
|
+
* `placeholder`/`alt`/`title` as ordinary default-included candidates
|
|
62
|
+
* (neither aria/data-shaped nor on its structural denylist). This gate
|
|
63
|
+
* re-derives each literal's own attribute NAME locally — a string
|
|
64
|
+
* literal never spans a line (see `scan.ts`'s `scanStringLiteral`), so
|
|
65
|
+
* "the identifier immediately before this literal's own quote, on the
|
|
66
|
+
* SAME line" is an exact lookup, not an approximation — specifically to
|
|
67
|
+
* recover `aria-label` back into scope while leaving every OTHER
|
|
68
|
+
* aria-*, data-* attribute exactly where `scan.ts` already decided it
|
|
69
|
+
* belongs: out of copy governance entirely. A literal-valued occurrence
|
|
70
|
+
* of one of the four, carrying at least one letter, is ALWAYS a
|
|
71
|
+
* violation — there is no citation or registry-text-match escape hatch
|
|
72
|
+
* here (see this file's top comment for why) — UNLESS the assignment
|
|
73
|
+
* this gate re-derived is itself a DESTRUCTURING-PATTERN DEFAULT or a
|
|
74
|
+
* plain (non-destructured) PARAMETER DEFAULT (`{ "aria-label":
|
|
75
|
+
* ariaLabel = "Pagination" }`, `{ placeholder = "Search" }`,
|
|
76
|
+
* `function f(alt = "...")`), never a JSX attribute at all — see
|
|
77
|
+
* `isDestructuringOrParameterDefault`. A default value in a
|
|
78
|
+
* destructuring pattern is exactly the property this gate exists to
|
|
79
|
+
* protect: the CONSUMER can override it, so the component's own source
|
|
80
|
+
* does not lock the sentence in the way a hardcoded JSX attribute
|
|
81
|
+
* does. Reporting one as a violation would not just be a false
|
|
82
|
+
* positive — it would INVERT the verdict on a construct that is
|
|
83
|
+
* already correctly addressable.
|
|
84
|
+
*
|
|
85
|
+
* 3. EVERYTHING ELSE THIS GATE CANNOT CONFIDENTLY CLASSIFY — a template
|
|
86
|
+
* literal (in ANY position, including one of the four attributes above:
|
|
87
|
+
* a template can carry an interpolated copy id, a partial sentence, or
|
|
88
|
+
* genuine inline prose, and this gate does not attempt to tell those
|
|
89
|
+
* apart), an object/array literal VALUE (`scan.ts` only excludes object
|
|
90
|
+
* KEYS, never values — `{ tooltip: "Explain this" }`'s `"Explain
|
|
91
|
+
* this"` is a default-included candidate with no attribute name at
|
|
92
|
+
* all), or a plain string in a prop that is none of the four (a custom
|
|
93
|
+
* `tooltip`, a bare variable assignment, ...) — UNLESS that string is
|
|
94
|
+
* itself shaped like a CSS/Tailwind utility class LIST (`"border-t
|
|
95
|
+
* border-line-base pt-xs"`, `"grid-cols-1 tablet:grid-cols-2"`), which
|
|
96
|
+
* is definitively not prose and is excluded here for the same reason
|
|
97
|
+
* `scan.ts` already excludes that shape when it DOES have an attribute
|
|
98
|
+
* name to key off (a literal `className="..."` value) — see
|
|
99
|
+
* `looksLikeUtilityClassList`. Everything else in this category is
|
|
100
|
+
* NEVER silently treated as clean, and NEVER treated as a violation
|
|
101
|
+
* either — reported as `unchecked`, the same discipline `scan.ts`'s own
|
|
102
|
+
* `UncheckedItem` and `checkCopyTraceability`'s `unchecked` passthrough
|
|
103
|
+
* already hold JSX parse ambiguities to. A measurement may round an
|
|
104
|
+
* unknown down to "probably fine"; a gate may not — that is the whole
|
|
105
|
+
* reason this file exists rather than a grep for `aria-label="`.
|
|
106
|
+
*
|
|
107
|
+
* `scan.ts`'s own exclusions for genuinely non-user-facing literals
|
|
108
|
+
* (`className`, `data-testid`, an import specifier, a `cx(...)` argument, a
|
|
109
|
+
* developer diagnostic, a decorative no-letters glyph, an enum-shaped
|
|
110
|
+
* single token, a `type`/`interface` context) apply here for free: a
|
|
111
|
+
* literal `scan.ts` excludes for one of THOSE reasons never becomes a
|
|
112
|
+
* `candidates` entry at all, so it never reaches this file's own
|
|
113
|
+
* classification and is never reported by it, in any of the three
|
|
114
|
+
* categories above. This is what keeps this gate from flagging `className`,
|
|
115
|
+
* `data-*`, an import specifier, or a `testId` — see this file's own test
|
|
116
|
+
* suite for a fixture proving exactly that.
|
|
117
|
+
*
|
|
118
|
+
* ============================================================================
|
|
119
|
+
* THE TERNARY
|
|
120
|
+
* ============================================================================
|
|
121
|
+
*
|
|
122
|
+
* Deliberately DIFFERENT from `copy-gate.ts`/`scan.ts`'s "an `unchecked[]`
|
|
123
|
+
* list gates a `2` before findings are counted" precedence — see issue #407.
|
|
124
|
+
* On this gate specifically, partial coverage of a real tree is not a rare
|
|
125
|
+
* edge case: hundreds of string positions are token data (category 3 above)
|
|
126
|
+
* this gate cannot classify BY DESIGN, on every real tree it has ever been
|
|
127
|
+
* run against. Letting "at least one unclassified position" outrank a
|
|
128
|
+
* confirmed violation therefore does not model "an occasional coverage gap
|
|
129
|
+
* might be hiding something" — it makes the `"violated"` branch permanently
|
|
130
|
+
* unreachable outside a fixture, because a real tree always has unclassified
|
|
131
|
+
* positions somewhere. A gate that can only ever report `"indeterminate"` or
|
|
132
|
+
* `"satisfied"` in production has one usable state, and a caller that
|
|
133
|
+
* learns to treat `2` as "coverage is never complete, ignore it" will walk
|
|
134
|
+
* straight past five named, file-and-line violations without ever seeing
|
|
135
|
+
* them — see `packages/inspector/src/review-evidence.ts`'s own argument
|
|
136
|
+
* against the mirror image of this mistake (folding an outage into a
|
|
137
|
+
* verdict); this is folding a verdict into an outage.
|
|
138
|
+
*
|
|
139
|
+
* - `"violated"` — at least one inline user-facing prose string found
|
|
140
|
+
* (category 1 or 2 above), REGARDLESS of how many string positions are
|
|
141
|
+
* unclassified. The coverage gap is not hidden: `reasons` and
|
|
142
|
+
* `unchecked` are still populated and still printed (`cli.ts`'s
|
|
143
|
+
* `printAddressabilityAccounting` reports `unchecked` unconditionally,
|
|
144
|
+
* independent of verdict) — a `"violated"` verdict never claims full
|
|
145
|
+
* coverage, it claims that what WAS seen included a definite violation,
|
|
146
|
+
* which is true regardless of what else was not seen.
|
|
147
|
+
* - `"indeterminate"` — zero violations, AND the source tree could not be
|
|
148
|
+
* read at all (this file's `scanAddressabilitySources` fails closed,
|
|
149
|
+
* throwing, exactly like `scan.ts`'s `scanCopySourceTree`), ZERO
|
|
150
|
+
* components were successfully scanned (nothing matched, or every
|
|
151
|
+
* matched file failed to parse), a `pathExclusions` entry was
|
|
152
|
+
* malformed, or ANY string position could not be confidently classified
|
|
153
|
+
* (category 3 above, or a JSX construct `scan.ts` itself could not
|
|
154
|
+
* resolve). The honest "found nothing, but did not see everything"
|
|
155
|
+
* case — it must never read as a pass.
|
|
156
|
+
* - `"satisfied"` — at least one component scanned, every string
|
|
157
|
+
* position classified, and no inline user-facing prose found.
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
161
|
+
import { extname, join, relative, sep } from "node:path";
|
|
162
|
+
import { extractCopyCandidates } from "./scan.js";
|
|
163
|
+
import type { ParseFailure, SkippedFile } from "./scan.js";
|
|
164
|
+
import {
|
|
165
|
+
matchesPathExclusion,
|
|
166
|
+
validatePathExclusions,
|
|
167
|
+
type ExcludedPath,
|
|
168
|
+
type PathExclusion,
|
|
169
|
+
type PathExclusionFinding,
|
|
170
|
+
type ValidatedPathExclusion,
|
|
171
|
+
} from "./path-exclusions.js";
|
|
172
|
+
|
|
173
|
+
export type { ParseFailure, SkippedFile } from "./scan.js";
|
|
174
|
+
export type { ExcludedPath, PathExclusion, PathExclusionFinding } from "./path-exclusions.js";
|
|
175
|
+
|
|
176
|
+
// --------------------------------------------------------------- walking
|
|
177
|
+
|
|
178
|
+
export interface AddressabilityScanOptions {
|
|
179
|
+
/** File extensions to read, each including the leading dot. Default: `.ts`, `.tsx`, `.js`, `.jsx`. */
|
|
180
|
+
extensions?: string[];
|
|
181
|
+
/** Directory names never descended into. Default: node_modules, .git, dist, build, coverage. */
|
|
182
|
+
skipDirs?: string[];
|
|
183
|
+
/** Same mechanism `scan.ts`'s `ScanOptions.pathExclusions` uses — see `path-exclusions.ts`. */
|
|
184
|
+
pathExclusions?: PathExclusion[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
188
|
+
const DEFAULT_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
189
|
+
|
|
190
|
+
/** `.test.ts(x)`, `.spec.ts(x)`, `.check.ts(x)`, and bare `.d.ts` — the identical pattern `scan.ts` and `@example/ui`'s `style-scan.ts` both use, reused verbatim rather than reinvented (not exported by `scan.ts`, so redefined locally here — the same precedent `style-scan.ts` sets for its own copy of this constant). */
|
|
191
|
+
const SKIP_FILE_RE = /\.(test|spec|check)\.(ts|tsx|js|jsx)$|\.d\.ts$/;
|
|
192
|
+
|
|
193
|
+
export type AddressabilityPosition = "markup-text" | "user-facing-attribute";
|
|
194
|
+
|
|
195
|
+
export interface AddressabilityViolation {
|
|
196
|
+
file: string;
|
|
197
|
+
line: number;
|
|
198
|
+
position: AddressabilityPosition;
|
|
199
|
+
/** Set only for `"user-facing-attribute"` — which of the four this literal was the value of. */
|
|
200
|
+
attribute?: "aria-label" | "placeholder" | "alt" | "title";
|
|
201
|
+
/** The literal's own raw source text, for a human auditing the finding. */
|
|
202
|
+
raw: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface AddressabilityUncheckedItem {
|
|
206
|
+
file: string;
|
|
207
|
+
line: number;
|
|
208
|
+
/** A short, stable machine-readable name — see this file's top doc comment, category 3, and `extractAddressabilityCandidates`'s own inline comments for every kind this can be. */
|
|
209
|
+
kind: string;
|
|
210
|
+
detail: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface AddressabilityScanResult {
|
|
214
|
+
/** Files whose content was successfully tokenized. Required to be > 0 for a satisfied verdict — see this file's top doc comment, "THE TERNARY". */
|
|
215
|
+
filesScanned: number;
|
|
216
|
+
violations: AddressabilityViolation[];
|
|
217
|
+
/** Every string position this gate could not confidently classify — never silently dropped. See this file's top doc comment, category 3. */
|
|
218
|
+
unchecked: AddressabilityUncheckedItem[];
|
|
219
|
+
skippedByDesign: SkippedFile[];
|
|
220
|
+
parseFailures: ParseFailure[];
|
|
221
|
+
excludedFiles: ExcludedPath[];
|
|
222
|
+
pathExclusionFindings: PathExclusionFinding[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Walks `root` recursively, reads every matching file, and extracts
|
|
227
|
+
* addressability violations/unchecked positions from each (see
|
|
228
|
+
* `extractAddressabilityCandidates`). FAILS CLOSED on an unreadable
|
|
229
|
+
* directory or file — throws a plain `Error`, matching `scan.ts`'s
|
|
230
|
+
* `scanCopySourceTree` exactly, for the identical reason: a directory this
|
|
231
|
+
* function could not read might be hiding an unknown amount of inline
|
|
232
|
+
* prose, and reporting "0 files scanned" for that case would read as a
|
|
233
|
+
* clean pass when nothing was actually verified.
|
|
234
|
+
*/
|
|
235
|
+
export function scanAddressabilitySources(root: string, options: AddressabilityScanOptions = {}): AddressabilityScanResult {
|
|
236
|
+
const extensions = new Set(options.extensions ?? DEFAULT_EXTENSIONS);
|
|
237
|
+
const skipDirs = new Set(options.skipDirs ?? DEFAULT_SKIP_DIRS);
|
|
238
|
+
|
|
239
|
+
const pathExclusionValidation = validatePathExclusions(options.pathExclusions);
|
|
240
|
+
const matchCounts = new Map<ValidatedPathExclusion, number>();
|
|
241
|
+
for (const exclusion of pathExclusionValidation.valid) matchCounts.set(exclusion, 0);
|
|
242
|
+
|
|
243
|
+
const result: AddressabilityScanResult = {
|
|
244
|
+
filesScanned: 0,
|
|
245
|
+
violations: [],
|
|
246
|
+
unchecked: [],
|
|
247
|
+
skippedByDesign: [],
|
|
248
|
+
parseFailures: [],
|
|
249
|
+
excludedFiles: [],
|
|
250
|
+
pathExclusionFindings: [...pathExclusionValidation.findings],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function walk(dir: string): void {
|
|
254
|
+
let entries: string[];
|
|
255
|
+
try {
|
|
256
|
+
entries = readdirSync(dir);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`scanAddressabilitySources: cannot read directory "${dir}": ${error instanceof Error ? error.message : String(error)}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (skipDirs.has(entry)) continue;
|
|
264
|
+
const full = join(dir, entry);
|
|
265
|
+
let stat;
|
|
266
|
+
try {
|
|
267
|
+
stat = statSync(full);
|
|
268
|
+
} catch {
|
|
269
|
+
continue; // broken symlink — nothing to read, not a directory-listing failure
|
|
270
|
+
}
|
|
271
|
+
if (stat.isDirectory()) {
|
|
272
|
+
walk(full);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!stat.isFile()) continue;
|
|
276
|
+
if (!extensions.has(extname(entry).toLowerCase())) continue;
|
|
277
|
+
|
|
278
|
+
const relPath = relative(root, full).split(sep).join("/");
|
|
279
|
+
|
|
280
|
+
let matchedExclusion: ValidatedPathExclusion | undefined;
|
|
281
|
+
for (const exclusion of pathExclusionValidation.valid) {
|
|
282
|
+
if (matchesPathExclusion(relPath, exclusion)) {
|
|
283
|
+
matchCounts.set(exclusion, (matchCounts.get(exclusion) ?? 0) + 1);
|
|
284
|
+
matchedExclusion ??= exclusion;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (matchedExclusion) {
|
|
288
|
+
result.excludedFiles.push({ file: relPath, reason: matchedExclusion.reason, pattern: matchedExclusion.path });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (SKIP_FILE_RE.test(entry)) {
|
|
293
|
+
result.skippedByDesign.push({ file: relPath, reason: "test-or-check-file" });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let content: string;
|
|
298
|
+
try {
|
|
299
|
+
content = readFileSync(full, "utf8");
|
|
300
|
+
} catch (error) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`scanAddressabilitySources: cannot read file "${full}": ${error instanceof Error ? error.message : String(error)}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const extracted = extractAddressabilityCandidates(content, relPath);
|
|
307
|
+
if (extracted.parseFailure) {
|
|
308
|
+
result.parseFailures.push({ file: relPath, detail: extracted.parseFailure });
|
|
309
|
+
continue; // no positions trusted from a file scan.ts could not reliably tokenize
|
|
310
|
+
}
|
|
311
|
+
result.filesScanned++;
|
|
312
|
+
result.violations.push(...extracted.violations);
|
|
313
|
+
result.unchecked.push(...extracted.unchecked);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
walk(root);
|
|
318
|
+
|
|
319
|
+
for (const exclusion of pathExclusionValidation.valid) {
|
|
320
|
+
if ((matchCounts.get(exclusion) ?? 0) === 0) {
|
|
321
|
+
result.pathExclusionFindings.push({
|
|
322
|
+
rule: "path-exclusion-unused",
|
|
323
|
+
severity: "warning",
|
|
324
|
+
message: `pathExclusions entry "${exclusion.path}" (reason: "${exclusion.reason}") did not match any scanned file. Remove it, or check for a typo/renamed path.`,
|
|
325
|
+
path: exclusion.path,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ------------------------------------------------------------- extraction
|
|
334
|
+
|
|
335
|
+
/** `ariaLabel` -> `arialabel`, `aria-label` -> `arialabel` — the identical case/hyphen-insensitive normalization `scan.ts`'s own `normalizeIdentifier` performs (not exported, so reimplemented locally — a one-line heuristic, not worth a cross-file dependency). */
|
|
336
|
+
function normalizeAttrName(name: string): string {
|
|
337
|
+
return name
|
|
338
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
339
|
+
.toLowerCase()
|
|
340
|
+
.replace(/-/g, "");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function hasProse(text: string): boolean {
|
|
344
|
+
return /\p{L}/u.test(text.trim());
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Finds the identifier immediately before `raw` on its own source line —
|
|
349
|
+
* `raw` INCLUDES its own quotes, exactly as `scan.ts` slices it, so
|
|
350
|
+
* `lineText.indexOf(raw)` lands on the literal's own opening quote. Safe
|
|
351
|
+
* ONLY for a plain string literal: `scan.ts`'s `scanStringLiteral` never
|
|
352
|
+
* lets a string span a raw newline (an unterminated string ends the
|
|
353
|
+
* literal at end-of-line instead), so a single-line lookup is exact here,
|
|
354
|
+
* never an approximation — unlike a template literal, which CAN span
|
|
355
|
+
* multiple lines and is never passed through this function (see
|
|
356
|
+
* `extractAddressabilityCandidates`, which routes every template literal
|
|
357
|
+
* straight to `unchecked` without ever attempting this lookup).
|
|
358
|
+
*/
|
|
359
|
+
function attributeNameFor(lines: string[], line: number, raw: string): string | undefined {
|
|
360
|
+
const lineText = lines[line - 1];
|
|
361
|
+
if (lineText === undefined) return undefined;
|
|
362
|
+
const idx = lineText.indexOf(raw);
|
|
363
|
+
if (idx === -1) return undefined;
|
|
364
|
+
const prefix = lineText.slice(0, idx);
|
|
365
|
+
const match = /([A-Za-z_$][A-Za-z0-9_$-]*)\s*=\s*\{?\s*$/.exec(prefix);
|
|
366
|
+
return match?.[1];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* How many lines of LEADING context `isDestructuringOrParameterDefault`
|
|
371
|
+
* looks back through to find the `{`/`(`/`,` that precedes a destructured
|
|
372
|
+
* property or parameter — each property in a real multi-line destructure
|
|
373
|
+
* (see `Pagination.tsx`'s own `PaginationProps` destructure) is normally
|
|
374
|
+
* its own line, so the delimiter is usually one line up, on the PRIOR
|
|
375
|
+
* property's trailing comma. A small, bounded window, not an attempt to
|
|
376
|
+
* parse the whole enclosing pattern.
|
|
377
|
+
*/
|
|
378
|
+
const DESTRUCTURING_CONTEXT_LINES = 5;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `{`/`(` (the pattern's or parameter list's own opening delimiter) or a
|
|
382
|
+
* bare `,` (separating this property/parameter from the previous one),
|
|
383
|
+
* optionally preceded by a rename (`identifier:` or `"quoted-key":`, for
|
|
384
|
+
* `{ "aria-label": ariaLabel = "..." }`), then the bound identifier
|
|
385
|
+
* itself and `=` — anchored at the END of the search text, matching
|
|
386
|
+
* exactly what precedes a destructuring-pattern DEFAULT VALUE or a plain
|
|
387
|
+
* (non-destructured) parameter default. Deliberately does NOT match
|
|
388
|
+
* through any OTHER non-whitespace token: if something this narrow
|
|
389
|
+
* heuristic cannot account for sits between the delimiter and the
|
|
390
|
+
* identifier, the match simply fails and `isDestructuringOrParameterDefault`
|
|
391
|
+
* returns `false`, falling back to the pre-existing (JSX-attribute)
|
|
392
|
+
* assumption rather than guessing.
|
|
393
|
+
*/
|
|
394
|
+
const DESTRUCTURING_OR_PARAMETER_DEFAULT_RE =
|
|
395
|
+
/[{(,]\s*(?:(?:"[^"]*"|'[^']*'|[A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*)?[A-Za-z_$][A-Za-z0-9_$-]*\s*=\s*\{?\s*$/;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Whether the `identifier =` assignment `attributeNameFor` found
|
|
399
|
+
* immediately before `raw` is actually a DESTRUCTURING-PATTERN DEFAULT
|
|
400
|
+
* (`{ label = "Save" }`, or renamed `{ "aria-label": ariaLabel = "Save"
|
|
401
|
+
* }`) or a plain, non-destructured PARAMETER DEFAULT (`function
|
|
402
|
+
* f(ariaLabel = "Save")`) — never a JSX attribute assignment at all
|
|
403
|
+
* (`aria-label="Save"`). `attributeNameFor`'s own regex cannot tell these
|
|
404
|
+
* apart on its own: both are exactly `identifier = "..."` in the raw
|
|
405
|
+
* source text it looks at. They ARE distinguishable, though, by what
|
|
406
|
+
* immediately precedes the identifier once an optional rename is skipped
|
|
407
|
+
* over: a JSX attribute is always preceded by a tag name, another
|
|
408
|
+
* attribute's already-finished value, or `<` itself — NEVER by `{`, `(`,
|
|
409
|
+
* or `,`. A destructuring pattern's own property, or a parameter list's
|
|
410
|
+
* own argument, is ALWAYS preceded by exactly one of those three (`{`/`(`
|
|
411
|
+
* for the first property/argument, `,` for every one after) — JSX
|
|
412
|
+
* attributes, by contrast, are never comma-separated, and neither a
|
|
413
|
+
* pattern nor a parameter list opens with `<`. See
|
|
414
|
+
* `DESTRUCTURING_OR_PARAMETER_DEFAULT_RE`'s own doc comment for exactly
|
|
415
|
+
* what is and is not matched.
|
|
416
|
+
*/
|
|
417
|
+
function isDestructuringOrParameterDefault(lines: string[], line: number, raw: string): boolean {
|
|
418
|
+
const lineText = lines[line - 1];
|
|
419
|
+
if (lineText === undefined) return false;
|
|
420
|
+
const idx = lineText.indexOf(raw);
|
|
421
|
+
if (idx === -1) return false;
|
|
422
|
+
const start = Math.max(0, line - 1 - DESTRUCTURING_CONTEXT_LINES);
|
|
423
|
+
const context = [...lines.slice(start, line - 1), lineText.slice(0, idx)].join("\n");
|
|
424
|
+
return DESTRUCTURING_OR_PARAMETER_DEFAULT_RE.test(context);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Each token in a CSS/Tailwind-shaped utility class LIST: an optional
|
|
429
|
+
* leading `-` (negative utilities, e.g. `-mt-2`), a lowercase
|
|
430
|
+
* alphanumeric run, then one or more `-`/`:`/`/`-separated lowercase
|
|
431
|
+
* alphanumeric segments — `border-t`, `border-line-base`, `pt-xs`,
|
|
432
|
+
* `grid-cols-1`, `tablet:grid-cols-2` all match; a bare English word
|
|
433
|
+
* (`flex`, `hidden`, `low`, `open`) does NOT, because it has no separated
|
|
434
|
+
* segment at all (the trailing group requires at least one repetition).
|
|
435
|
+
* That asymmetry is deliberate — see `looksLikeUtilityClassList`'s own
|
|
436
|
+
* doc comment.
|
|
437
|
+
*/
|
|
438
|
+
const CLASS_TOKEN_RE = /^-?[a-z0-9]+(?:[:/-][a-z0-9.]+)+$/;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Whether `raw` (a plain string literal's own raw source text, quotes
|
|
442
|
+
* included — never a template literal, see this function's call site)
|
|
443
|
+
* is shaped like a space-separated list of CSS/Tailwind utility class
|
|
444
|
+
* names rather than prose. `scan.ts` already excludes this exact shape
|
|
445
|
+
* when the literal sits in an actual `className` ATTRIBUTE (see this
|
|
446
|
+
* file's top doc comment, "THREE POSITIONS"); this catches the SAME
|
|
447
|
+
* shape one level removed — a class list assigned to a variable or
|
|
448
|
+
* sitting in an object-literal value (`Faq.tsx`'s `"border-t
|
|
449
|
+
* border-line-base pt-xs"`, `FieldGroup.tsx`'s `"grid-cols-1
|
|
450
|
+
* tablet:grid-cols-2"`) — which has no attribute name for `scan.ts`'s own
|
|
451
|
+
* exclusion to key off, and so reaches this file as an ordinary "string"
|
|
452
|
+
* candidate instead.
|
|
453
|
+
*
|
|
454
|
+
* Deliberately conservative: EVERY token must match `CLASS_TOKEN_RE`,
|
|
455
|
+
* which requires at least one `-`/`:`/`/`-separated segment — a shape a
|
|
456
|
+
* hand-typed English sentence does not reliably have across every one of
|
|
457
|
+
* its words, but a Tailwind utility name always does. A bare,
|
|
458
|
+
* separator-free utility class (`"flex"`, `"hidden"`) mixed into such a
|
|
459
|
+
* list still fails this check and is left `unchecked` rather than risk a
|
|
460
|
+
* real short English word (`"low"`, `"open"`) being swept in as
|
|
461
|
+
* definitively clean — an intentional under-classification, not an
|
|
462
|
+
* oversight; see this file's own test suite.
|
|
463
|
+
*/
|
|
464
|
+
function looksLikeUtilityClassList(raw: string): boolean {
|
|
465
|
+
const quote = raw[0];
|
|
466
|
+
if (quote !== '"' && quote !== "'") return false; // a template literal never reaches this — see this function's call site
|
|
467
|
+
const text = raw.slice(1, -1).trim();
|
|
468
|
+
if (text.length === 0 || text.length > 300) return false;
|
|
469
|
+
const tokens = text.split(/\s+/);
|
|
470
|
+
if (tokens.length < 2 || tokens.length > 40) return false;
|
|
471
|
+
return tokens.every((t) => t.length <= 60 && CLASS_TOKEN_RE.test(t));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
interface AddressabilityExtractResult {
|
|
475
|
+
violations: AddressabilityViolation[];
|
|
476
|
+
unchecked: AddressabilityUncheckedItem[];
|
|
477
|
+
parseFailure?: string;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Pure — no I/O. Classifies every string/template literal and JSX text
|
|
482
|
+
* node `scan.ts`'s `extractCopyCandidates` finds in `content` into one of
|
|
483
|
+
* this file's three positions (see top doc comment). Exported so a test
|
|
484
|
+
* can exercise classification directly against a source-text fixture,
|
|
485
|
+
* mirroring `extractCopyCandidates` itself.
|
|
486
|
+
*/
|
|
487
|
+
export function extractAddressabilityCandidates(content: string, filePath: string): AddressabilityExtractResult {
|
|
488
|
+
const { candidates, excluded, unchecked: jsxUnchecked, parseFailure } = extractCopyCandidates(content, filePath);
|
|
489
|
+
if (parseFailure) return { violations: [], unchecked: [], parseFailure };
|
|
490
|
+
|
|
491
|
+
const lines = content.split("\n");
|
|
492
|
+
const violations: AddressabilityViolation[] = [];
|
|
493
|
+
const unchecked: AddressabilityUncheckedItem[] = [];
|
|
494
|
+
|
|
495
|
+
// JSX constructs scan.ts itself recognized but could not resolve
|
|
496
|
+
// (unclosed elements, unterminated attribute values/expressions, ...) —
|
|
497
|
+
// passed through unchanged. A region scan.ts could not classify is a
|
|
498
|
+
// region THIS gate cannot classify either.
|
|
499
|
+
for (const u of jsxUnchecked) {
|
|
500
|
+
unchecked.push({ file: u.file, line: u.line, kind: u.kind, detail: u.detail });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---- position 1: markup text nodes — always a violation ----
|
|
504
|
+
for (const c of candidates) {
|
|
505
|
+
if (c.kind !== "jsx-text") continue;
|
|
506
|
+
violations.push({ file: c.file, line: c.line, position: "markup-text", raw: c.raw });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---- position 2a: aria-label, recovered from scan.ts's own excluded
|
|
510
|
+
// "aria-or-data-attribute-value" bucket, which bundles it together with
|
|
511
|
+
// every OTHER aria-*/data-* attribute (see this file's top doc comment).
|
|
512
|
+
for (const e of excluded) {
|
|
513
|
+
if (e.reason !== "aria-or-data-attribute-value") continue;
|
|
514
|
+
// scan.ts's own attribute-name check (which decided this literal
|
|
515
|
+
// belongs here) runs identically regardless of quote kind, so this
|
|
516
|
+
// bucket can contain either a plain string OR a template literal —
|
|
517
|
+
// `attributeNameFor`'s single-line lookup is safe for both here
|
|
518
|
+
// specifically because a template literal used as an ATTRIBUTE VALUE
|
|
519
|
+
// is always `name={`...`}` on one line in practice (a multi-line one
|
|
520
|
+
// would fail this lookup and fall into "unresolved-attribute-name"
|
|
521
|
+
// below, never silently misattributed).
|
|
522
|
+
const attrName = attributeNameFor(lines, e.line, e.raw);
|
|
523
|
+
if (attrName === undefined) {
|
|
524
|
+
// scan.ts confirmed this literal sits in an aria-*/data-* attribute
|
|
525
|
+
// position, but this file's own (deliberately simpler, JSX-only)
|
|
526
|
+
// lookup could not re-derive which one — never silently dropped.
|
|
527
|
+
unchecked.push({
|
|
528
|
+
file: e.file,
|
|
529
|
+
line: e.line,
|
|
530
|
+
kind: "unresolved-attribute-name",
|
|
531
|
+
detail: `an aria-*/data-* attribute value ${e.raw} could not be traced back to its own attribute name on the same line`,
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (normalizeAttrName(attrName) !== "arialabel") continue; // a different aria-*/data-* attribute — out of this gate's scope, same as scan.ts's own traceability gate
|
|
536
|
+
if (isDestructuringOrParameterDefault(lines, e.line, e.raw)) continue; // a default value the consumer can override, not a JSX attribute at all — see isDestructuringOrParameterDefault
|
|
537
|
+
if (e.raw.startsWith("`")) {
|
|
538
|
+
// A template literal in the ONE attribute this gate DOES track —
|
|
539
|
+
// could carry an interpolated copy id or genuine inline prose, and
|
|
540
|
+
// this gate does not attempt to tell those apart (see this file's
|
|
541
|
+
// top doc comment, category 3). Indeterminate, never a violation.
|
|
542
|
+
unchecked.push({
|
|
543
|
+
file: e.file,
|
|
544
|
+
line: e.line,
|
|
545
|
+
kind: "template-literal",
|
|
546
|
+
detail: `template literal ${e.raw} (aria-label) could carry an interpolated copy id, a partial sentence, or genuine inline prose — this gate does not attempt to tell those apart`,
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (!hasProse(e.raw)) continue; // e.g. aria-hidden-shaped booleans/empty values that happened to still match the aria/data check
|
|
551
|
+
violations.push({ file: e.file, line: e.line, position: "user-facing-attribute", attribute: "aria-label", raw: e.raw });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---- position 2b: placeholder/alt/title — not aria/data-shaped and not
|
|
555
|
+
// on scan.ts's structural denylist, so these are already ordinary
|
|
556
|
+
// default-included "string" candidates. Reclassified here rather than
|
|
557
|
+
// left to fall through to position 3 below.
|
|
558
|
+
const claimed = new Set<string>();
|
|
559
|
+
for (const c of candidates) {
|
|
560
|
+
if (c.kind !== "string") continue;
|
|
561
|
+
const attrName = attributeNameFor(lines, c.line, c.raw);
|
|
562
|
+
if (attrName === undefined) continue;
|
|
563
|
+
const normalized = normalizeAttrName(attrName);
|
|
564
|
+
// "arialabel" never reaches this branch in practice — scan.ts already
|
|
565
|
+
// routed it into `excluded` (see position 2a above) before it could
|
|
566
|
+
// ever become a "string"-kind candidate — but the explicit exclusion
|
|
567
|
+
// documents that this loop is deliberately NOT the aria-label path.
|
|
568
|
+
const attribute = (["placeholder", "alt", "title"] as const).find((n) => n === normalized);
|
|
569
|
+
if (!attribute) continue;
|
|
570
|
+
// Handled here either way (violation OR a default value excluded
|
|
571
|
+
// below) — `claimed` marks it done so position 3 below never
|
|
572
|
+
// re-examines it and reports it as unclassified instead.
|
|
573
|
+
claimed.add(`${c.line}::${c.raw}`);
|
|
574
|
+
if (isDestructuringOrParameterDefault(lines, c.line, c.raw)) continue; // a default value the consumer can override, not a JSX attribute at all — see isDestructuringOrParameterDefault
|
|
575
|
+
violations.push({ file: c.file, line: c.line, position: "user-facing-attribute", attribute, raw: c.raw });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---- position 3: everything else scan.ts kept as a real candidate —
|
|
579
|
+
// never silently treated as clean, never treated as a violation either
|
|
580
|
+
// — except a plain string shaped like a utility class LIST, which is
|
|
581
|
+
// definitively not prose (see looksLikeUtilityClassList).
|
|
582
|
+
for (const c of candidates) {
|
|
583
|
+
if (c.kind === "jsx-text") continue; // already position 1
|
|
584
|
+
if (c.kind === "string" && claimed.has(`${c.line}::${c.raw}`)) continue; // already position 2
|
|
585
|
+
if (c.kind === "string" && looksLikeUtilityClassList(c.raw)) continue; // a class-name list, not prose
|
|
586
|
+
unchecked.push({
|
|
587
|
+
file: c.file,
|
|
588
|
+
line: c.line,
|
|
589
|
+
kind: c.kind === "template" ? "template-literal" : "unclassified-string-position",
|
|
590
|
+
detail:
|
|
591
|
+
c.kind === "template"
|
|
592
|
+
? `template literal ${c.raw} could carry an interpolated copy id, a partial sentence, or genuine inline prose — this gate does not attempt to tell those apart`
|
|
593
|
+
: `string ${c.raw} is not a JSX markup text node or one of aria-label/placeholder/alt/title — could be an object/array literal value or some other prop this gate does not classify`,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return { violations, unchecked };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ------------------------------------------------------------------- gate
|
|
601
|
+
|
|
602
|
+
export type AddressabilityVerdict = "satisfied" | "violated" | "indeterminate";
|
|
603
|
+
|
|
604
|
+
export interface AddressabilityGateResult {
|
|
605
|
+
verdict: AddressabilityVerdict;
|
|
606
|
+
violations: AddressabilityViolation[];
|
|
607
|
+
unchecked: AddressabilityUncheckedItem[];
|
|
608
|
+
filesScanned: number;
|
|
609
|
+
/**
|
|
610
|
+
* Human-readable reasons coverage is incomplete — empty for `"satisfied"`.
|
|
611
|
+
* Since #407, this can be non-empty for `"violated"` too: a run can find a
|
|
612
|
+
* definite violation AND have unclassified positions left over in the
|
|
613
|
+
* same scan. Only `"indeterminate"` requires `reasons.length > 0`; a
|
|
614
|
+
* `"violated"` verdict does not promise `reasons` is empty, only that at
|
|
615
|
+
* least one real finding exists regardless of what else `reasons` lists.
|
|
616
|
+
*/
|
|
617
|
+
reasons: string[];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Pure — takes an already-computed `AddressabilityScanResult`, decides the
|
|
622
|
+
* ternary verdict. Mirrors `copy-gate.ts`'s `checkCopyTraceability` split
|
|
623
|
+
* from `scan.ts`: this function does no I/O and never throws.
|
|
624
|
+
*/
|
|
625
|
+
export function checkAddressability(scan: AddressabilityScanResult): AddressabilityGateResult {
|
|
626
|
+
const reasons: string[] = [];
|
|
627
|
+
|
|
628
|
+
if (scan.filesScanned === 0) {
|
|
629
|
+
reasons.push(
|
|
630
|
+
scan.parseFailures.length > 0
|
|
631
|
+
? "every matched file failed to parse — nothing was actually scanned"
|
|
632
|
+
: "no components were scanned",
|
|
633
|
+
);
|
|
634
|
+
} else if (scan.parseFailures.length > 0) {
|
|
635
|
+
reasons.push(`${scan.parseFailures.length} matched file(s) could not be parsed and were never examined`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const invalidPathExclusions = scan.pathExclusionFindings.filter((f) => f.severity === "error");
|
|
639
|
+
if (invalidPathExclusions.length > 0) {
|
|
640
|
+
reasons.push(
|
|
641
|
+
`${invalidPathExclusions.length} pathExclusions entr${invalidPathExclusions.length === 1 ? "y is" : "ies are"} invalid`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (scan.unchecked.length > 0) {
|
|
646
|
+
reasons.push(`${scan.unchecked.length} string position(s) could not be confidently classified`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const base = { violations: scan.violations, unchecked: scan.unchecked, filesScanned: scan.filesScanned };
|
|
650
|
+
|
|
651
|
+
// A real finding wins over an incomplete picture — see this file's top
|
|
652
|
+
// doc comment, "THE TERNARY", and issue #407. Deliberately the OPPOSITE
|
|
653
|
+
// of copy-gate.ts's/scan.ts's "a 2 gates before findings are counted"
|
|
654
|
+
// precedence: on THIS gate, partial coverage (category 3's unclassified
|
|
655
|
+
// positions) is the PERMANENT state of any real tree, so letting it
|
|
656
|
+
// outrank a violation makes "violated" unreachable outside a fixture.
|
|
657
|
+
// The coverage gap is not lost by returning "violated" here — `reasons`
|
|
658
|
+
// is still populated below and still printed unconditionally by
|
|
659
|
+
// `cli.ts`'s `printAddressabilityAccounting`; only the verdict/exit-code
|
|
660
|
+
// changes.
|
|
661
|
+
if (scan.violations.length > 0) return { verdict: "violated", reasons, ...base };
|
|
662
|
+
if (reasons.length > 0) return { verdict: "indeterminate", reasons, ...base };
|
|
663
|
+
return { verdict: "satisfied", reasons, ...base };
|
|
664
|
+
}
|