@assure-one/design-system 1.31.0 → 1.33.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/README.md +61 -6
- package/codemods/0.2.0-radix-migration.mjs +315 -0
- package/codemods/README.md +365 -0
- package/codemods/lib/css-selectors.mjs +33 -0
- package/codemods/lib/css-values.mjs +223 -0
- package/codemods/lib/ds-stylesheet.mjs +168 -0
- package/codemods/lib/environment.mjs +72 -0
- package/codemods/lib/files.mjs +100 -0
- package/codemods/lib/forms.mjs +253 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +84 -0
- package/codemods/lib/registry.mjs +32 -0
- package/codemods/lib/report.mjs +119 -0
- package/codemods/lib/runner.mjs +164 -0
- package/codemods/run.mjs +161 -0
- package/codemods/transforms/cm-14-hidden-mirrors.mjs +275 -0
- package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
- package/codemods/transforms/cm-16-globals-css.mjs +487 -0
- package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
- package/dist/css/base.css +60 -0
- package/dist/css/components.css +7 -0
- package/dist/css/legacy-aliases.css +665 -0
- package/dist/css/shadcn.css +155 -0
- package/dist/css/tailwind.css +296 -0
- package/dist/css/tokens.css +630 -0
- package/dist/index.d.ts +807 -27
- package/dist/index.js +2038 -683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/system-BDU18fVg.d.ts +559 -0
- package/dist/testing/index.cjs +458 -0
- package/dist/testing/index.d.cts +253 -0
- package/dist/testing/index.d.ts +253 -0
- package/dist/testing/index.js +452 -0
- package/dist/testing/setup.cjs +123 -0
- package/dist/testing/setup.js +121 -0
- package/dist/testing/style-stub.cjs +7 -0
- package/dist/testing/style-stub.js +5 -0
- package/dist/tokens/index.d.ts +50 -439
- package/dist/tokens/index.js +557 -50
- package/dist/tokens/index.js.map +1 -1
- package/package.json +74 -5
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-20 — Select sentinel finder (class X, report-only; plan §29, registry
|
|
3
|
+
* `C-SELECT-EMPTY`, feeds Wave 4 W4-08).
|
|
4
|
+
*
|
|
5
|
+
* `Select` reserves the empty string for "nothing picked" and Radix refuses
|
|
6
|
+
* `value=""` on an item, so every application invented a stand-in value for
|
|
7
|
+
* the "no value" option and converts it back at the form boundary ([CU §18]:
|
|
8
|
+
* "sentinels, because Select reserves the empty string for 'nothing picked'").
|
|
9
|
+
* W4-08 gives `Select` a real `value: string | null`.
|
|
10
|
+
*
|
|
11
|
+
* Replacing a sentinel with `null` changes what the server receives — the
|
|
12
|
+
* one thing plan §29 never automates — so CM-20 is report-only for ever. It
|
|
13
|
+
* finds the sentinels and the line where each is converted; a human decides
|
|
14
|
+
* what the field should post.
|
|
15
|
+
*
|
|
16
|
+
* ## Finding the sentinels rather than assuming them
|
|
17
|
+
*
|
|
18
|
+
* `"none"` and `"__unassigned__"` are the two the registry names, but the
|
|
19
|
+
* applications use many more (`__all__`, `__all_products__`, `__custom__`,
|
|
20
|
+
* `__scratch__`, `__external__`, `__none__`, …), and `"none"` is also a
|
|
21
|
+
* perfectly real domain value (`deposit_type: "none"`). A fixed list would
|
|
22
|
+
* both miss and over-report, so CM-20 works from evidence in the file:
|
|
23
|
+
*
|
|
24
|
+
* | evidence | why it is conclusive |
|
|
25
|
+
* | ------------ | ------------------------------------------------------------------------ |
|
|
26
|
+
* | round trip | the file converts the value to `null`/`undefined`/`""`, or defaults to it |
|
|
27
|
+
* | `__dunder__` | the spelling exists only to dodge Radix's empty-value rule |
|
|
28
|
+
* | constant name| `NONE_VALUE`, `UNASSIGNED`, `NO_OWNER`, `ALL_MEMBERS`, `…_SENTINEL` |
|
|
29
|
+
* | option label | the option is labelled "All …", "None", "Unassigned", "No …" |
|
|
30
|
+
*
|
|
31
|
+
* A spelling is reported only where it is actually used as a `Select` value:
|
|
32
|
+
* a control's `value`/`defaultValue`, a `SelectItem value`, or the `value`
|
|
33
|
+
* of an option record in a file that renders one of the Select family. The
|
|
34
|
+
* same `"__ungrouped__"` used as a grouping key in a table is not a Select
|
|
35
|
+
* sentinel and stays silent.
|
|
36
|
+
*
|
|
37
|
+
* | rule | registry | what it is |
|
|
38
|
+
* | --------------------- | --------------- | ------------------------------------------------------------ |
|
|
39
|
+
* | `empty-string-value` | C-SELECT-EMPTY | `value=""` on a Select-family control or an option |
|
|
40
|
+
* | `sentinel-dunder` | C-SELECT-EMPTY | a `__…__` spelling used as a Select value |
|
|
41
|
+
* | `sentinel-constant` | C-SELECT-EMPTY | a no-value-named constant used as a Select value |
|
|
42
|
+
* | `sentinel-word` | C-SELECT-EMPTY | `none`/`all`/… with a no-value label or a round trip |
|
|
43
|
+
* | `sentinel-discovered` | C-SELECT-EMPTY | any other spelling the file converts to nothing |
|
|
44
|
+
* | `sentinel-conversion` | C-SELECT-EMPTY | the line that maps a reported sentinel to `null`/`undefined`/`""` |
|
|
45
|
+
*
|
|
46
|
+
* ## What it deliberately does not do
|
|
47
|
+
*
|
|
48
|
+
* - It never writes a file (the runner refuses on class X), and it proposes
|
|
49
|
+
* no replacement: whether a field should post `null`, `""` or the sentinel
|
|
50
|
+
* is a server contract.
|
|
51
|
+
* - A known word with no evidence at all (`"none"` as a genuine deposit
|
|
52
|
+
* type, `"all"` as a real filter the API understands) is **not** reported.
|
|
53
|
+
* CM-20 would rather miss a sentinel than invite someone to change a value
|
|
54
|
+
* the backend depends on.
|
|
55
|
+
* - Sentinels outside the Select family — grouping keys, tab ids, route
|
|
56
|
+
* segments — are out of scope even when the spelling matches.
|
|
57
|
+
* - It does not follow a spelling across files; a sentinel declared in a
|
|
58
|
+
* shared constants module and used elsewhere is reported at the use site
|
|
59
|
+
* only if that file resolves it.
|
|
60
|
+
*/
|
|
61
|
+
import { analyseForms, literalValue } from "../lib/forms.mjs";
|
|
62
|
+
|
|
63
|
+
export const meta = {
|
|
64
|
+
id: "CM-20",
|
|
65
|
+
title: "Select sentinel finder: option values standing in for “no value”",
|
|
66
|
+
class: "X",
|
|
67
|
+
oneShot: false,
|
|
68
|
+
requires: { codemods: [], dsVersion: null },
|
|
69
|
+
parses: ["code"],
|
|
70
|
+
includeTests: false,
|
|
71
|
+
usesTypeScript: true,
|
|
72
|
+
usesPostcss: false,
|
|
73
|
+
registryIds: ["C-SELECT-EMPTY"],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** How a finding is weighted in the report and in the Wave 4 work. */
|
|
77
|
+
export const SEVERITY = { high: "high", medium: "medium", low: "low" };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The Select family: components whose `value` is an option identifier the
|
|
81
|
+
* user picks from a list. `DatePicker` and the checkbox-like controls are
|
|
82
|
+
* not here — they have no "no value" option to spell.
|
|
83
|
+
*/
|
|
84
|
+
export const SELECT_FAMILY = new Set([
|
|
85
|
+
"Select",
|
|
86
|
+
"SelectRoot",
|
|
87
|
+
"SelectItem",
|
|
88
|
+
"SearchSelect",
|
|
89
|
+
"ClientSelect",
|
|
90
|
+
"TeamMemberSelect",
|
|
91
|
+
"MultiSelectField",
|
|
92
|
+
"MultiFilterPill",
|
|
93
|
+
"DisplayMenu",
|
|
94
|
+
"Combobox",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/** Attributes of a Select-family element that hold an option value. */
|
|
98
|
+
const VALUE_ATTRIBUTES = ["value", "defaultValue"];
|
|
99
|
+
|
|
100
|
+
/** Object keys that make an object literal an option record. */
|
|
101
|
+
const LABEL_KEYS = new Set(["label", "labelKey", "title", "name", "text"]);
|
|
102
|
+
|
|
103
|
+
/** Words that stand in for "no value" often enough to be worth checking. */
|
|
104
|
+
export const NO_VALUE_WORDS = new Set([
|
|
105
|
+
"none",
|
|
106
|
+
"all",
|
|
107
|
+
"any",
|
|
108
|
+
"unassigned",
|
|
109
|
+
"unspecified",
|
|
110
|
+
"unset",
|
|
111
|
+
"empty",
|
|
112
|
+
"null",
|
|
113
|
+
"undefined",
|
|
114
|
+
"n/a",
|
|
115
|
+
"na",
|
|
116
|
+
"-",
|
|
117
|
+
"--",
|
|
118
|
+
"—",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
/** A spelling whose only purpose is to dodge Radix's empty-value rule. */
|
|
122
|
+
export const DUNDER = /^__[A-Za-z0-9][\w-]*__$/;
|
|
123
|
+
|
|
124
|
+
/** A constant name that says the value means "nothing picked". */
|
|
125
|
+
export const SENTINEL_NAME =
|
|
126
|
+
/(?:^|_)(?:NONE|ALL|ANY|UNASSIGNED|UNSPECIFIED|UNSET|EMPTY|SENTINEL|PLACEHOLDER|NO)(?:_|$)/;
|
|
127
|
+
|
|
128
|
+
/** A label that says the option is the "no value" one. */
|
|
129
|
+
export const NO_VALUE_LABEL =
|
|
130
|
+
/(?:^|[.\s_\-—])(?:all|none|any|unassigned|unspecified|unset|empty|everyone|everything|no)(?:[.\s_\-—]|$)/i;
|
|
131
|
+
|
|
132
|
+
export function transform(file, { ts }) {
|
|
133
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
134
|
+
const sf = facts.sf;
|
|
135
|
+
const usesSelect = [...facts.componentsUsed].some((c) => SELECT_FAMILY.has(c));
|
|
136
|
+
if (!usesSelect) return { findings: [], parseErrors: facts.parseErrors };
|
|
137
|
+
|
|
138
|
+
const findings = [];
|
|
139
|
+
const notTransformed = [];
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// Evidence 1 — round trips. The file converts a spelling to nothing, or
|
|
143
|
+
// defaults to it when there is nothing.
|
|
144
|
+
|
|
145
|
+
const EMPTY = (node) =>
|
|
146
|
+
node !== undefined &&
|
|
147
|
+
node !== null &&
|
|
148
|
+
((ts.isStringLiteral(node) && node.text === "") ||
|
|
149
|
+
node.kind === ts.SyntaxKind.NullKeyword ||
|
|
150
|
+
(ts.isIdentifier(node) && node.text === "undefined") ||
|
|
151
|
+
(ts.isNoSubstitutionTemplateLiteral(node) && node.text === ""));
|
|
152
|
+
|
|
153
|
+
const stringOf = (node) => {
|
|
154
|
+
if (!node) return null;
|
|
155
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
156
|
+
if (ts.isIdentifier(node)) return facts.constants.get(node.text)?.text ?? null;
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
/** The declared name behind a value expression, when it is a constant. */
|
|
160
|
+
const constantName = (node) =>
|
|
161
|
+
node && ts.isIdentifier(node) && facts.constants.has(node.text) ? node.text : null;
|
|
162
|
+
|
|
163
|
+
/** spelling → { line, mapsTo, how } */
|
|
164
|
+
const roundTrips = new Map();
|
|
165
|
+
const noteRoundTrip = (spelling, node, mapsTo, how) => {
|
|
166
|
+
if (spelling === null || spelling === "") return;
|
|
167
|
+
if (roundTrips.has(spelling)) return;
|
|
168
|
+
roundTrips.set(spelling, {
|
|
169
|
+
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
|
|
170
|
+
mapsTo,
|
|
171
|
+
how,
|
|
172
|
+
text: node.getText(sf).replace(/\s+/g, " ").slice(0, 160),
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
const emptyName = (node) =>
|
|
176
|
+
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
|
|
177
|
+
? '""'
|
|
178
|
+
: node.kind === ts.SyntaxKind.NullKeyword
|
|
179
|
+
? "null"
|
|
180
|
+
: "undefined";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The opening elements of the Select family, so that `x ?? SENTINEL` can be
|
|
184
|
+
* required to sit in a Select's own `value` — `currency ?? "USD"` elsewhere
|
|
185
|
+
* in the file is a default, not a stand-in for "nothing picked".
|
|
186
|
+
*/
|
|
187
|
+
const selectOpenings = new Set();
|
|
188
|
+
for (const el of facts.elements) {
|
|
189
|
+
if (el.isDs && SELECT_FAMILY.has(el.base)) {
|
|
190
|
+
selectOpenings.add(ts.isJsxElement(el.node) ? el.node.openingElement : el.node);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const isSelectValueAttribute = (node) =>
|
|
194
|
+
ts.isJsxAttribute(node) &&
|
|
195
|
+
VALUE_ATTRIBUTES.includes(node.name.getText(sf)) &&
|
|
196
|
+
selectOpenings.has(node.parent?.parent);
|
|
197
|
+
|
|
198
|
+
const same = (a, b) => a && b && a.getText(sf).trim() === b.getText(sf).trim();
|
|
199
|
+
|
|
200
|
+
const scanEvidence = (node, inSelectValue) => {
|
|
201
|
+
// The normaliser shape: `x === SENTINEL ? nothing : x`, and its inverse
|
|
202
|
+
// `x === "" ? SENTINEL : x`. The untouched branch must be the subject
|
|
203
|
+
// itself — `status === "completed" ? null : "draft"` maps one domain
|
|
204
|
+
// value onto another and is not a sentinel.
|
|
205
|
+
if (ts.isConditionalExpression(node)) {
|
|
206
|
+
const cond = node.condition;
|
|
207
|
+
if (ts.isBinaryExpression(cond) && /^[=!]==?$/.test(cond.operatorToken.getText(sf))) {
|
|
208
|
+
const negated = cond.operatorToken.getText(sf).startsWith("!");
|
|
209
|
+
const [emptyBranch, subjectBranch] = negated
|
|
210
|
+
? [node.whenFalse, node.whenTrue]
|
|
211
|
+
: [node.whenTrue, node.whenFalse];
|
|
212
|
+
const sides = [cond.left, cond.right];
|
|
213
|
+
const subject = sides.find((s) => stringOf(s) === null);
|
|
214
|
+
if (subject && same(subject, subjectBranch) && EMPTY(emptyBranch)) {
|
|
215
|
+
const spelling = sides.map(stringOf).find((s) => s !== null && s !== "");
|
|
216
|
+
if (spelling !== undefined) {
|
|
217
|
+
noteRoundTrip(spelling, node, emptyName(emptyBranch), "conditional");
|
|
218
|
+
}
|
|
219
|
+
} else if (subject && same(subject, emptyBranch) && sides.some(EMPTY)) {
|
|
220
|
+
// `x === "" ? SENTINEL : x` — the sentinel stands in for `""`.
|
|
221
|
+
const branchSpelling = stringOf(subjectBranch);
|
|
222
|
+
if (branchSpelling !== null && branchSpelling !== "") {
|
|
223
|
+
noteRoundTrip(branchSpelling, node, '""', "conditional");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// `x ?? SENTINEL` inside a Select's own `value`: the value the control
|
|
229
|
+
// shows when the state holds nothing.
|
|
230
|
+
if (inSelectValue && ts.isBinaryExpression(node)) {
|
|
231
|
+
const op = node.operatorToken.getText(sf);
|
|
232
|
+
if (op === "??" || op === "||") {
|
|
233
|
+
const spelling = stringOf(node.right);
|
|
234
|
+
if (spelling !== null && spelling !== "" && stringOf(node.left) === null) {
|
|
235
|
+
noteRoundTrip(spelling, node, "a missing value", "default");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const next = inSelectValue || isSelectValueAttribute(node);
|
|
240
|
+
ts.forEachChild(node, (child) => scanEvidence(child, next));
|
|
241
|
+
};
|
|
242
|
+
scanEvidence(sf, false);
|
|
243
|
+
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
// Evidence 2 — option records: `{ value: "…", label: "…" }` anywhere in a
|
|
246
|
+
// file that renders a Select-family component.
|
|
247
|
+
|
|
248
|
+
/** spelling → label text */
|
|
249
|
+
const optionLabels = new Map();
|
|
250
|
+
const optionRecords = [];
|
|
251
|
+
const scanOptions = (node) => {
|
|
252
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
253
|
+
let value = null;
|
|
254
|
+
let valueNode = null;
|
|
255
|
+
let label = null;
|
|
256
|
+
for (const prop of node.properties) {
|
|
257
|
+
if (!ts.isPropertyAssignment(prop) || !prop.name) continue;
|
|
258
|
+
const key =
|
|
259
|
+
ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null;
|
|
260
|
+
if (key === "value") {
|
|
261
|
+
value = stringOf(prop.initializer);
|
|
262
|
+
valueNode = prop.initializer;
|
|
263
|
+
} else if (key && LABEL_KEYS.has(key) && label === null) {
|
|
264
|
+
label = stringOf(prop.initializer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (value !== null && (label !== null || valueNode !== null)) {
|
|
268
|
+
optionRecords.push({
|
|
269
|
+
spelling: value,
|
|
270
|
+
label,
|
|
271
|
+
node: valueNode,
|
|
272
|
+
line: sf.getLineAndCharacterOfPosition(valueNode.getStart(sf)).line + 1,
|
|
273
|
+
constant: constantName(valueNode),
|
|
274
|
+
});
|
|
275
|
+
if (label !== null && !optionLabels.has(value)) optionLabels.set(value, label);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
ts.forEachChild(node, scanOptions);
|
|
279
|
+
};
|
|
280
|
+
scanOptions(sf);
|
|
281
|
+
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
// Use sites: where a spelling is actually a Select value.
|
|
284
|
+
|
|
285
|
+
/** { spelling, line, where, component, label, constant } */
|
|
286
|
+
const uses = [];
|
|
287
|
+
for (const el of facts.elements) {
|
|
288
|
+
if (!el.isDs || !SELECT_FAMILY.has(el.base)) continue;
|
|
289
|
+
for (const attribute of VALUE_ATTRIBUTES) {
|
|
290
|
+
const prop = el.props.get(attribute);
|
|
291
|
+
if (!prop) continue;
|
|
292
|
+
const direct = literalValue(prop);
|
|
293
|
+
const spellings = direct !== null ? [direct] : resolveFromExpression(prop);
|
|
294
|
+
for (const spelling of spellings) {
|
|
295
|
+
uses.push({
|
|
296
|
+
spelling,
|
|
297
|
+
line: prop.line,
|
|
298
|
+
where: `${attribute} on ${el.component}`,
|
|
299
|
+
component: el.component,
|
|
300
|
+
label:
|
|
301
|
+
el.base === "SelectItem" ? childText(el.node) : (optionLabels.get(spelling) ?? null),
|
|
302
|
+
constant: prop.identifiers.find((id) => facts.constants.has(id)) ?? null,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (el.spread && !el.props.has("value") && !el.props.has("defaultValue")) {
|
|
307
|
+
notTransformed.push({
|
|
308
|
+
line: el.line,
|
|
309
|
+
reason: "spread-props",
|
|
310
|
+
detail: `<${el.component} {...props}> — the value is spread in, so the scan cannot read it`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const record of optionRecords) {
|
|
315
|
+
uses.push({
|
|
316
|
+
spelling: record.spelling,
|
|
317
|
+
line: record.line,
|
|
318
|
+
where: "option value",
|
|
319
|
+
component: null,
|
|
320
|
+
label: record.label,
|
|
321
|
+
constant: record.constant,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The spellings a `value={…}` expression can hold. The empty string is
|
|
327
|
+
* excluded on purpose: inside an expression it is the *result* of a
|
|
328
|
+
* conversion (`x === SENTINEL ? "" : x`), not a `value=""` the control is
|
|
329
|
+
* given, and `empty-string-value` is about the latter.
|
|
330
|
+
*/
|
|
331
|
+
function resolveFromExpression(prop) {
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const id of prop.identifiers) {
|
|
334
|
+
const constant = facts.constants.get(id);
|
|
335
|
+
if (constant) out.push(constant.text);
|
|
336
|
+
}
|
|
337
|
+
for (const literal of prop.literals) out.push(literal);
|
|
338
|
+
return [...new Set(out)].filter((s) => s !== "");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The text children of a `<SelectItem>`, its visible label. */
|
|
342
|
+
function childText(node) {
|
|
343
|
+
if (!node || !ts.isJsxElement(node)) return null;
|
|
344
|
+
const parts = node.children
|
|
345
|
+
.filter((c) => ts.isJsxText(c))
|
|
346
|
+
.map((c) => c.text.trim())
|
|
347
|
+
.filter(Boolean);
|
|
348
|
+
return parts.length ? parts.join(" ").slice(0, 80) : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// -------------------------------------------------------------------------
|
|
352
|
+
// Classification.
|
|
353
|
+
|
|
354
|
+
const reported = new Set();
|
|
355
|
+
const reportedSpellings = new Set();
|
|
356
|
+
for (const use of uses) {
|
|
357
|
+
const key = `${use.line}:${use.spelling}:${use.where}`;
|
|
358
|
+
if (reported.has(key)) continue;
|
|
359
|
+
reported.add(key);
|
|
360
|
+
const trip = roundTrips.get(use.spelling) ?? null;
|
|
361
|
+
const labelled = use.label !== null && NO_VALUE_LABEL.test(use.label);
|
|
362
|
+
const named = use.constant !== null && SENTINEL_NAME.test(use.constant);
|
|
363
|
+
|
|
364
|
+
let rule = null;
|
|
365
|
+
let severity = SEVERITY.medium;
|
|
366
|
+
let confidence = "high";
|
|
367
|
+
if (use.spelling === "") {
|
|
368
|
+
rule = "empty-string-value";
|
|
369
|
+
severity = SEVERITY.high;
|
|
370
|
+
} else if (DUNDER.test(use.spelling)) {
|
|
371
|
+
rule = "sentinel-dunder";
|
|
372
|
+
severity = trip ? SEVERITY.high : SEVERITY.medium;
|
|
373
|
+
} else if (named) {
|
|
374
|
+
rule = "sentinel-constant";
|
|
375
|
+
severity = trip ? SEVERITY.high : SEVERITY.medium;
|
|
376
|
+
confidence = trip || labelled ? "high" : "medium";
|
|
377
|
+
} else if (NO_VALUE_WORDS.has(use.spelling.toLowerCase())) {
|
|
378
|
+
// A known word is only a sentinel with evidence: `"none"` is also a
|
|
379
|
+
// real deposit type and `"all"` a real filter the API understands.
|
|
380
|
+
if (!trip && !labelled) continue;
|
|
381
|
+
rule = "sentinel-word";
|
|
382
|
+
severity = trip ? SEVERITY.high : SEVERITY.medium;
|
|
383
|
+
confidence = trip ? "high" : "medium";
|
|
384
|
+
} else if (trip) {
|
|
385
|
+
// A spelling no list would have predicted. A conditional that maps it
|
|
386
|
+
// to nothing is conclusive; a `??` default inside the Select's own
|
|
387
|
+
// value is weaker — it may be a deliberate default role or currency
|
|
388
|
+
// rather than a stand-in for "nothing picked".
|
|
389
|
+
rule = "sentinel-discovered";
|
|
390
|
+
severity = trip.how === "conditional" ? SEVERITY.high : SEVERITY.medium;
|
|
391
|
+
confidence = trip.how === "conditional" ? "high" : "medium";
|
|
392
|
+
} else continue;
|
|
393
|
+
|
|
394
|
+
reportedSpellings.add(use.spelling);
|
|
395
|
+
findings.push({
|
|
396
|
+
line: use.line,
|
|
397
|
+
registryId: "C-SELECT-EMPTY",
|
|
398
|
+
gate: null,
|
|
399
|
+
severity,
|
|
400
|
+
rule,
|
|
401
|
+
scope: use.where,
|
|
402
|
+
component: use.component,
|
|
403
|
+
match: JSON.stringify(use.spelling),
|
|
404
|
+
detail: {
|
|
405
|
+
spelling: use.spelling,
|
|
406
|
+
constant: use.constant,
|
|
407
|
+
label: use.label,
|
|
408
|
+
mapsTo: trip?.mapsTo ?? null,
|
|
409
|
+
conversionLine: trip?.line ?? null,
|
|
410
|
+
},
|
|
411
|
+
confidence,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// The conversion site itself: the line where the server contract is
|
|
416
|
+
// decided, reported once per reported spelling.
|
|
417
|
+
for (const [spelling, trip] of [...roundTrips].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
418
|
+
if (!reportedSpellings.has(spelling)) continue;
|
|
419
|
+
findings.push({
|
|
420
|
+
line: trip.line,
|
|
421
|
+
registryId: "C-SELECT-EMPTY",
|
|
422
|
+
gate: null,
|
|
423
|
+
severity: SEVERITY.medium,
|
|
424
|
+
rule: "sentinel-conversion",
|
|
425
|
+
scope: trip.how,
|
|
426
|
+
component: null,
|
|
427
|
+
match: trip.text,
|
|
428
|
+
detail: { spelling, mapsTo: trip.mapsTo, constant: null, label: null, conversionLine: null },
|
|
429
|
+
confidence: trip.how === "conditional" ? "high" : "medium",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
findings,
|
|
435
|
+
notTransformed,
|
|
436
|
+
parseErrors: facts.parseErrors,
|
|
437
|
+
context: {
|
|
438
|
+
sentinels: [...reportedSpellings].sort(),
|
|
439
|
+
roundTrips: [...roundTrips.keys()].sort(),
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* Optional document defaults (W1-15).
|
|
2
|
+
*
|
|
3
|
+
* Hand-written, not generated: it carries no token values, only references.
|
|
4
|
+
*
|
|
5
|
+
* `styles.css` ships a Tailwind preflight, `h1`–`h4` element styles, a global
|
|
6
|
+
* number-spinner rule, a global `::selection` rule and a body font size into
|
|
7
|
+
* every host. Target architecture §13 gives all of that back to the
|
|
8
|
+
* application, and this file is the small, opt-in replacement for the part an
|
|
9
|
+
* application usually does want: the document's background, ink, font and
|
|
10
|
+
* colour scheme, expressed as references to `--ds-*` tokens.
|
|
11
|
+
*
|
|
12
|
+
* What this file deliberately does NOT contain:
|
|
13
|
+
* - no preflight and no reset. DS components are written to be correct given
|
|
14
|
+
* a modern reset, which every known consumer already has (an app that has
|
|
15
|
+
* none needs its own; §13).
|
|
16
|
+
* - no element rules beyond `html` and `body`. No `h1`–`h4` (that is what
|
|
17
|
+
* `Heading` is for), no `input[type=number]` spinner rule (scoped to
|
|
18
|
+
* `NumberInput` instead), no global `*` box-sizing, no global
|
|
19
|
+
* reduced-motion override (durations collapse at the token level).
|
|
20
|
+
* - no `@import url(…)`: the design system loads no fonts. The application
|
|
21
|
+
* owns font loading and sets `--ds-font-sans` (§14, D2). Until it does,
|
|
22
|
+
* `--ds-font-sans` resolves to whatever `css/tokens.css` declares.
|
|
23
|
+
* - no `font-size`. `styles.css` sets the body to 14px; this file does not
|
|
24
|
+
* restore that, because a document base size is the application's
|
|
25
|
+
* decision. Moving an app to the new CSS mode is a deliberate visual
|
|
26
|
+
* change for exactly this kind of reason (ADR-004 rule 10, plan §12.4).
|
|
27
|
+
*
|
|
28
|
+
* Importing the file is the opt-in. Everything in it sits in `@layer base`, so
|
|
29
|
+
* an application's own base rules — declared later in the same layer — win,
|
|
30
|
+
* and any utility in a higher layer wins over all of it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
@layer theme, base, ds, components, utilities;
|
|
34
|
+
|
|
35
|
+
@layer base {
|
|
36
|
+
/* Tells the browser which form controls, scrollbars and default canvas to
|
|
37
|
+
* render. Both spellings of the scope, as everywhere in the new mode. */
|
|
38
|
+
html {
|
|
39
|
+
color-scheme: light;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
html:where([data-ds-scheme="dark"], .dark) {
|
|
43
|
+
color-scheme: dark;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
body {
|
|
47
|
+
background-color: var(--ds-color-canvas);
|
|
48
|
+
color: var(--ds-color-content);
|
|
49
|
+
font-family: var(--ds-font-sans);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Selection ink. Scoped to the document body rather than written as a bare
|
|
53
|
+
* `::selection`, so the rule cannot reach a shadow host or a detached
|
|
54
|
+
* subtree the application renders outside `<body>`. */
|
|
55
|
+
body::selection,
|
|
56
|
+
body *::selection {
|
|
57
|
+
background-color: var(--ds-color-action-brand-subtle-bg);
|
|
58
|
+
color: var(--ds-color-content);
|
|
59
|
+
}
|
|
60
|
+
}
|