@epilot/volt-ui-experimental-griffel 0.2.3 → 0.3.0

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 (169) hide show
  1. package/ADOPTING.md +416 -0
  2. package/README.md +32 -31
  3. package/dist/_virtual/jsx-dev-runtime.js +5 -0
  4. package/dist/_virtual/jsx-dev-runtime.js.map +1 -0
  5. package/dist/_virtual/react-jsx-dev-runtime.development.js +5 -0
  6. package/dist/_virtual/react-jsx-dev-runtime.development.js.map +1 -0
  7. package/dist/_virtual/react-jsx-dev-runtime.production.js +5 -0
  8. package/dist/_virtual/react-jsx-dev-runtime.production.js.map +1 -0
  9. package/dist/components/accordion/accordion.js +126 -66
  10. package/dist/components/accordion/accordion.js.map +1 -1
  11. package/dist/components/alert-dialog/alert-dialog.js +239 -137
  12. package/dist/components/alert-dialog/alert-dialog.js.map +1 -1
  13. package/dist/components/avatar/avatar-group.js +102 -47
  14. package/dist/components/avatar/avatar-group.js.map +1 -1
  15. package/dist/components/avatar/avatar.js +97 -73
  16. package/dist/components/avatar/avatar.js.map +1 -1
  17. package/dist/components/badge/badge.js +53 -37
  18. package/dist/components/badge/badge.js.map +1 -1
  19. package/dist/components/breadcrumb/breadcrumb.js +97 -49
  20. package/dist/components/breadcrumb/breadcrumb.js.map +1 -1
  21. package/dist/components/button/button.js +88 -77
  22. package/dist/components/button/button.js.map +1 -1
  23. package/dist/components/button-group/button-group.js +55 -31
  24. package/dist/components/button-group/button-group.js.map +1 -1
  25. package/dist/components/callout/callout.js +288 -164
  26. package/dist/components/callout/callout.js.map +1 -1
  27. package/dist/components/card/card.js +137 -81
  28. package/dist/components/card/card.js.map +1 -1
  29. package/dist/components/checkbox/checkbox.js +100 -49
  30. package/dist/components/checkbox/checkbox.js.map +1 -1
  31. package/dist/components/collapsible-sidebar/collapsible-sidebar.js +155 -107
  32. package/dist/components/collapsible-sidebar/collapsible-sidebar.js.map +1 -1
  33. package/dist/components/command/command.js +297 -158
  34. package/dist/components/command/command.js.map +1 -1
  35. package/dist/components/data-table/data-table-body.js +114 -54
  36. package/dist/components/data-table/data-table-body.js.map +1 -1
  37. package/dist/components/data-table/data-table-column-header.js +117 -45
  38. package/dist/components/data-table/data-table-column-header.js.map +1 -1
  39. package/dist/components/data-table/data-table-column-visibility.js +98 -39
  40. package/dist/components/data-table/data-table-column-visibility.js.map +1 -1
  41. package/dist/components/data-table/data-table-content.js +90 -58
  42. package/dist/components/data-table/data-table-content.js.map +1 -1
  43. package/dist/components/data-table/data-table-error.js +83 -39
  44. package/dist/components/data-table/data-table-error.js.map +1 -1
  45. package/dist/components/data-table/data-table-header.js +88 -54
  46. package/dist/components/data-table/data-table-header.js.map +1 -1
  47. package/dist/components/data-table/data-table-loading.js +27 -15
  48. package/dist/components/data-table/data-table-loading.js.map +1 -1
  49. package/dist/components/data-table/data-table-pagination.js +27 -19
  50. package/dist/components/data-table/data-table-pagination.js.map +1 -1
  51. package/dist/components/data-table/data-table-row.js +87 -64
  52. package/dist/components/data-table/data-table-row.js.map +1 -1
  53. package/dist/components/data-table/data-table-toolbar.js +24 -16
  54. package/dist/components/data-table/data-table-toolbar.js.map +1 -1
  55. package/dist/components/data-table/data-table.js +182 -145
  56. package/dist/components/data-table/data-table.js.map +1 -1
  57. package/dist/components/date-range-picker/date-range-picker.js +342 -214
  58. package/dist/components/date-range-picker/date-range-picker.js.map +1 -1
  59. package/dist/components/date-range-picker/range-calendar.js +132 -92
  60. package/dist/components/date-range-picker/range-calendar.js.map +1 -1
  61. package/dist/components/date-time-picker/calendar.js +501 -317
  62. package/dist/components/date-time-picker/calendar.js.map +1 -1
  63. package/dist/components/date-time-picker/date-time-picker-context.js +64 -60
  64. package/dist/components/date-time-picker/date-time-picker-context.js.map +1 -1
  65. package/dist/components/date-time-picker/date-time-picker-input.js +249 -149
  66. package/dist/components/date-time-picker/date-time-picker-input.js.map +1 -1
  67. package/dist/components/date-time-picker/date-time-picker-panel.js +273 -165
  68. package/dist/components/date-time-picker/date-time-picker-panel.js.map +1 -1
  69. package/dist/components/date-time-picker/date-time-picker-popover.js +22 -14
  70. package/dist/components/date-time-picker/date-time-picker-popover.js.map +1 -1
  71. package/dist/components/date-time-picker/date-time-picker.js +134 -90
  72. package/dist/components/date-time-picker/date-time-picker.js.map +1 -1
  73. package/dist/components/date-time-picker/time-picker.js +198 -132
  74. package/dist/components/date-time-picker/time-picker.js.map +1 -1
  75. package/dist/components/density-provider/density-provider.js +48 -0
  76. package/dist/components/density-provider/density-provider.js.map +1 -0
  77. package/dist/components/dialog/dialog.js +280 -150
  78. package/dist/components/dialog/dialog.js.map +1 -1
  79. package/dist/components/drawer/drawer.js +273 -163
  80. package/dist/components/drawer/drawer.js.map +1 -1
  81. package/dist/components/dropdown-menu/dropdown-menu.js +378 -195
  82. package/dist/components/dropdown-menu/dropdown-menu.js.map +1 -1
  83. package/dist/components/field/field-combobox.js +447 -240
  84. package/dist/components/field/field-combobox.js.map +1 -1
  85. package/dist/components/field/field-select.js +23 -19
  86. package/dist/components/field/field-select.js.map +1 -1
  87. package/dist/components/field/field.js +318 -209
  88. package/dist/components/field/field.js.map +1 -1
  89. package/dist/components/label/label.js +27 -19
  90. package/dist/components/label/label.js.map +1 -1
  91. package/dist/components/list/list.js +623 -369
  92. package/dist/components/list/list.js.map +1 -1
  93. package/dist/components/option-card/option-card.js +323 -159
  94. package/dist/components/option-card/option-card.js.map +1 -1
  95. package/dist/components/pill/pill.js +82 -64
  96. package/dist/components/pill/pill.js.map +1 -1
  97. package/dist/components/popover/popover.js +101 -56
  98. package/dist/components/popover/popover.js.map +1 -1
  99. package/dist/components/radio/radio.js +94 -51
  100. package/dist/components/radio/radio.js.map +1 -1
  101. package/dist/components/scroll-overflow/scroll-overflow.js +350 -198
  102. package/dist/components/scroll-overflow/scroll-overflow.js.map +1 -1
  103. package/dist/components/segmented-control/segmented-control.js +141 -97
  104. package/dist/components/segmented-control/segmented-control.js.map +1 -1
  105. package/dist/components/select/select.js +452 -227
  106. package/dist/components/select/select.js.map +1 -1
  107. package/dist/components/separator/separator.js +24 -16
  108. package/dist/components/separator/separator.js.map +1 -1
  109. package/dist/components/skeleton/skeleton.js +83 -43
  110. package/dist/components/skeleton/skeleton.js.map +1 -1
  111. package/dist/components/spinner/spinner.js +44 -20
  112. package/dist/components/spinner/spinner.js.map +1 -1
  113. package/dist/components/switch/switch.js +163 -86
  114. package/dist/components/switch/switch.js.map +1 -1
  115. package/dist/components/table/table-pagination.js +465 -205
  116. package/dist/components/table/table-pagination.js.map +1 -1
  117. package/dist/components/table/table.js +212 -144
  118. package/dist/components/table/table.js.map +1 -1
  119. package/dist/components/tabs/tabs.js +94 -58
  120. package/dist/components/tabs/tabs.js.map +1 -1
  121. package/dist/components/text/text.js +40 -32
  122. package/dist/components/text/text.js.map +1 -1
  123. package/dist/components/toast/toast.js +187 -111
  124. package/dist/components/toast/toast.js.map +1 -1
  125. package/dist/components/toggle/toggle-group.js +40 -24
  126. package/dist/components/toggle/toggle-group.js.map +1 -1
  127. package/dist/components/toggle/toggle.js +48 -40
  128. package/dist/components/toggle/toggle.js.map +1 -1
  129. package/dist/components/tooltip/tooltip.js +101 -55
  130. package/dist/components/tooltip/tooltip.js.map +1 -1
  131. package/dist/index.d.ts +600 -427
  132. package/dist/index.js +215 -212
  133. package/dist/index.js.map +1 -1
  134. package/dist/lib/clear-button.js +27 -15
  135. package/dist/lib/clear-button.js.map +1 -1
  136. package/dist/lib/icons.js +18 -6
  137. package/dist/lib/icons.js.map +1 -1
  138. package/dist/packages/volt-runtime/dist/sheet.js +27 -10
  139. package/dist/packages/volt-runtime/dist/sheet.js.map +1 -1
  140. package/dist/packages/volt-runtime/dist/styles/recipe.js +76 -0
  141. package/dist/packages/volt-runtime/dist/styles/recipe.js.map +1 -0
  142. package/dist/styles/document-context.js +12 -0
  143. package/dist/styles/document-context.js.map +1 -0
  144. package/dist/styles/make-styles.js +18 -10
  145. package/dist/styles/make-styles.js.map +1 -1
  146. package/dist/tokens/core.generated.js +1238 -1166
  147. package/dist/tokens/core.generated.js.map +1 -1
  148. package/dist/tokens/index.d.ts +442 -415
  149. package/dist/tokens/index.js +1105 -1078
  150. package/dist/tokens/index.js.map +1 -1
  151. package/dist/tokens/install.js +9 -5
  152. package/dist/tokens/install.js.map +1 -1
  153. package/dist/tokens/use-palette-tokens.js +20 -0
  154. package/dist/tokens/use-palette-tokens.js.map +1 -0
  155. package/dist/tokens.json +83 -2
  156. package/dist/vendor/react/cjs/react-jsx-dev-runtime.development.js +236 -0
  157. package/dist/vendor/react/cjs/react-jsx-dev-runtime.development.js.map +1 -0
  158. package/dist/vendor/react/cjs/react-jsx-dev-runtime.production.js +21 -0
  159. package/dist/vendor/react/cjs/react-jsx-dev-runtime.production.js.map +1 -0
  160. package/dist/vendor/react/jsx-dev-runtime.js +9 -0
  161. package/dist/vendor/react/jsx-dev-runtime.js.map +1 -0
  162. package/package.json +13 -5
  163. package/scripts/migration-diagnostics-core.mjs +859 -0
  164. package/scripts/migration-diagnostics.mjs +353 -0
  165. package/scripts/migration-sweep.mjs +179 -0
  166. package/AGENTS.md +0 -153
  167. package/AUTHORING.md +0 -292
  168. package/dist/packages/volt-runtime/dist/styles/make-styles.js +0 -22
  169. package/dist/packages/volt-runtime/dist/styles/make-styles.js.map +0 -1
@@ -0,0 +1,859 @@
1
+ /**
2
+ * Pure, fs-free migration-diagnostics rule core (WP6B-2 extraction — see
3
+ * WP6B-DECISION-CORE-EXTRACTION.md). Every rule in this file is a straight-line function of its
4
+ * arguments: no `node:fs`/`node:path`/`node:module`/`node:process`/`node:child_process`/network
5
+ * access, no dynamic `import()`, no `eval`/`new Function`, no ambient DOM/global mutation, no
6
+ * module-load side effects. TypeScript is INJECTED by the caller (a `ts` parameter) — this module
7
+ * never imports, requires, or resolves `typescript` itself. `identity` (package name/version/
8
+ * generatedInputHash and the `core`/`palette` token-family `Set`s) is likewise always supplied by
9
+ * the caller; this module never reads a `dist/tokens/*` file or a `package.json` to derive it.
10
+ *
11
+ * `migration-diagnostics.mjs` (the sibling fs-bound orchestration module — file/directory walking,
12
+ * symlink boundary checks, hashing, `resolvePackageIdentity`, `resolveConsumerTypeScript`) is the
13
+ * CLI's entrypoint and delegates every file it reads to this module's `analyzeSource`. A future
14
+ * remote MCP tool would call `analyzeSource` directly with a caller-submitted string and a
15
+ * build-time-embedded identity — no path ever reaches this file. This module is not itself a
16
+ * "complete scan" API: it analyzes exactly the one string it is given, and callers remain
17
+ * responsible for their own completeness/incompleteness accounting (e.g. the CLI's
18
+ * `analysisIncomplete`/`toolingWarning` contract when TypeScript could not be resolved at all).
19
+ *
20
+ * Three rules, deliberately bounded to what survived every other gate on the four pilot
21
+ * migrations (see the outer module's original header for the incident history):
22
+ *
23
+ * legacy-volt-class — a v1-only `volt-*` utility class left in a className string.
24
+ * hand-written-volt-css-var — a raw `var(--volt-*)` (v1) or a hand-typed, hash-inlined
25
+ * `var(--volt2-<hash>-*)` (v2) custom property, instead of
26
+ * importing the token.
27
+ * unestablished-palette-install — a decorative (non-core) token family is used, but no
28
+ * correctly-scoped install call was found for it.
29
+ *
30
+ * `unestablished-palette-install` uses TypeScript's parser (syntax only — no type checker, no
31
+ * cross-module resolution) to do real lexical binding resolution: a name only counts as "the
32
+ * installer" or "a decorative token" if it resolves, through actual scope/shadowing, to an import
33
+ * from this package's own public specifiers, is not a type-only import, and is not shadowed by a
34
+ * closer local declaration. Certification is evaluated per DECORATIVE USAGE (not merely its
35
+ * enclosing scope), scoped to that usage's own consuming function plus at most one hop into a
36
+ * locally-declared helper the consuming scope calls directly, unconditionally, and BEFORE the
37
+ * usage in document order — never "this name is exported/called somewhere in the file".
38
+ *
39
+ * Supported analysis boundary: single-source, syntax-aware static analysis. It can prove a call
40
+ * exists, is unconditional relative to its own enclosing function, is reachable (not dead code
41
+ * after an unconditional return/throw), and precedes a given usage in straight-line document
42
+ * order. It cannot prove a *conditional* or *deferred* (callback/event) call's path always runs
43
+ * before the usage renders, and it cannot trace an install performed in a genuinely different
44
+ * source (e.g. a shared bootstrap/provider component) or through a relative (non-public-specifier)
45
+ * import — both get an explicit warning/uncertain finding, never a silent pass or a hard "error"
46
+ * (this rule never asserts a definite defect — see `RULES` below).
47
+ */
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Rule catalogue (also the "rule-support matrix" source of truth)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export const RULES = [
54
+ {
55
+ code: "legacy-volt-class",
56
+ title: "Legacy v1 volt-* class usage",
57
+ defaultSeverity: "error",
58
+ requiresTypeScript: false,
59
+ description:
60
+ "A v1-only Tailwind utility class (volt-*) is referenced in a className string. v2 ships no such class, and the singleton stylesheet is what is quietly still supplying it.",
61
+ },
62
+ {
63
+ code: "hand-written-volt-css-var",
64
+ title: "Hand-written legacy or hashed Volt CSS custom property",
65
+ defaultSeverity: "error",
66
+ requiresTypeScript: false,
67
+ description:
68
+ 'A raw var(--volt-*) (v1, unpublished by v2) or a hand-typed var(--volt2-<hash>-*) (v2, with the generated salt/hash inlined) custom property is referenced directly instead of importing the token — see AGENTS.md: "never hardcode a namespace or salt hash."',
69
+ },
70
+ {
71
+ code: "unestablished-palette-install",
72
+ title: "Decorative token used without an established palette install",
73
+ defaultSeverity: "warning (always — see below)",
74
+ requiresTypeScript: true,
75
+ description:
76
+ "A decorative (non-core) token family is used, with varying confidence that a correctly-scoped install call covers it (high confidence for no local evidence at all, down to low for an unresolved dynamic path). This rule NEVER reports severity \"error\": local absence of an install call does not prove a bootstrap/provider install does not exist elsewhere — this is single-source analysis with no cross-file tracing, so it cannot assert a definite defect, only flag what needs verification. Core families (accent/gray/error/warning/success/info) are excluded — they load by default. Requires an injected TypeScript module (see requiresTypeScript).",
77
+ },
78
+ ]
79
+
80
+ /** ts/tsx/js/jsx — the extensions `unestablished-palette-install` can parse. Exported so the
81
+ * fs-bound outer module can reuse the exact same rule (e.g. for its own `hasScriptFiles` check)
82
+ * without redefining it. */
83
+ export const SCRIPT_EXT_RE = /\.(tsx?|jsx?)$/
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Diagnostic shape
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** Exported (not just internal) so the fs-bound outer module can build a finding for its own
90
+ * fs-level failures (e.g. "could not read this file") in the exact same shape, without a second,
91
+ * possibly-drifting definition of what a finding looks like. */
92
+ export function makeFinding(identity, { code, severity, confidence, file, line, column, evidence, explanation, suggestion }) {
93
+ return {
94
+ code,
95
+ severity,
96
+ confidence,
97
+ file,
98
+ line,
99
+ column,
100
+ package: { name: identity.packageName, version: identity.packageVersion, generatedInputHash: identity.generatedInputHash },
101
+ evidence,
102
+ explanation,
103
+ suggestion,
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Rule 1 + 2: textual, comment-stripped scan (no TypeScript needed)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** `volt-` inside a quoted string, skipping the package name itself. */
112
+ const CLASS_RE =
113
+ /(['"`])((?:(?!\1)[^\\]|\\.)*?\bvolt-[a-z][a-z0-9-]*(?:(?!\1)[^\\]|\\.)*?)\1/g
114
+ const VAR_RE_V1 = /var\(\s*--volt-[a-z0-9-]+/g
115
+ /** A hand-typed v2 hashed custom property — same salt-shaped pattern the family reader uses. */
116
+ const VAR_RE_V2_HASH = /var\(\s*--volt2-[a-z0-9]+-[a-z]+-[a-z0-9-]*/g
117
+
118
+ const stripComments = (s) =>
119
+ s
120
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
121
+ .replace(
122
+ /(^|[^:'"`\\])\/\/[^\n]*/g,
123
+ (m, p) => p + " ".repeat(m.length - p.length)
124
+ )
125
+
126
+ /** Module specifiers are paths, not class lists — `./volt-token-bridge` is a filename. */
127
+ const isModulePath = (src, idx) =>
128
+ /\b(?:import|require|from)\s*\(?\s*$/.test(src.slice(Math.max(0, idx - 40), idx))
129
+
130
+ function textualFindings(identity, fileLabel, raw) {
131
+ const findings = []
132
+ const stripped = stripComments(raw)
133
+ const lineOf = (idx) => stripped.slice(0, idx).split("\n").length
134
+ const colOf = (idx) => idx - stripped.lastIndexOf("\n", idx - 1)
135
+
136
+ for (const m of stripped.matchAll(CLASS_RE)) {
137
+ if (isModulePath(stripped, m.index)) continue
138
+ const cls = m[2].match(/\bvolt-[a-z][a-z0-9-]*/g) ?? []
139
+ const orphans = cls.filter((c) => !c.startsWith("volt-ui"))
140
+ if (orphans.length) {
141
+ findings.push(
142
+ makeFinding(identity, {
143
+ code: "legacy-volt-class",
144
+ severity: "error",
145
+ confidence: "high",
146
+ file: fileLabel,
147
+ line: lineOf(m.index),
148
+ column: colOf(m.index),
149
+ evidence: orphans.join(" "),
150
+ explanation:
151
+ "This v1-only utility class has no v2 equivalent and disappears once the v1 singleton stops loading its stylesheet. Note: this rule matches any quoted string shaped like a volt-* class, not only className attribute values — an unrelated string literal with the same shape can also match (see the WP6A report's documented limitation).",
152
+ suggestion:
153
+ "Replace with the equivalent Griffel component prop, or a makeVoltStyles() rule using a canonical token.",
154
+ })
155
+ )
156
+ }
157
+ }
158
+
159
+ for (const m of stripped.matchAll(VAR_RE_V1)) {
160
+ findings.push(
161
+ makeFinding(identity, {
162
+ code: "hand-written-volt-css-var",
163
+ severity: "error",
164
+ confidence: "high",
165
+ file: fileLabel,
166
+ line: lineOf(m.index),
167
+ column: colOf(m.index),
168
+ evidence: `${m[0]})`,
169
+ explanation:
170
+ "v2 publishes no such custom property; this resolves today only because the v1 singleton's stylesheet is still loaded at the portal root.",
171
+ suggestion: "Import the equivalent token from the package's /tokens subpath instead of the raw CSS custom property.",
172
+ })
173
+ )
174
+ }
175
+
176
+ for (const m of stripped.matchAll(VAR_RE_V2_HASH)) {
177
+ findings.push(
178
+ makeFinding(identity, {
179
+ code: "hand-written-volt-css-var",
180
+ severity: "error",
181
+ confidence: "high",
182
+ file: fileLabel,
183
+ line: lineOf(m.index),
184
+ column: colOf(m.index),
185
+ evidence: `${m[0]})`,
186
+ explanation:
187
+ "The generated salt/hash is not a stable contract — it changes across Griffel engine or token-generation version bumps.",
188
+ suggestion:
189
+ "Import the token by name from the package's /tokens subpath and let makeVoltStyles() resolve the current hash; never hardcode a namespace or salt hash.",
190
+ })
191
+ )
192
+ }
193
+
194
+ return findings
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Rule 3: syntax-aware (TypeScript AST) palette-install analysis
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function scriptKindFor(ts, fileLabel) {
202
+ if (fileLabel.endsWith(".tsx")) return ts.ScriptKind.TSX
203
+ if (fileLabel.endsWith(".jsx")) return ts.ScriptKind.JSX
204
+ if (fileLabel.endsWith(".ts")) return ts.ScriptKind.TS
205
+ return ts.ScriptKind.JS
206
+ }
207
+
208
+ const isPaletteBindingName = (identity, name) => {
209
+ const fam = name.match(/^([a-z]+)(?:A?\d+|Contrast|Surface|Indicator|Track)$/)?.[1]
210
+ return Boolean(fam && identity.palette.has(fam))
211
+ }
212
+
213
+ /** The decorative family a token identifier belongs to (e.g. "blue11" -> "blue"), for suggestion
214
+ * text — never the specific token accessor itself, which is not the argument ensurePaletteTokensFor
215
+ * documents. */
216
+ const familyOf = (identity, tokenName) => {
217
+ const fam = tokenName.match(/^([a-z]+)(?:A?\d+|Contrast|Surface|Indicator|Track)$/)?.[1]
218
+ return fam && identity.palette.has(fam) ? fam : tokenName
219
+ }
220
+
221
+ /** Real lexical scope/shadow resolution and same-scope call collection, bound to one `ts` module. */
222
+ function createScopeHelpers(ts) {
223
+ /** Recursively checks a binding NAME (a plain identifier, or an object/array destructuring
224
+ * pattern — parameters, `const`/`let`/`var`, catch, and for-loop bindings can all destructure)
225
+ * for whether it declares `name` anywhere within it — `{ ensurePaletteTokens }`,
226
+ * `{ ensurePaletteTokens: x }` (bound as `x`, not shadowing), `[a, { b }]`, etc. */
227
+ function bindingNameDeclares(bindingName, name) {
228
+ if (ts.isIdentifier(bindingName)) return bindingName.text === name
229
+ if (ts.isObjectBindingPattern(bindingName) || ts.isArrayBindingPattern(bindingName)) {
230
+ return bindingName.elements.some((el) => {
231
+ if (ts.isOmittedExpression(el)) return false
232
+ return bindingNameDeclares(el.name, name)
233
+ })
234
+ }
235
+ return false
236
+ }
237
+
238
+ function declarationDeclares(stmt, name) {
239
+ if (ts.isVariableStatement(stmt)) {
240
+ return stmt.declarationList.declarations.some((d) => bindingNameDeclares(d.name, name))
241
+ }
242
+ if (ts.isFunctionDeclaration(stmt)) return stmt.name?.text === name
243
+ if (ts.isClassDeclaration(stmt)) return stmt.name?.text === name
244
+ return false
245
+ }
246
+
247
+ /** True if `name`, referenced at `identifierNode`, is bound by something OTHER than a top-level
248
+ * (SourceFile-scope) import before the walk reaches the SourceFile — a parameter (including a
249
+ * destructured one), a local const/let/var (including destructured), a nested function/class
250
+ * declaration, a catch binding, or a for-loop variable. */
251
+ function isShadowedBeforeSourceFile(identifierNode, name) {
252
+ let cur = identifierNode.parent
253
+ while (cur && !ts.isSourceFile(cur)) {
254
+ if (
255
+ ts.isFunctionDeclaration(cur) ||
256
+ ts.isFunctionExpression(cur) ||
257
+ ts.isArrowFunction(cur) ||
258
+ ts.isMethodDeclaration(cur)
259
+ ) {
260
+ if (cur.parameters.some((p) => bindingNameDeclares(p.name, name))) return true
261
+ if ((ts.isFunctionDeclaration(cur) || ts.isFunctionExpression(cur)) && cur.name?.text === name) return true
262
+ }
263
+ if (ts.isBlock(cur) && cur.statements.some((s) => declarationDeclares(s, name))) return true
264
+ if (
265
+ ts.isCatchClause(cur) &&
266
+ cur.variableDeclaration &&
267
+ bindingNameDeclares(cur.variableDeclaration.name, name)
268
+ ) {
269
+ return true
270
+ }
271
+ if (
272
+ (ts.isForStatement(cur) || ts.isForInStatement(cur) || ts.isForOfStatement(cur)) &&
273
+ cur.initializer &&
274
+ ts.isVariableDeclarationList(cur.initializer) &&
275
+ cur.initializer.declarations.some((d) => bindingNameDeclares(d.name, name))
276
+ ) {
277
+ return true
278
+ }
279
+ cur = cur.parent
280
+ }
281
+ return false
282
+ }
283
+
284
+ /** True if `node` sits under any branch that can fail to execute, up to (not past) `boundary`. */
285
+ function isWithinConditional(node, boundary) {
286
+ let cur = node.parent
287
+ while (cur && cur !== boundary) {
288
+ if (
289
+ ts.isIfStatement(cur) ||
290
+ ts.isConditionalExpression(cur) ||
291
+ ts.isSwitchStatement(cur) ||
292
+ ts.isCaseClause(cur) ||
293
+ ts.isForStatement(cur) ||
294
+ ts.isForInStatement(cur) ||
295
+ ts.isForOfStatement(cur) ||
296
+ ts.isWhileStatement(cur) ||
297
+ ts.isDoStatement(cur) ||
298
+ ts.isCatchClause(cur) ||
299
+ (ts.isBinaryExpression(cur) &&
300
+ [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(
301
+ cur.operatorToken.kind
302
+ ))
303
+ ) {
304
+ return true
305
+ }
306
+ cur = cur.parent
307
+ }
308
+ return false
309
+ }
310
+
311
+ /** The nearest enclosing function-like node, or the SourceFile itself for a module-scope node. */
312
+ function enclosingFunctionOrFile(node, sf) {
313
+ let cur = node.parent
314
+ while (cur) {
315
+ if (
316
+ ts.isFunctionDeclaration(cur) ||
317
+ ts.isFunctionExpression(cur) ||
318
+ ts.isArrowFunction(cur) ||
319
+ ts.isMethodDeclaration(cur)
320
+ ) {
321
+ return cur
322
+ }
323
+ cur = cur.parent
324
+ }
325
+ return sf
326
+ }
327
+
328
+ function functionName(fn, sf) {
329
+ if (fn === sf) return null
330
+ if (ts.isFunctionDeclaration(fn) && fn.name) return fn.name.text
331
+ if (fn.parent && ts.isVariableDeclaration(fn.parent) && ts.isIdentifier(fn.parent.name)) return fn.parent.name.text
332
+ if (ts.isMethodDeclaration(fn) && ts.isIdentifier(fn.name)) return fn.name.text
333
+ return null
334
+ }
335
+
336
+ /** Nodes matching `matcher` that are direct descendants of `scope`'s own body — NOT nested
337
+ * inside a further function-like declared within `scope` (so a callback/handler's contents are
338
+ * excluded; see collectNested for the complement). */
339
+ function collectDirect(scope, matcher) {
340
+ const results = []
341
+ const body = ts.isSourceFile(scope) ? scope : scope.body
342
+ if (!body) return results
343
+ const visit = (node) => {
344
+ if (matcher(node)) results.push(node)
345
+ if (
346
+ node !== body &&
347
+ (ts.isFunctionDeclaration(node) ||
348
+ ts.isFunctionExpression(node) ||
349
+ ts.isArrowFunction(node) ||
350
+ ts.isMethodDeclaration(node))
351
+ ) {
352
+ return // stop: do not descend into a NESTED function's own body from here
353
+ }
354
+ ts.forEachChild(node, visit)
355
+ }
356
+ ts.forEachChild(body, visit)
357
+ return results
358
+ }
359
+
360
+ /** The complement of collectDirect: matches nested inside a function-like descendant of
361
+ * `scope` — a callback/event-handler/deferred position relative to `scope`. */
362
+ function collectNested(scope, matcher) {
363
+ const results = []
364
+ const body = ts.isSourceFile(scope) ? scope : scope.body
365
+ if (!body) return results
366
+ const visitInside = (node) => {
367
+ if (matcher(node)) results.push(node)
368
+ ts.forEachChild(node, visitInside)
369
+ }
370
+ const visitTop = (node) => {
371
+ if (
372
+ ts.isFunctionDeclaration(node) ||
373
+ ts.isFunctionExpression(node) ||
374
+ ts.isArrowFunction(node) ||
375
+ ts.isMethodDeclaration(node)
376
+ ) {
377
+ ts.forEachChild(node, visitInside)
378
+ return
379
+ }
380
+ ts.forEachChild(node, visitTop)
381
+ }
382
+ ts.forEachChild(body, visitTop)
383
+ return results
384
+ }
385
+
386
+ return {
387
+ isShadowedBeforeSourceFile,
388
+ isWithinConditional,
389
+ enclosingFunctionOrFile,
390
+ functionName,
391
+ collectDirect,
392
+ collectNested,
393
+ }
394
+ }
395
+
396
+ /** Collects trusted import bindings (installer, namespace, individual token names) — value-only:
397
+ * a `import type {...}` clause or a `import { type X }` specifier is never trusted as a runtime
398
+ * binding, since it cannot be called/read at runtime (and using it as if it could is itself a
399
+ * compiler error the consumer's own tsc will catch, not something to certify as evidence). */
400
+ function collectBindings(sf, ts, identity) {
401
+ const validSpecifiers = new Set([identity.packageName, `${identity.packageName}/tokens`])
402
+ // local (possibly aliased) name -> the ORIGINAL imported symbol ("ensurePaletteTokens" or
403
+ // "ensurePaletteTokensFor"). installDecorativeStatus must key off this canonical name, never the
404
+ // local spelling — `import { ensurePaletteTokens as install }` is a genuine, correct install.
405
+ const installerNames = new Map()
406
+ const namespaceNames = new Set()
407
+ const tokenBindings = new Map()
408
+
409
+ for (const stmt of sf.statements) {
410
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
411
+ if (!validSpecifiers.has(stmt.moduleSpecifier.text)) continue
412
+ const clause = stmt.importClause
413
+ if (!clause || clause.isTypeOnly || !clause.namedBindings) continue
414
+ if (ts.isNamespaceImport(clause.namedBindings)) {
415
+ namespaceNames.add(clause.namedBindings.name.text)
416
+ } else if (ts.isNamedImports(clause.namedBindings)) {
417
+ for (const el of clause.namedBindings.elements) {
418
+ if (el.isTypeOnly) continue
419
+ const imported = (el.propertyName ?? el.name).text
420
+ const local = el.name.text
421
+ if (imported === "ensurePaletteTokens" || imported === "ensurePaletteTokensFor") {
422
+ installerNames.set(local, imported)
423
+ } else if (imported === "tokens") {
424
+ // `export * as tokens from "./tokens"` at the package root — a named import of this
425
+ // object is functionally a namespace import (`import { tokens } from packageName`, or
426
+ // `import { tokens as t } from packageName`).
427
+ namespaceNames.add(local)
428
+ } else if (isPaletteBindingName(identity, imported)) {
429
+ tokenBindings.set(local, imported)
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ return { installerNames, namespaceNames, tokenBindings }
436
+ }
437
+
438
+ /** Whether `call` installs the decorative palette sheet: "confirmed" (the bare no-arg
439
+ * `ensurePaletteTokens()` form, or an `ensurePaletteTokensFor(<literal>)` argument that is not a
440
+ * known core family — matching the REAL runtime contract in `tokens/install.ts`:
441
+ * `if (CORE_FAMILIES.has(family)) return false; return ensurePaletteTokens(doc)` — an
442
+ * unrecognized/unknown literal still reaches installation; only a KNOWN CORE family is rejected),
443
+ * "uncertain" (a non-literal/dynamic argument to `-For` — cannot be resolved statically), or
444
+ * "not-decorative": either a known core family literal argument (a real, provable runtime no-op
445
+ * per the contract above), OR a call to `ensurePaletteTokensFor` with NO argument at all — `family`
446
+ * is a required, non-optional parameter, so a genuinely zero-argument call is not a valid typed
447
+ * call in the first place (it would fail `tsc` with "Expected 1-2 arguments, but got 0"), not a
448
+ * proven runtime no-op the way the core-family case is. Classified "not-decorative" defensively
449
+ * (there is no valid evidence of an install here), never claimed as a confirmed no-op. ANY
450
+ * "confirmed" call installs the WHOLE decorative sheet, not just one family — this does not
451
+ * require matching the specific family a usage reads. Resolves the callee through `bindings` to
452
+ * its ORIGINAL imported symbol, never the local (possibly aliased) spelling. */
453
+ function installDecorativeStatus(call, ts, identity, bindings) {
454
+ const callee = call.expression
455
+ let canonicalName = null
456
+ if (ts.isIdentifier(callee)) {
457
+ canonicalName = bindings.installerNames.get(callee.text) ?? null
458
+ } else if (ts.isPropertyAccessExpression(callee)) {
459
+ // Property access names cannot be import-aliased — `tokens.ensurePaletteTokensFor` always
460
+ // uses the real member name.
461
+ canonicalName = callee.name.text
462
+ }
463
+ if (canonicalName === "ensurePaletteTokens") return "confirmed"
464
+ if (canonicalName === "ensurePaletteTokensFor") {
465
+ const arg = call.arguments[0]
466
+ if (!arg) return "not-decorative"
467
+ if (ts.isStringLiteralLike(arg)) {
468
+ return identity.core.has(arg.text) ? "not-decorative" : "confirmed"
469
+ }
470
+ return "uncertain"
471
+ }
472
+ return "not-decorative"
473
+ }
474
+
475
+ function isTrustedInstallerCall(call, ts, scope, bindings) {
476
+ const callee = call.expression
477
+ if (ts.isIdentifier(callee)) {
478
+ return bindings.installerNames.has(callee.text) && !scope.isShadowedBeforeSourceFile(callee, callee.text)
479
+ }
480
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
481
+ const objName = callee.expression.text
482
+ return (
483
+ bindings.namespaceNames.has(objName) &&
484
+ !scope.isShadowedBeforeSourceFile(callee.expression, objName) &&
485
+ (callee.name.text === "ensurePaletteTokens" || callee.name.text === "ensurePaletteTokensFor")
486
+ )
487
+ }
488
+ return false
489
+ }
490
+
491
+ /** Every decorative-token usage in the source: a direct trusted token identifier, `namespace.blue11`,
492
+ * or `namespace["blue11"]` (a literal computed member). A computed member with a non-literal key
493
+ * (`namespace[dynamicExpr]`) cannot be resolved to a family — recorded as `dynamic: true` so it
494
+ * gets an explicit uncertain finding rather than silently disappearing. */
495
+ function collectDecorativeUsages(sf, ts, scope, bindings, identity) {
496
+ const usages = []
497
+ const visit = (node) => {
498
+ if (ts.isIdentifier(node) && !ts.isImportSpecifier(node.parent) && bindings.tokenBindings.has(node.text)) {
499
+ if (!scope.isShadowedBeforeSourceFile(node, node.text)) {
500
+ usages.push({ node, family: bindings.tokenBindings.get(node.text), dynamic: false })
501
+ }
502
+ } else if (
503
+ ts.isPropertyAccessExpression(node) &&
504
+ ts.isIdentifier(node.expression) &&
505
+ bindings.namespaceNames.has(node.expression.text) &&
506
+ !scope.isShadowedBeforeSourceFile(node.expression, node.expression.text) &&
507
+ isPaletteBindingName(identity, node.name.text)
508
+ ) {
509
+ usages.push({ node, family: node.name.text, dynamic: false })
510
+ } else if (
511
+ ts.isElementAccessExpression(node) &&
512
+ ts.isIdentifier(node.expression) &&
513
+ bindings.namespaceNames.has(node.expression.text) &&
514
+ !scope.isShadowedBeforeSourceFile(node.expression, node.expression.text)
515
+ ) {
516
+ const arg = node.argumentExpression
517
+ if (arg && ts.isStringLiteralLike(arg)) {
518
+ if (isPaletteBindingName(identity, arg.text)) {
519
+ usages.push({ node, family: arg.text, dynamic: false })
520
+ }
521
+ } else if (arg) {
522
+ usages.push({ node, family: null, dynamic: true })
523
+ }
524
+ }
525
+ ts.forEachChild(node, visit)
526
+ }
527
+ visit(sf)
528
+ return usages
529
+ }
530
+
531
+ /**
532
+ * True if `node` is positioned after an unconditional `return`/`throw` STATEMENT in the same
533
+ * block, anywhere between `node` and `boundaryFn` — i.e. it is dead code that can never execute
534
+ * (bounded, single-block-level reachability; not a full CFG — a `return`/`throw` nested inside its
535
+ * own further conditional does not count, since it does not unconditionally exit ITS enclosing
536
+ * block).
537
+ */
538
+ function isDeadCode(node, boundaryFn, ts) {
539
+ let child = node
540
+ let cur = node.parent
541
+ while (cur && cur !== boundaryFn) {
542
+ if (ts.isBlock(cur) || ts.isSourceFile(cur)) {
543
+ const idx = cur.statements.indexOf(child)
544
+ if (idx > 0) {
545
+ for (let i = 0; i < idx; i++) {
546
+ if (ts.isReturnStatement(cur.statements[i]) || ts.isThrowStatement(cur.statements[i])) {
547
+ return true
548
+ }
549
+ }
550
+ }
551
+ }
552
+ child = cur
553
+ cur = cur.parent
554
+ }
555
+ return false
556
+ }
557
+
558
+ /** The direct child of `block.statements` that (transitively) contains `node`, or null if `node`
559
+ * is not actually within `block`. */
560
+ function directStatementContaining(block, node, ts) {
561
+ let cur = node
562
+ while (cur && cur.parent) {
563
+ if (cur.parent === block) return cur
564
+ cur = cur.parent
565
+ }
566
+ return null
567
+ }
568
+
569
+ /**
570
+ * Bounded, order-sensitive coverage check (straight-line document order — no loop/goto modelling):
571
+ * true if `call` is guaranteed to run before `usageNode` can be evaluated, within the same
572
+ * consuming scope `F`. This is what makes classification USAGE-sensitive rather than scope-wide —
573
+ * `isWithinConditional`/`isDeadCode` alone say a call is "unconditional in F", but that does not
574
+ * mean it precedes every usage in F: a guard clause can return an EARLIER usage before the call
575
+ * ever runs.
576
+ *
577
+ * Walks the block-ancestor chain of both `call` and `usageNode` up to `F`, finds their lowest
578
+ * shared block (outermost-first), and at the first level where they sit in DIFFERENT direct
579
+ * statements of that block, compares statement index — the call covers the usage only if its
580
+ * statement comes first. If they share the exact same direct statement at every checked level
581
+ * (e.g. both nested in the same `if` body), the deeper shared block is checked next. No shared
582
+ * block, or no resolvable statement, is conservatively "not covered".
583
+ */
584
+ function callCoversUsage(call, usageNode, F, ts) {
585
+ const blockChain = (node) => {
586
+ const blocks = []
587
+ let cur = node.parent
588
+ while (cur && cur !== F) {
589
+ if (ts.isBlock(cur) || ts.isSourceFile(cur)) blocks.push(cur)
590
+ cur = cur.parent
591
+ }
592
+ const fBody = ts.isSourceFile(F) ? F : F.body
593
+ if (fBody) blocks.push(fBody)
594
+ return blocks.reverse() // outermost first
595
+ }
596
+ const callBlocks = blockChain(call)
597
+ const usageBlocks = new Set(blockChain(usageNode))
598
+ const shared = callBlocks.filter((b) => usageBlocks.has(b))
599
+ if (shared.length === 0) return false
600
+
601
+ for (const block of shared) {
602
+ const callStmt = directStatementContaining(block, call, ts)
603
+ const usageStmt = directStatementContaining(block, usageNode, ts)
604
+ if (!callStmt || !usageStmt) return false
605
+ if (callStmt === usageStmt) continue // same statement at this level -- check the next, deeper shared block
606
+ const callIdx = block.statements.indexOf(callStmt)
607
+ const usageIdx = block.statements.indexOf(usageStmt)
608
+ if (callIdx === -1 || usageIdx === -1) return false
609
+ return callIdx < usageIdx
610
+ }
611
+ return false
612
+ }
613
+
614
+ /**
615
+ * Classifies one decorative USAGE (not merely its enclosing scope — see `callCoversUsage`):
616
+ * "confirmed" if a reachable, unconditional, correctly-scoped install call precedes it; "uncertain"
617
+ * with a `reason` if the best (or only) evidence is conditional, dead/unreachable, deferred into a
618
+ * callback, dynamic, positioned after the usage, or reached through an ambiguously-named or
619
+ * conditionally-called helper; "absent" if no relevant candidate exists at all. The caller maps
620
+ * ALL non-"confirmed" tiers to severity "warning" — this function's tiers only drive relative
621
+ * confidence and the reported reason, never an "error".
622
+ */
623
+ function classifyScope(F, sf, ts, scope, bindings, identity, usageNode) {
624
+ const isInstaller = (n) => ts.isCallExpression(n) && isTrustedInstallerCall(n, ts, scope, bindings)
625
+ const relevant = (call) => installDecorativeStatus(call, ts, identity, bindings) !== "not-decorative"
626
+ const reachable = (call) => !isDeadCode(call, F, ts)
627
+ const precedesUsage = (call) => callCoversUsage(call, usageNode, F, ts)
628
+
629
+ const direct = scope.collectDirect(F, isInstaller).filter(relevant).filter(reachable)
630
+ for (const call of direct) {
631
+ if (
632
+ installDecorativeStatus(call, ts, identity, bindings) === "confirmed" &&
633
+ !scope.isWithinConditional(call, F) &&
634
+ precedesUsage(call)
635
+ ) {
636
+ return { tier: "confirmed" }
637
+ }
638
+ }
639
+
640
+ // One hop into a locally-declared helper the consuming scope calls directly (not nested in a
641
+ // further callback) — conditionally called helpers, ambiguously-named helpers (more than one
642
+ // local declaration shares the name — cannot safely resolve which one runs), helpers whose only
643
+ // internal install call is itself conditional/dynamic/unreachable, or a helper call that does not
644
+ // itself PRECEDE this usage (same guard-ordering rule as a direct call), do not certify.
645
+ const helperDecls = collectLocalFunctionDeclarations(sf, ts)
646
+ const helperCalls = scope
647
+ .collectDirect(F, (n) => ts.isCallExpression(n) && ts.isIdentifier(n.expression) && helperDecls.has(n.expression.text))
648
+ .filter((call) => !scope.isShadowedBeforeSourceFile(call.expression, call.expression.text))
649
+ .filter(reachable)
650
+
651
+ let helperUncertain = false
652
+ for (const helperCall of helperCalls) {
653
+ const helperFn = helperDecls.get(helperCall.expression.text)
654
+ if (helperFn === "ambiguous") {
655
+ helperUncertain = true
656
+ continue
657
+ }
658
+ const helperConditional = scope.isWithinConditional(helperCall, F)
659
+ const helperOrdered = precedesUsage(helperCall)
660
+ const innerDirect = scope
661
+ .collectDirect(helperFn, isInstaller)
662
+ .filter(relevant)
663
+ .filter((c) => !isDeadCode(c, helperFn, ts))
664
+ if (innerDirect.length > 0) helperUncertain = true
665
+ for (const inner of innerDirect) {
666
+ const innerConfirmed = installDecorativeStatus(inner, ts, identity, bindings) === "confirmed"
667
+ const innerConditional = scope.isWithinConditional(inner, helperFn)
668
+ if (innerConfirmed && !innerConditional && !helperConditional && helperOrdered) {
669
+ return { tier: "confirmed" }
670
+ }
671
+ }
672
+ }
673
+
674
+ if (direct.length > 0) {
675
+ const dynamicOnly = direct.every((c) => installDecorativeStatus(c, ts, identity, bindings) === "uncertain")
676
+ const anyOrdered = direct.some((c) => precedesUsage(c))
677
+ return {
678
+ tier: "uncertain",
679
+ reason: dynamicOnly
680
+ ? "the install call's family argument is not a literal, so it cannot be statically confirmed as decorative."
681
+ : !anyOrdered
682
+ ? "a reachable, unconditional ensurePaletteTokens()/-For() call exists in this scope, but only AFTER this usage — an earlier return/branch can reach this usage before the install runs."
683
+ : "a reachable ensurePaletteTokens()/-For() call exists in this scope, but only inside a conditional/guarded path.",
684
+ }
685
+ }
686
+
687
+ const nested = scope.collectNested(F, isInstaller).filter(relevant)
688
+ if (nested.length > 0) {
689
+ return {
690
+ tier: "uncertain",
691
+ reason:
692
+ "an ensurePaletteTokens()/-For() call exists, but only inside a nested callback/event handler — it does not run synchronously as part of this scope's own render path.",
693
+ }
694
+ }
695
+
696
+ if (helperUncertain) {
697
+ return {
698
+ tier: "uncertain",
699
+ reason:
700
+ "an ensurePaletteTokens()/-For() call exists in a locally-declared helper this scope calls, but either the helper's name is ambiguous (more than one local declaration), the call to the helper is conditional or positioned after this usage, or the call inside it is conditional/unreachable.",
701
+ }
702
+ }
703
+
704
+ return { tier: "absent" }
705
+ }
706
+
707
+ /** Top-level (and nested) named function/arrow declarations, for one-hop helper-call tracing.
708
+ * Keyed by declared name. A second declaration sharing a name marks it `"ambiguous"` instead of
709
+ * silently keeping the first one found — classifyScope treats an ambiguous helper call as
710
+ * uncertain, never resolving into (and potentially mis-certifying from) the wrong declaration. */
711
+ function collectLocalFunctionDeclarations(sf, ts) {
712
+ const decls = new Map()
713
+ const record = (name, fn) => {
714
+ if (decls.has(name)) decls.set(name, "ambiguous")
715
+ else decls.set(name, fn)
716
+ }
717
+ const visit = (node) => {
718
+ if (ts.isFunctionDeclaration(node) && node.name) {
719
+ record(node.name.text, node)
720
+ } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
721
+ if (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) {
722
+ record(node.name.text, node.initializer)
723
+ }
724
+ }
725
+ ts.forEachChild(node, visit)
726
+ }
727
+ visit(sf)
728
+ return decls
729
+ }
730
+
731
+ function describeScope(F, sf, scope, ts) {
732
+ const name = scope.functionName(F, sf)
733
+ return name ? `\`${name}\`` : "module scope"
734
+ }
735
+
736
+ function analyzePaletteInstallation(ts, identity, fileLabel, raw) {
737
+ const scope = createScopeHelpers(ts)
738
+ const sf = ts.createSourceFile(fileLabel, raw, ts.ScriptTarget.Latest, true, scriptKindFor(ts, fileLabel))
739
+ const bindings = collectBindings(sf, ts, identity)
740
+ const usages = collectDecorativeUsages(sf, ts, scope, bindings, identity)
741
+ if (usages.length === 0) return []
742
+
743
+ const findings = []
744
+ const reportedScopes = new Set()
745
+
746
+ for (const usage of usages) {
747
+ if (usage.dynamic) {
748
+ const key = `dynamic:${usage.node.getStart(sf)}`
749
+ if (reportedScopes.has(key)) continue
750
+ reportedScopes.add(key)
751
+ const pos = sf.getLineAndCharacterOfPosition(usage.node.getStart(sf))
752
+ findings.push(
753
+ makeFinding(identity, {
754
+ code: "unestablished-palette-install",
755
+ severity: "warning",
756
+ confidence: "low",
757
+ file: fileLabel,
758
+ line: pos.line + 1,
759
+ column: pos.character + 1,
760
+ evidence: "A computed member access on the tokens namespace uses a non-literal key, so the referenced family cannot be statically determined.",
761
+ explanation: "Static analysis cannot resolve which decorative family this reads, so palette-install coverage for this usage cannot be verified.",
762
+ suggestion: "Prefer a literal token member (e.g. tokens.blue11 or tokens[\"blue11\"]) so this is verifiable, or confirm manually that every family this path may resolve to has its palette sheet installed.",
763
+ })
764
+ )
765
+ continue
766
+ }
767
+
768
+ // Classified PER USAGE, not per scope: a scope can contain an unconditional install call that
769
+ // still does not cover an EARLIER usage reachable via a guard clause that returns first (see
770
+ // callCoversUsage) — deduping by scope alone would let that earlier usage silently inherit a
771
+ // later usage's "confirmed" verdict.
772
+ const F = scope.enclosingFunctionOrFile(usage.node, sf)
773
+ const usageKey = String(usage.node.getStart(sf))
774
+ if (reportedScopes.has(usageKey)) continue
775
+ reportedScopes.add(usageKey)
776
+
777
+ const status = classifyScope(F, sf, ts, scope, bindings, identity, usage.node)
778
+ if (status.tier === "confirmed") continue
779
+
780
+ const pos = sf.getLineAndCharacterOfPosition(usage.node.getStart(sf))
781
+ const scopeDesc = describeScope(F, sf, scope, ts)
782
+ const family = familyOf(identity, usage.family)
783
+
784
+ // Never "error" here: this is single-source analysis with no cross-file tracing, so it can
785
+ // never prove a decorative usage is actually broken — a shared bootstrap/provider install
786
+ // elsewhere is always a possibility this source alone cannot rule out. Severity stays
787
+ // "warning" for every tier; only confidence varies.
788
+ const verifyAction =
789
+ `Not confirmable from this source alone — local absence does not prove a bootstrap/provider install does not exist elsewhere. ` +
790
+ `Inspect this integration's existing installation contract for "${family}" before assuming a defect. ` +
791
+ `Once available, establish the install through the pending WP2B React adapter (not yet shipped) rather than adding an ad hoc render-time call here.`
792
+
793
+ if (status.tier === "uncertain") {
794
+ findings.push(
795
+ makeFinding(identity, {
796
+ code: "unestablished-palette-install",
797
+ severity: "warning",
798
+ confidence: "medium",
799
+ file: fileLabel,
800
+ line: pos.line + 1,
801
+ column: pos.character + 1,
802
+ evidence: `Decorative token "${usage.family}" is used in ${scopeDesc}: ${status.reason}`,
803
+ explanation:
804
+ "Static analysis cannot prove this path always runs, unconditionally and synchronously, before the decorative token renders. If it does not actually cover this usage, the palette sheet may never load and the value renders as an invalid custom property with no colour.",
805
+ suggestion: verifyAction,
806
+ })
807
+ )
808
+ } else {
809
+ findings.push(
810
+ makeFinding(identity, {
811
+ code: "unestablished-palette-install",
812
+ severity: "warning",
813
+ confidence: "high",
814
+ file: fileLabel,
815
+ line: pos.line + 1,
816
+ column: pos.character + 1,
817
+ evidence: `Decorative token "${usage.family}" is used in ${scopeDesc}, with no reachable, unconditional, correctly-scoped ensurePaletteTokens()/-For() call found for it in this source.`,
818
+ explanation: `"${family}" sits outside the core sheet (accent/gray/error/warning/success/info) that loads by default. Without an install covering it, this renders an invalid custom property and no colour — but this source alone cannot confirm whether a shared bootstrap/provider elsewhere already covers it.`,
819
+ suggestion: verifyAction,
820
+ })
821
+ )
822
+ }
823
+ }
824
+
825
+ return findings
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // Public entrypoint
830
+ // ---------------------------------------------------------------------------
831
+
832
+ /**
833
+ * Analyzes one already-in-memory source string — no fs, no path resolution, nothing derived from
834
+ * `sourceText` is ever used as a filesystem path or executed. Runs `legacy-volt-class` and
835
+ * `hand-written-volt-css-var` unconditionally (pure text, no TypeScript needed). Runs
836
+ * `unestablished-palette-install` only if BOTH `ts` is supplied (injected by the caller — this
837
+ * module never resolves it) AND `fileNameHint` looks like a script file (`.ts`/`.tsx`/`.js`/`.jsx`)
838
+ * — a `.css`/`.scss` hint (or any non-script hint) only ever produces the two textual rules'
839
+ * findings, same as the CLI's file-based behavior for a stylesheet. `fileNameHint` is a LABEL for
840
+ * output only (the `file` field on findings, and which parse mode to use) — never treated as a
841
+ * path to read.
842
+ *
843
+ * Returns the same finding array shape `migration-diagnostics.mjs` (via its `analyzeFile`) has
844
+ * always returned, in the same order (textual findings first, then palette findings). Calling this
845
+ * twice with the same inputs returns equivalent findings and does not mutate `identity` (its
846
+ * `core`/`palette` `Set`s are only ever read, never written).
847
+ */
848
+ export function analyzeSource(sourceText, { identity, ts, fileNameHint = "source.tsx" } = {}) {
849
+ if (!identity) {
850
+ throw new Error(
851
+ "analyzeSource requires a resolved `identity` — { packageName, packageVersion, generatedInputHash, core: Set<string>, palette: Set<string> }."
852
+ )
853
+ }
854
+ const findings = textualFindings(identity, fileNameHint, sourceText)
855
+ if (ts && SCRIPT_EXT_RE.test(fileNameHint)) {
856
+ findings.push(...analyzePaletteInstallation(ts, identity, fileNameHint, sourceText))
857
+ }
858
+ return findings
859
+ }