@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.
- package/ADOPTING.md +416 -0
- package/README.md +32 -31
- package/dist/_virtual/jsx-dev-runtime.js +5 -0
- package/dist/_virtual/jsx-dev-runtime.js.map +1 -0
- package/dist/_virtual/react-jsx-dev-runtime.development.js +5 -0
- package/dist/_virtual/react-jsx-dev-runtime.development.js.map +1 -0
- package/dist/_virtual/react-jsx-dev-runtime.production.js +5 -0
- package/dist/_virtual/react-jsx-dev-runtime.production.js.map +1 -0
- package/dist/components/accordion/accordion.js +126 -66
- package/dist/components/accordion/accordion.js.map +1 -1
- package/dist/components/alert-dialog/alert-dialog.js +239 -137
- package/dist/components/alert-dialog/alert-dialog.js.map +1 -1
- package/dist/components/avatar/avatar-group.js +102 -47
- package/dist/components/avatar/avatar-group.js.map +1 -1
- package/dist/components/avatar/avatar.js +97 -73
- package/dist/components/avatar/avatar.js.map +1 -1
- package/dist/components/badge/badge.js +53 -37
- package/dist/components/badge/badge.js.map +1 -1
- package/dist/components/breadcrumb/breadcrumb.js +97 -49
- package/dist/components/breadcrumb/breadcrumb.js.map +1 -1
- package/dist/components/button/button.js +88 -77
- package/dist/components/button/button.js.map +1 -1
- package/dist/components/button-group/button-group.js +55 -31
- package/dist/components/button-group/button-group.js.map +1 -1
- package/dist/components/callout/callout.js +288 -164
- package/dist/components/callout/callout.js.map +1 -1
- package/dist/components/card/card.js +137 -81
- package/dist/components/card/card.js.map +1 -1
- package/dist/components/checkbox/checkbox.js +100 -49
- package/dist/components/checkbox/checkbox.js.map +1 -1
- package/dist/components/collapsible-sidebar/collapsible-sidebar.js +155 -107
- package/dist/components/collapsible-sidebar/collapsible-sidebar.js.map +1 -1
- package/dist/components/command/command.js +297 -158
- package/dist/components/command/command.js.map +1 -1
- package/dist/components/data-table/data-table-body.js +114 -54
- package/dist/components/data-table/data-table-body.js.map +1 -1
- package/dist/components/data-table/data-table-column-header.js +117 -45
- package/dist/components/data-table/data-table-column-header.js.map +1 -1
- package/dist/components/data-table/data-table-column-visibility.js +98 -39
- package/dist/components/data-table/data-table-column-visibility.js.map +1 -1
- package/dist/components/data-table/data-table-content.js +90 -58
- package/dist/components/data-table/data-table-content.js.map +1 -1
- package/dist/components/data-table/data-table-error.js +83 -39
- package/dist/components/data-table/data-table-error.js.map +1 -1
- package/dist/components/data-table/data-table-header.js +88 -54
- package/dist/components/data-table/data-table-header.js.map +1 -1
- package/dist/components/data-table/data-table-loading.js +27 -15
- package/dist/components/data-table/data-table-loading.js.map +1 -1
- package/dist/components/data-table/data-table-pagination.js +27 -19
- package/dist/components/data-table/data-table-pagination.js.map +1 -1
- package/dist/components/data-table/data-table-row.js +87 -64
- package/dist/components/data-table/data-table-row.js.map +1 -1
- package/dist/components/data-table/data-table-toolbar.js +24 -16
- package/dist/components/data-table/data-table-toolbar.js.map +1 -1
- package/dist/components/data-table/data-table.js +182 -145
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/date-range-picker/date-range-picker.js +342 -214
- package/dist/components/date-range-picker/date-range-picker.js.map +1 -1
- package/dist/components/date-range-picker/range-calendar.js +132 -92
- package/dist/components/date-range-picker/range-calendar.js.map +1 -1
- package/dist/components/date-time-picker/calendar.js +501 -317
- package/dist/components/date-time-picker/calendar.js.map +1 -1
- package/dist/components/date-time-picker/date-time-picker-context.js +64 -60
- package/dist/components/date-time-picker/date-time-picker-context.js.map +1 -1
- package/dist/components/date-time-picker/date-time-picker-input.js +249 -149
- package/dist/components/date-time-picker/date-time-picker-input.js.map +1 -1
- package/dist/components/date-time-picker/date-time-picker-panel.js +273 -165
- package/dist/components/date-time-picker/date-time-picker-panel.js.map +1 -1
- package/dist/components/date-time-picker/date-time-picker-popover.js +22 -14
- package/dist/components/date-time-picker/date-time-picker-popover.js.map +1 -1
- package/dist/components/date-time-picker/date-time-picker.js +134 -90
- package/dist/components/date-time-picker/date-time-picker.js.map +1 -1
- package/dist/components/date-time-picker/time-picker.js +198 -132
- package/dist/components/date-time-picker/time-picker.js.map +1 -1
- package/dist/components/density-provider/density-provider.js +48 -0
- package/dist/components/density-provider/density-provider.js.map +1 -0
- package/dist/components/dialog/dialog.js +280 -150
- package/dist/components/dialog/dialog.js.map +1 -1
- package/dist/components/drawer/drawer.js +273 -163
- package/dist/components/drawer/drawer.js.map +1 -1
- package/dist/components/dropdown-menu/dropdown-menu.js +378 -195
- package/dist/components/dropdown-menu/dropdown-menu.js.map +1 -1
- package/dist/components/field/field-combobox.js +447 -240
- package/dist/components/field/field-combobox.js.map +1 -1
- package/dist/components/field/field-select.js +23 -19
- package/dist/components/field/field-select.js.map +1 -1
- package/dist/components/field/field.js +318 -209
- package/dist/components/field/field.js.map +1 -1
- package/dist/components/label/label.js +27 -19
- package/dist/components/label/label.js.map +1 -1
- package/dist/components/list/list.js +623 -369
- package/dist/components/list/list.js.map +1 -1
- package/dist/components/option-card/option-card.js +323 -159
- package/dist/components/option-card/option-card.js.map +1 -1
- package/dist/components/pill/pill.js +82 -64
- package/dist/components/pill/pill.js.map +1 -1
- package/dist/components/popover/popover.js +101 -56
- package/dist/components/popover/popover.js.map +1 -1
- package/dist/components/radio/radio.js +94 -51
- package/dist/components/radio/radio.js.map +1 -1
- package/dist/components/scroll-overflow/scroll-overflow.js +350 -198
- package/dist/components/scroll-overflow/scroll-overflow.js.map +1 -1
- package/dist/components/segmented-control/segmented-control.js +141 -97
- package/dist/components/segmented-control/segmented-control.js.map +1 -1
- package/dist/components/select/select.js +452 -227
- package/dist/components/select/select.js.map +1 -1
- package/dist/components/separator/separator.js +24 -16
- package/dist/components/separator/separator.js.map +1 -1
- package/dist/components/skeleton/skeleton.js +83 -43
- package/dist/components/skeleton/skeleton.js.map +1 -1
- package/dist/components/spinner/spinner.js +44 -20
- package/dist/components/spinner/spinner.js.map +1 -1
- package/dist/components/switch/switch.js +163 -86
- package/dist/components/switch/switch.js.map +1 -1
- package/dist/components/table/table-pagination.js +465 -205
- package/dist/components/table/table-pagination.js.map +1 -1
- package/dist/components/table/table.js +212 -144
- package/dist/components/table/table.js.map +1 -1
- package/dist/components/tabs/tabs.js +94 -58
- package/dist/components/tabs/tabs.js.map +1 -1
- package/dist/components/text/text.js +40 -32
- package/dist/components/text/text.js.map +1 -1
- package/dist/components/toast/toast.js +187 -111
- package/dist/components/toast/toast.js.map +1 -1
- package/dist/components/toggle/toggle-group.js +40 -24
- package/dist/components/toggle/toggle-group.js.map +1 -1
- package/dist/components/toggle/toggle.js +48 -40
- package/dist/components/toggle/toggle.js.map +1 -1
- package/dist/components/tooltip/tooltip.js +101 -55
- package/dist/components/tooltip/tooltip.js.map +1 -1
- package/dist/index.d.ts +600 -427
- package/dist/index.js +215 -212
- package/dist/index.js.map +1 -1
- package/dist/lib/clear-button.js +27 -15
- package/dist/lib/clear-button.js.map +1 -1
- package/dist/lib/icons.js +18 -6
- package/dist/lib/icons.js.map +1 -1
- package/dist/packages/volt-runtime/dist/sheet.js +27 -10
- package/dist/packages/volt-runtime/dist/sheet.js.map +1 -1
- package/dist/packages/volt-runtime/dist/styles/recipe.js +76 -0
- package/dist/packages/volt-runtime/dist/styles/recipe.js.map +1 -0
- package/dist/styles/document-context.js +12 -0
- package/dist/styles/document-context.js.map +1 -0
- package/dist/styles/make-styles.js +18 -10
- package/dist/styles/make-styles.js.map +1 -1
- package/dist/tokens/core.generated.js +1238 -1166
- package/dist/tokens/core.generated.js.map +1 -1
- package/dist/tokens/index.d.ts +442 -415
- package/dist/tokens/index.js +1105 -1078
- package/dist/tokens/index.js.map +1 -1
- package/dist/tokens/install.js +9 -5
- package/dist/tokens/install.js.map +1 -1
- package/dist/tokens/use-palette-tokens.js +20 -0
- package/dist/tokens/use-palette-tokens.js.map +1 -0
- package/dist/tokens.json +83 -2
- package/dist/vendor/react/cjs/react-jsx-dev-runtime.development.js +236 -0
- package/dist/vendor/react/cjs/react-jsx-dev-runtime.development.js.map +1 -0
- package/dist/vendor/react/cjs/react-jsx-dev-runtime.production.js +21 -0
- package/dist/vendor/react/cjs/react-jsx-dev-runtime.production.js.map +1 -0
- package/dist/vendor/react/jsx-dev-runtime.js +9 -0
- package/dist/vendor/react/jsx-dev-runtime.js.map +1 -0
- package/package.json +13 -5
- package/scripts/migration-diagnostics-core.mjs +859 -0
- package/scripts/migration-diagnostics.mjs +353 -0
- package/scripts/migration-sweep.mjs +179 -0
- package/AGENTS.md +0 -153
- package/AUTHORING.md +0 -292
- package/dist/packages/volt-runtime/dist/styles/make-styles.js +0 -22
- 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
|
+
}
|