basalt-ui 1.20.0 → 1.22.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/AGENTS.md +11 -5
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +231 -50
- package/README.md +57 -10
- package/agent/rules/basalt-app.md +49 -2
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +78 -16
- package/agent/skills/basalt-app/SKILL.md +5 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +218 -69
- package/configs/oxlint-plugin.test.ts +331 -9
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/cli/index.d.ts +55 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +521 -71
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +114 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +216 -33
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +4 -2
- package/dist/vite.d.ts +28 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +978 -133
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +352 -1
- package/src/cli/waiver-audit.test.ts +211 -0
- package/src/guard/check-source.test.ts +638 -1
- package/src/guard/index.ts +556 -63
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +158 -6
- package/src/vite.ts +176 -34
package/src/guard/index.ts
CHANGED
|
@@ -247,11 +247,93 @@ const INLINE_SPACING = new RegExp(
|
|
|
247
247
|
* there is no prop form, and the emitted `--vx-space-*` set is basalt's own component one-offs
|
|
248
248
|
* (`--vx-space-toc-sub-indent`), not a consumer surface. Without this escape a consumer's CSS
|
|
249
249
|
* module had no legal way to write a 4px cluster gap: no token to reach for, `theme-allow` on every
|
|
250
|
-
* line (which `basalt-tokens.md` explicitly warns against), or `exemptRules` — which
|
|
251
|
-
* path segments and so
|
|
250
|
+
* line (which `basalt-tokens.md` explicitly warns against), or `exemptRules` — which at the time
|
|
251
|
+
* matched whole path segments only and so could not express `*.module.css` (it can now, see
|
|
252
|
+
* `exemptPatternMatches`; the escape stays because a per-file exemption is still the wrong shape
|
|
253
|
+
* for a value that has no token).
|
|
252
254
|
*/
|
|
253
255
|
const MICRO_SPACING_CEILING_PX = 10
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Object-literal openers that make the properties under them CSS.
|
|
259
|
+
*
|
|
260
|
+
* A UNITLESS spacing number is only a length by React's inline-style convention, and that
|
|
261
|
+
* convention only holds inside a style object. `const FIT_BOUNDS_OPTIONS = { padding: 48, duration:
|
|
262
|
+
* 0 }` is a maplibre viewport inset measured in MAP pixels — there is no Mantine token that could
|
|
263
|
+
* express it, and "use p/m/gap with xs..xl" is not a thing that can be done to it. The same shape
|
|
264
|
+
* arrives from every canvas/map/layout library a consumer wraps, so this is a class, not a case.
|
|
265
|
+
*
|
|
266
|
+
* A value carrying a UNIT (`'12px'`, `1.5rem`) is exempt from this test entirely — a unit IS the
|
|
267
|
+
* evidence that the number is CSS, wherever it was written.
|
|
268
|
+
*/
|
|
269
|
+
/** `style={{`, `styles={{`, `sx={{`, `css={{` — the object under it IS CSS by construction. */
|
|
270
|
+
const STYLE_ATTRIBUTE_OPENER = /\b(?:style|styles|sx|css)\s*=\s*\{?\s*$/
|
|
271
|
+
|
|
272
|
+
/** `const wrapperStyle: CSSProperties = {` — the hoisted form; name and type are both evidence. */
|
|
273
|
+
const OBJECT_DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::([^=]*))?=\s*$/
|
|
274
|
+
|
|
275
|
+
/** A name that says "these are styles" on its own, without a `style={…}` use site to confirm it. */
|
|
276
|
+
const STYLE_OBJECT_NAME = /(?:Style|Styles|Css)$|^(?:style|styles|css|sx)$/
|
|
277
|
+
|
|
278
|
+
/** A type annotation that says the same thing — `CSSProperties`, `CSSObject`, `MantineStyleProp`. */
|
|
279
|
+
const STYLE_OBJECT_TYPE = /\bCSS(?:Properties|Object)\b|\bStyle(?:s|Prop)?\b/
|
|
280
|
+
|
|
281
|
+
/** `style={someStyleConst}` / `styles={parts}` — collected file-wide, so a hoisted object is known. */
|
|
282
|
+
const STYLE_BOUND_IDENTIFIER = /\b(?:style|styles|sx|css)=\{\s*([A-Za-z_$][\w$]*)\s*\}/g
|
|
283
|
+
|
|
284
|
+
/** A property key introducing a nested object — `input: {`, `'&:hover': {`, `root: {`. */
|
|
285
|
+
const NESTED_OBJECT_KEY = /(?:[\w$]+|'[^']*'|"[^"]*"|\[[^\]]*\])\s*:\s*$/
|
|
286
|
+
|
|
287
|
+
/** How far back {@link isStyleObjectContext} walks before giving up. Bounded so a huge file can't stall. */
|
|
288
|
+
const MAX_STYLE_CONTEXT_SCAN = 4000
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Is the match at `at` inside an inline-style object literal?
|
|
292
|
+
*
|
|
293
|
+
* Walks out through enclosing `{` openers (a nested part like `styles={{ input: { … } }}` keeps
|
|
294
|
+
* walking) and asks what introduced the outermost one: a style ATTRIBUTE, or a `const` whose name
|
|
295
|
+
* says styles or which some `style={…}` in the file binds. Everything else — a call argument, an
|
|
296
|
+
* options bag, a plain record — is not CSS.
|
|
297
|
+
*
|
|
298
|
+
* Consulted only for a UNITLESS number, where the alternative is reporting a violation with no
|
|
299
|
+
* possible remedy. A value carrying a unit is CSS wherever it was written and never reaches here.
|
|
300
|
+
*/
|
|
301
|
+
function isStyleObjectContext(
|
|
302
|
+
codeText: string,
|
|
303
|
+
at: number,
|
|
304
|
+
styleIdentifiers: ReadonlySet<string>,
|
|
305
|
+
): boolean {
|
|
306
|
+
let depth = 0
|
|
307
|
+
const stop = Math.max(0, at - MAX_STYLE_CONTEXT_SCAN)
|
|
308
|
+
for (let i = at - 1; i >= stop; i--) {
|
|
309
|
+
const ch = codeText[i]
|
|
310
|
+
if (ch === '}') depth++
|
|
311
|
+
else if (ch === '{') {
|
|
312
|
+
if (depth > 0) {
|
|
313
|
+
depth--
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
const before = codeText.slice(Math.max(0, i - 120), i)
|
|
317
|
+
if (STYLE_ATTRIBUTE_OPENER.test(before)) return true
|
|
318
|
+
const declaration = OBJECT_DECLARATION.exec(before)
|
|
319
|
+
if (declaration !== null) {
|
|
320
|
+
const [, name = '', annotation = ''] = declaration
|
|
321
|
+
return (
|
|
322
|
+
STYLE_OBJECT_NAME.test(name) ||
|
|
323
|
+
STYLE_OBJECT_TYPE.test(annotation) ||
|
|
324
|
+
styleIdentifiers.has(name)
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
// `{{` — a JSX expression container around the style object; keep walking out to the `=`.
|
|
328
|
+
if (before.endsWith('{')) continue
|
|
329
|
+
// A nested style part (`styles={{ input: { … } }}`) — the key names a part, not a context.
|
|
330
|
+
if (NESTED_OBJECT_KEY.test(before)) continue
|
|
331
|
+
return false
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
255
337
|
/** The root font size `rem` values resolve against — the same 16 the token layer's `pxRem` uses. */
|
|
256
338
|
const ROOT_FONT_SIZE_PX = 16
|
|
257
339
|
|
|
@@ -456,13 +538,6 @@ function isChartFile(relPath: string): boolean {
|
|
|
456
538
|
return relPath.includes('/charts/') && !AXIS_WRAPPER_FILE.test(relPath)
|
|
457
539
|
}
|
|
458
540
|
|
|
459
|
-
/**
|
|
460
|
-
* Whether `kind` is exempted at `relPath` via `cfg.exemptRules` — the config-driven, per-rule
|
|
461
|
-
* counterpart to `isChartFile`'s hardcoded path scoping. A pattern matches when `relPath` split on
|
|
462
|
-
* `/` includes it as a WHOLE segment (a trailing `/` is stripped first, so `'agent'` and `'agent/'`
|
|
463
|
-
* are equivalent); a substring match against one longer segment (e.g. `'age'` against `agent`)
|
|
464
|
-
* never counts.
|
|
465
|
-
*/
|
|
466
541
|
/**
|
|
467
542
|
* Kinds that inherit another kind's `exemptRules` entry. A grace-minor kind that exists only to
|
|
468
543
|
* carry a WIDENING of an established kind is the same rule to a consumer, so an exemption written
|
|
@@ -475,6 +550,59 @@ const EXEMPT_RULE_ALIASES: Partial<Record<GuardKind, GuardKind>> = {
|
|
|
475
550
|
'css-raw-surface': 'raw-surface',
|
|
476
551
|
}
|
|
477
552
|
|
|
553
|
+
/** `./` and any trailing `/` are noise — `'agent'`, `'agent/'` and `'./agent'` are one pattern. */
|
|
554
|
+
function normalizeExemptPattern(pattern: string): string {
|
|
555
|
+
return pattern.trim().replace(/^\.\//, '').replace(/\/+$/, '')
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** `*` stops at a `/`, `**` does not, `?` is one non-`/` character. Everything else is literal. */
|
|
559
|
+
function exemptGlobToRegExp(pattern: string): RegExp {
|
|
560
|
+
const body = pattern
|
|
561
|
+
.split(/(\*\*\/|\*\*|\*|\?)/)
|
|
562
|
+
.map((part) => {
|
|
563
|
+
if (part === '**/') return '(?:.*/)?'
|
|
564
|
+
if (part === '**') return '.*'
|
|
565
|
+
if (part === '*') return '[^/]*'
|
|
566
|
+
if (part === '?') return '[^/]'
|
|
567
|
+
return part.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
568
|
+
})
|
|
569
|
+
.join('')
|
|
570
|
+
return new RegExp(`^${body}$`)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Does one `exemptRules` pattern match `relPath`?
|
|
575
|
+
*
|
|
576
|
+
* Three shapes, tried in the order a consumer reaches for them:
|
|
577
|
+
*
|
|
578
|
+
* 1. **A relative path** — `public/site.webmanifest` for one file, `public` or `src/agent` for
|
|
579
|
+
* everything under a directory.
|
|
580
|
+
* 2. **A glob** — `public/*.webmanifest`, `**\/*.module.css`; a glob with no `/` in it is also
|
|
581
|
+
* tried against the BASENAME, so `*.module.css` works the way `site.webmanifest` does.
|
|
582
|
+
* 3. **A bare path segment** — the legacy shape, matching a whole segment anywhere in the path.
|
|
583
|
+
*
|
|
584
|
+
* Only (3) existed, and it was the one shape nobody guesses: rollhook wrote the obvious, correct
|
|
585
|
+
* `"public/site.webmanifest"`, matched nothing, got no diagnostic, and kept reporting. That is a
|
|
586
|
+
* silent no-op exemption, and it became load-bearing the minute `.webmanifest`/`.json` entered the
|
|
587
|
+
* scan — see {@link unmatchedExemptPatterns} for the other half of the fix.
|
|
588
|
+
*/
|
|
589
|
+
function exemptPatternMatches(pattern: string, relPath: string): boolean {
|
|
590
|
+
const p = normalizeExemptPattern(pattern)
|
|
591
|
+
if (p === '') return false
|
|
592
|
+
if (relPath === p || relPath.startsWith(`${p}/`)) return true
|
|
593
|
+
if (/[*?]/.test(p)) {
|
|
594
|
+
const re = exemptGlobToRegExp(p)
|
|
595
|
+
if (re.test(relPath)) return true
|
|
596
|
+
if (!p.includes('/') && re.test(relPath.split('/').pop() ?? '')) return true
|
|
597
|
+
return false
|
|
598
|
+
}
|
|
599
|
+
return !p.includes('/') && relPath.split('/').includes(p)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Whether `kind` is exempted at `relPath` via `cfg.exemptRules` — the config-driven, per-rule
|
|
604
|
+
* counterpart to `isChartFile`'s hardcoded path scoping.
|
|
605
|
+
*/
|
|
478
606
|
function isRuleExempt(
|
|
479
607
|
kind: GuardKind,
|
|
480
608
|
relPath: string,
|
|
@@ -484,8 +612,46 @@ function isRuleExempt(
|
|
|
484
612
|
if (alias !== undefined && isRuleExempt(alias, relPath, exemptRules)) return true
|
|
485
613
|
const patterns = exemptRules?.[kind]
|
|
486
614
|
if (patterns === undefined || patterns.length === 0) return false
|
|
487
|
-
|
|
488
|
-
|
|
615
|
+
return patterns.some((pattern) => exemptPatternMatches(pattern, relPath))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** One `exemptRules` entry that suppressed nothing, and why — see {@link unmatchedExemptPatterns}. */
|
|
619
|
+
export type UnmatchedExemptPattern = {
|
|
620
|
+
readonly kind: string
|
|
621
|
+
readonly pattern: string
|
|
622
|
+
readonly reason: 'unknown-kind' | 'no-match'
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Every `exemptRules` entry that matched no scanned file, so a run can say so instead of passing.
|
|
627
|
+
*
|
|
628
|
+
* An exemption is a claim ("this rule does not apply here"), and a claim that resolves to nothing is
|
|
629
|
+
* not harmless — it reads as coverage in a config review while enforcing exactly as much as an empty
|
|
630
|
+
* object. rollhook's `["public/site.webmanifest"]` was correct, matched nothing under the
|
|
631
|
+
* segments-only matcher, and reported no diagnostic; the finding it was written for kept failing the
|
|
632
|
+
* build with the config sitting right there looking like the answer. A silent no-op exemption is the
|
|
633
|
+
* same false-green family as a guard that reports green while enforcing nothing.
|
|
634
|
+
*
|
|
635
|
+
* Called once per run by the CLI with every relative path it scanned. Kind keys are checked too: a
|
|
636
|
+
* typo'd kind name is an exemption for a rule that does not exist.
|
|
637
|
+
*/
|
|
638
|
+
export function unmatchedExemptPatterns(
|
|
639
|
+
cfg: GuardConfig,
|
|
640
|
+
scannedRelPaths: readonly string[],
|
|
641
|
+
): UnmatchedExemptPattern[] {
|
|
642
|
+
const out: UnmatchedExemptPattern[] = []
|
|
643
|
+
for (const [kind, patterns] of Object.entries(cfg.exemptRules ?? {})) {
|
|
644
|
+
const known = Object.hasOwn(GUARD_RULES, kind)
|
|
645
|
+
for (const pattern of patterns ?? []) {
|
|
646
|
+
if (!known) {
|
|
647
|
+
out.push({ kind, pattern, reason: 'unknown-kind' })
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
if (scannedRelPaths.some((relPath) => exemptPatternMatches(pattern, relPath))) continue
|
|
651
|
+
out.push({ kind, pattern, reason: 'no-match' })
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return out
|
|
489
655
|
}
|
|
490
656
|
|
|
491
657
|
// ── theme-allow annotations ──────────────────────────────────────────────────────────────────────
|
|
@@ -495,8 +661,12 @@ function isRuleExempt(
|
|
|
495
661
|
* but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
|
|
496
662
|
* accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
|
|
497
663
|
* standalone `.js` file this module must not import (see the plugin's own header).
|
|
664
|
+
*
|
|
665
|
+
* Exported for `check-theme --audit-allows`: an annotation naming one of these is outside the
|
|
666
|
+
* guard's reach, and judging it needs a `oxlint` run rather than a `checkSource` one. Keep in step
|
|
667
|
+
* with the plugin's own `KNOWN_RULE_IDS`; `configs/oxlint-plugin.test.ts` asserts the two agree.
|
|
498
668
|
*/
|
|
499
|
-
const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
|
|
669
|
+
export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
|
|
500
670
|
'no-raw-font-size',
|
|
501
671
|
'raw-size-literal',
|
|
502
672
|
'card-inset',
|
|
@@ -519,10 +689,23 @@ const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
|
|
|
519
689
|
/** The shortest string accepted as a written reason — enough to exclude a stray separator. */
|
|
520
690
|
const MIN_ALLOW_REASON_LENGTH = 4
|
|
521
691
|
|
|
692
|
+
/**
|
|
693
|
+
* How far one annotation reaches.
|
|
694
|
+
*
|
|
695
|
+
* `line` is the default and covers the placements in {@link collectAllowAnnotations}. `file` is the
|
|
696
|
+
* whole-file declaration, and it has to be SPELLED (`theme-allow-file`) rather than inferred: at
|
|
697
|
+
* 1.20.0 a `theme-allow <id> — <reason>` written anywhere in a file was silently promoted to a file
|
|
698
|
+
* declaration by the oxlint plugin, so naming a rule AND giving a reason — the exact shape the
|
|
699
|
+
* guard's own message asks for — was the only legal annotation and it was always whole-file. A
|
|
700
|
+
* consumer who wanted to waive one node and stay policed on the rest could not say so.
|
|
701
|
+
*/
|
|
702
|
+
type AllowScope = 'line' | 'file'
|
|
703
|
+
|
|
522
704
|
/**
|
|
523
705
|
* One parsed `theme-allow` annotation.
|
|
524
706
|
*
|
|
525
|
-
* `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind
|
|
707
|
+
* `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind —
|
|
708
|
+
* and only at `line` scope, never `file` (see {@link annotationCovers}).
|
|
526
709
|
* `unknownRules` holds words that occupied the rule-id slot but name no rule — a typo. Its presence
|
|
527
710
|
* is what makes the parse FAIL CLOSED: `theme-allow raw-hexx — reason` used to consume no id, fall
|
|
528
711
|
* through to `rules: []`, and be read as the blanket form, so one mistyped character escalated a
|
|
@@ -533,6 +716,7 @@ type AllowAnnotation = {
|
|
|
533
716
|
readonly rules: readonly string[]
|
|
534
717
|
readonly unknownRules: readonly string[]
|
|
535
718
|
readonly hasReason: boolean
|
|
719
|
+
readonly scope: AllowScope
|
|
536
720
|
}
|
|
537
721
|
|
|
538
722
|
/** One annotation at the (1-based) line it was WRITTEN on — where `theme-allow-unscoped` reports. */
|
|
@@ -562,7 +746,7 @@ type AllowDeclaration = { readonly line: number; readonly annotation: AllowAnnot
|
|
|
562
746
|
* `constructor` (or `toString`, `valueOf`, …) resolved as a real rule id and silently scoped the
|
|
563
747
|
* waiver to a kind that does not exist.
|
|
564
748
|
*/
|
|
565
|
-
function parseAllowAnnotation(rest: string): AllowAnnotation {
|
|
749
|
+
function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation {
|
|
566
750
|
const rules: string[] = []
|
|
567
751
|
const unknownRules: string[] = []
|
|
568
752
|
let remainder = rest.replace(/^[\s,]+/, '')
|
|
@@ -581,12 +765,15 @@ function parseAllowAnnotation(rest: string): AllowAnnotation {
|
|
|
581
765
|
inIdSlot = /^\s*,/.test(after)
|
|
582
766
|
}
|
|
583
767
|
const reason = remainder.replace(/^(?:—|–|-{1,2}|:)\s*/, '').trim()
|
|
584
|
-
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
|
|
768
|
+
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope }
|
|
585
769
|
}
|
|
586
770
|
|
|
587
771
|
/** How far back a trailing CSS annotation reaches for the declaration it terminates. */
|
|
588
772
|
const MAX_CSS_CONTINUATION_LINES = 8
|
|
589
773
|
|
|
774
|
+
/** How far a comment-only annotation reaches forward through the rest of its own comment block. */
|
|
775
|
+
const MAX_COMMENT_BLOCK_LINES = 8
|
|
776
|
+
|
|
590
777
|
/**
|
|
591
778
|
* A comment opener sitting immediately before the annotation token.
|
|
592
779
|
*
|
|
@@ -601,16 +788,42 @@ const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/
|
|
|
601
788
|
/** A line that is NOTHING but a comment — the placement that waives the line below it. */
|
|
602
789
|
const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
603
790
|
|
|
791
|
+
/**
|
|
792
|
+
* A JSX expression container closing immediately after the comment that filled it — the `*\/}` of a
|
|
793
|
+
* `{/* … *\/}` whose annotation wrapped onto its own line.
|
|
794
|
+
*
|
|
795
|
+
* `stripComments` blanks the comment and leaves the bare `}` behind, so the line read as CODE and
|
|
796
|
+
* the annotation was classified TRAILING — scoped to a line that holds nothing but a brace, and
|
|
797
|
+
* therefore waiving nothing. That is the shape linewatch writes for every hand-composed chart axis
|
|
798
|
+
* (`{/*` on one line, the annotation and `*\/}` on the next), which worked only because the rules it
|
|
799
|
+
* names live in the oxlint plugin, whose own placement test is comment-node-based and never saw the
|
|
800
|
+
* brace. Any guard kind annotated this way was silently unwaivable.
|
|
801
|
+
*
|
|
802
|
+
* Deliberately narrow: the `}` must abut the comment CLOSE. A `}` that is real code (`} // theme-
|
|
803
|
+
* allow …` closing a block) keeps its trailing classification, so the annotation stays on its own
|
|
804
|
+
* line rather than reaching the statement below.
|
|
805
|
+
*/
|
|
806
|
+
const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
|
|
807
|
+
|
|
604
808
|
/**
|
|
605
809
|
* Everything allowed between the comment opener (or the start of the line) and the annotation
|
|
606
810
|
* token, for the token to count as an ANNOTATION rather than prose that mentions one.
|
|
607
811
|
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
812
|
+
* **This gates WAIVING as well as reporting.** It used to gate only `theme-allow-unscoped`, on the
|
|
813
|
+
* argument that a stricter waiver could take a build down over comment placement. The cost of that
|
|
814
|
+
* asymmetry was the opposite and worse: a comment that merely MENTIONS the token in prose parsed as
|
|
815
|
+
* the bare blanket form and switched every kind off on the line below it. linewatch documented its
|
|
816
|
+
* own waivers in a docblock and thereby disarmed the file — the third hole found in this contract
|
|
817
|
+
* in two rounds, and the only one that was a false NEGATIVE.
|
|
818
|
+
*
|
|
819
|
+
* So an annotation must START its comment, or start a line inside one: after `//`, `/*` (or a
|
|
820
|
+
* docblock's `/**`), `<!--`, a block-comment gutter `*`, or nothing but leading whitespace.
|
|
821
|
+
* Everything a consumer actually writes qualifies; a sentence about the escape hatch does not.
|
|
822
|
+
*
|
|
823
|
+
* `/**` is in the alternation because the oxlint plugin's copy of this test always accepted it and
|
|
824
|
+
* this one did not: a one-line `/** theme-allow raw-hex — why *\/` waived under `oxlint` and
|
|
825
|
+
* reported under `check-theme`. The two halves of one contract must not disagree about what an
|
|
826
|
+
* annotation IS — only about which rules each can judge.
|
|
614
827
|
*
|
|
615
828
|
* What the annotation then WAIVES is a separate, fail-closed question — see
|
|
616
829
|
* {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
|
|
@@ -622,19 +835,71 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
622
835
|
* it anyway: the kind that fires is the UNSUPPRESSED one (`raw-hex`), not `theme-allow-unscoped`,
|
|
623
836
|
* and downgrading `raw-hex` to waive-by-default is the hole this closed.
|
|
624
837
|
*/
|
|
625
|
-
const ANNOTATION_PREFIX = /(
|
|
838
|
+
const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*\*?|<!--|^\s*\*)\s*$/
|
|
839
|
+
|
|
840
|
+
/** Escape a config-supplied string for literal use inside a RegExp. */
|
|
841
|
+
function escapeRegExp(value: string): string {
|
|
842
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* The annotation token itself, with its optional `-file` suffix.
|
|
847
|
+
*
|
|
848
|
+
* `(?![\w-])` is what keeps the two forms apart and keeps a longer word from being read as the bare
|
|
849
|
+
* form: `theme-allow-file` is the file declaration, `theme-allow-unscoped` (the KIND NAME, written
|
|
850
|
+
* in prose constantly) is neither — before this it consumed no id, fell through to `rules: []` and
|
|
851
|
+
* parsed as a blanket waiver.
|
|
852
|
+
*/
|
|
853
|
+
function allowTokenRe(allowComment: string): RegExp {
|
|
854
|
+
return new RegExp(`${escapeRegExp(allowComment)}(-file)?(?![\\w-])`, 'g')
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The JSON dialect of the annotation — `"basalt:theme-allow-file": "<ids> — <why>"`.
|
|
859
|
+
*
|
|
860
|
+
* JSON has no comments, so from 1.20.0 (when the scan reached `.webmanifest` / `.json`) every
|
|
861
|
+
* finding in that file class was permanently unwaivable and the printed remedy prescribed a comment
|
|
862
|
+
* the file cannot carry. Two consumers were pushed into a blanket `exemptRules` entry instead — the
|
|
863
|
+
* un-reviewable shape this contract exists to retire. A member key is the one annotation JSON can
|
|
864
|
+
* hold, it carries ids and a reason exactly like the comment form, and every manifest consumer
|
|
865
|
+
* ignores members it does not know.
|
|
866
|
+
*/
|
|
867
|
+
function jsonAllowKeyRe(allowComment: string): RegExp {
|
|
868
|
+
return new RegExp(`"basalt:${escapeRegExp(allowComment)}(-file)?"\\s*:\\s*"([^"]*)"`, 'g')
|
|
869
|
+
}
|
|
626
870
|
|
|
627
871
|
/**
|
|
628
|
-
* Every line an allow annotation WAIVES, keyed by 0-based line index
|
|
629
|
-
*
|
|
872
|
+
* Every line an allow annotation WAIVES, keyed by 0-based line index, plus the file-scoped
|
|
873
|
+
* declarations and every annotation as WRITTEN (for `theme-allow-unscoped`).
|
|
874
|
+
*
|
|
875
|
+
* **The grammar, in full:**
|
|
876
|
+
*
|
|
877
|
+
* ```text
|
|
878
|
+
* theme-allow → this placement, EVERY kind (reports unscoped)
|
|
879
|
+
* theme-allow <id>[, <id>…] [— <why>] → this placement, those kinds (unscoped without a why)
|
|
880
|
+
* theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those kinds
|
|
881
|
+
* "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON/.webmanifest
|
|
882
|
+
* ```
|
|
883
|
+
*
|
|
884
|
+
* Three rules make it unambiguous: the token must START its comment (see {@link ANNOTATION_PREFIX});
|
|
885
|
+
* `-file` is part of the token, not the first word of the reason (see {@link allowTokenRe}); and a
|
|
886
|
+
* bare `theme-allow-file` waives NOTHING, because whole-file blanket immunity off one unnamed
|
|
887
|
+
* comment is the exact thing this contract exists to price (see {@link annotationCovers}).
|
|
888
|
+
*
|
|
889
|
+
* Line scope has three placements, each one a shape people actually write — and the union of them
|
|
890
|
+
* is what makes the escape survive a formatter.
|
|
630
891
|
*
|
|
631
892
|
* 1. **Its own line.** The original rule, unchanged.
|
|
632
|
-
* 2. **The line below, when the annotation line is a comment and nothing else.** This is
|
|
633
|
-
* placement the oxlint plugin has always honored and the only one JSX can express: the
|
|
634
|
-
* line is usually a multi-line opening tag or a `{expr}` child, where a trailing `//` is
|
|
635
|
-
* syntax error or renders as visible text. Requiring the annotation line to be comment-ONLY is
|
|
893
|
+
* 2. **The first CODE line below, when the annotation line is a comment and nothing else.** This is
|
|
894
|
+
* the placement the oxlint plugin has always honored and the only one JSX can express: the
|
|
895
|
+
* reported line is usually a multi-line opening tag or a `{expr}` child, where a trailing `//` is
|
|
896
|
+
* a syntax error or renders as visible text. Requiring the annotation line to be comment-ONLY is
|
|
636
897
|
* what keeps a TRAILING comment scoped to its own line — otherwise
|
|
637
|
-
* `const a = '#f00' // theme-allow` would silently waive the next line too.
|
|
898
|
+
* `const a = '#f00' // theme-allow` would silently waive the next line too. The rest of the
|
|
899
|
+
* comment BLOCK is walked through rather than counted as the target line: a docblock's `*\/`, or
|
|
900
|
+
* a reason that wrapped onto a second line, used to absorb the whole waiver and the natural
|
|
901
|
+
* shape silently waived nothing. A blank line ends the block — that separation is how people say
|
|
902
|
+
* "this comment is not about the next statement".
|
|
638
903
|
* 3. **The rest of the CSS declaration it terminates.** The shipped `oxfmt` reflows
|
|
639
904
|
* `background-color: var(--x, #232326); /* theme-allow *\/` into four lines with the hex on one
|
|
640
905
|
* and the comment on another — two shipped tools pulling in opposite directions, and the
|
|
@@ -653,8 +918,13 @@ function collectAllowAnnotations(
|
|
|
653
918
|
codeLines: readonly string[],
|
|
654
919
|
allowComment: string,
|
|
655
920
|
syntax: GuardSyntax,
|
|
656
|
-
): {
|
|
921
|
+
): {
|
|
922
|
+
waivers: Map<number, AllowAnnotation[]>
|
|
923
|
+
fileScoped: AllowAnnotation[]
|
|
924
|
+
declared: AllowDeclaration[]
|
|
925
|
+
} {
|
|
657
926
|
const byLine = new Map<number, AllowAnnotation[]>()
|
|
927
|
+
const fileScoped: AllowAnnotation[] = []
|
|
658
928
|
const declared: AllowDeclaration[] = []
|
|
659
929
|
const add = (index: number, annotation: AllowAnnotation): void => {
|
|
660
930
|
const list = byLine.get(index) ?? []
|
|
@@ -662,45 +932,92 @@ function collectAllowAnnotations(
|
|
|
662
932
|
byLine.set(index, list)
|
|
663
933
|
}
|
|
664
934
|
|
|
935
|
+
/** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
|
|
936
|
+
const record = (index: number, annotation: AllowAnnotation, trailing: boolean): void => {
|
|
937
|
+
declared.push({ line: index + 1, annotation })
|
|
938
|
+
if (annotation.scope === 'file') {
|
|
939
|
+
fileScoped.push(annotation)
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
add(index, annotation)
|
|
943
|
+
if (!trailing) {
|
|
944
|
+
// The first line below that is not itself comment. A docblock's `*/` (or the rest of a
|
|
945
|
+
// wrapped reason) sits between the annotation and the code it is about, so `index + 1` alone
|
|
946
|
+
// meant the natural multi-line shape silently waived nothing — argo hit it three times in one
|
|
947
|
+
// upgrade, each looking like a correct annotation. A BLANK line still ends the block: that is
|
|
948
|
+
// the separation people use to mean "this comment is not about the next statement".
|
|
949
|
+
for (let ahead = 1; ahead <= MAX_COMMENT_BLOCK_LINES; ahead++) {
|
|
950
|
+
const raw = lines[index + ahead] ?? ''
|
|
951
|
+
add(index + ahead, annotation)
|
|
952
|
+
if (raw.trim() !== '' && (codeLines[index + ahead] ?? '').trim() !== '') break
|
|
953
|
+
if (raw.trim() === '') break
|
|
954
|
+
}
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
if (syntax !== 'css') return
|
|
958
|
+
for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
|
|
959
|
+
const previous = (codeLines[index - back] ?? '').trim()
|
|
960
|
+
if (previous === '' || /[;{}]$/.test(previous)) break
|
|
961
|
+
add(index - back, annotation)
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const token = allowTokenRe(allowComment)
|
|
966
|
+
const jsonKey = jsonAllowKeyRe(allowComment)
|
|
967
|
+
|
|
665
968
|
for (let i = 0; i < lines.length; i++) {
|
|
666
969
|
const line = lines[i] ?? ''
|
|
667
970
|
const stripped = codeLines[i] ?? ''
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const annotation = parseAllowAnnotation(line.slice(from))
|
|
677
|
-
if (ANNOTATION_PREFIX.test(line.slice(0, at))) declared.push({ line: i + 1, annotation })
|
|
678
|
-
add(i, annotation)
|
|
679
|
-
if (isCommentOnlyLine) {
|
|
680
|
-
add(i + 1, annotation)
|
|
681
|
-
continue
|
|
682
|
-
}
|
|
683
|
-
if (syntax !== 'css') continue
|
|
684
|
-
for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
|
|
685
|
-
const previous = (codeLines[i - back] ?? '').trim()
|
|
686
|
-
if (previous === '' || /[;{}]$/.test(previous)) break
|
|
687
|
-
add(i - back, annotation)
|
|
971
|
+
const code = stripped.trim()
|
|
972
|
+
const isCommentOnlyLine =
|
|
973
|
+
code === '' || COMMENT_ONLY_LINE.test(line) || (code === '}' && JSX_COMMENT_CLOSE.test(line))
|
|
974
|
+
|
|
975
|
+
// The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
|
|
976
|
+
if (syntax === 'markup') {
|
|
977
|
+
for (const m of line.matchAll(jsonKey)) {
|
|
978
|
+
record(i, parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'), false)
|
|
688
979
|
}
|
|
689
980
|
}
|
|
981
|
+
|
|
982
|
+
for (const m of line.matchAll(token)) {
|
|
983
|
+
const at = m.index
|
|
984
|
+
const before = line.slice(0, at)
|
|
985
|
+
// Not inside a comment (neither witness agrees) → real code, e.g. this file's own rule table.
|
|
986
|
+
if (stripped[at] !== ' ' && !COMMENT_OPENER_BEFORE.test(before)) continue
|
|
987
|
+
// Inside a comment, but not at the START of one → prose that mentions the token, not an
|
|
988
|
+
// annotation. See ANNOTATION_PREFIX: this is what stops a file documenting its own waivers
|
|
989
|
+
// from disarming itself.
|
|
990
|
+
if (!ANNOTATION_PREFIX.test(before)) continue
|
|
991
|
+
const rest = line.slice(at + m[0].length)
|
|
992
|
+
record(
|
|
993
|
+
i,
|
|
994
|
+
parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
|
|
995
|
+
!isCommentOnlyLine,
|
|
996
|
+
)
|
|
997
|
+
}
|
|
690
998
|
}
|
|
691
|
-
return { waivers: byLine, declared }
|
|
999
|
+
return { waivers: byLine, fileScoped, declared }
|
|
692
1000
|
}
|
|
693
1001
|
|
|
694
1002
|
/**
|
|
695
|
-
* Does
|
|
1003
|
+
* Does one annotation cover `kind`?
|
|
696
1004
|
*
|
|
697
|
-
* Only a BARE annotation (no ids at all, known or unknown) covers every kind. One that
|
|
698
|
-
* a rule id and missed covers exactly the ids it got right — nothing widens on a typo.
|
|
1005
|
+
* Only a BARE **line** annotation (no ids at all, known or unknown) covers every kind. One that
|
|
1006
|
+
* reached for a rule id and missed covers exactly the ids it got right — nothing widens on a typo.
|
|
1007
|
+
*
|
|
1008
|
+
* A bare `theme-allow-file` covers NOTHING. The blanket form is tolerable on one line, where a
|
|
1009
|
+
* reader sees what it sits on; over a whole file it is `exemptRules` without the config review, and
|
|
1010
|
+
* the widest waiver in the contract must be the one that has to name what it waives.
|
|
699
1011
|
*/
|
|
1012
|
+
function annotationCovers(annotation: AllowAnnotation, kind: GuardKind): boolean {
|
|
1013
|
+
if (annotation.rules.includes(kind)) return true
|
|
1014
|
+
if (annotation.scope === 'file') return false
|
|
1015
|
+
return annotation.rules.length === 0 && annotation.unknownRules.length === 0
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Does any annotation on this line cover `kind`? */
|
|
700
1019
|
function annotationsCover(annotations: AllowAnnotation[] | undefined, kind: GuardKind): boolean {
|
|
701
|
-
return (annotations ?? []).some(
|
|
702
|
-
(a) => (a.rules.length === 0 && a.unknownRules.length === 0) || a.rules.includes(kind),
|
|
703
|
-
)
|
|
1020
|
+
return (annotations ?? []).some((a) => annotationCovers(a, kind))
|
|
704
1021
|
}
|
|
705
1022
|
|
|
706
1023
|
/** The 3 comment dialects the stripper understands, resolved from the file's own extension. */
|
|
@@ -1282,14 +1599,14 @@ export const GUARD_RULES = {
|
|
|
1282
1599
|
kind: 'theme-allow-unscoped',
|
|
1283
1600
|
pattern: /theme-allow/, // handled inline (annotation pass); entry keeps the registry complete
|
|
1284
1601
|
message:
|
|
1285
|
-
'theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing.',
|
|
1602
|
+
'theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing. `theme-allow-file <rule-id> — <why>` declares the whole file, and must name its ids: a bare one waives nothing.',
|
|
1286
1603
|
},
|
|
1287
1604
|
'surface-shadow-override': {
|
|
1288
1605
|
kind: 'surface-shadow-override',
|
|
1289
1606
|
pattern: SHADOW_DECL, // handled inline (value is judged, not just matched)
|
|
1290
1607
|
enabled: (cfg: GuardConfig) => cfg.rawSurface,
|
|
1291
1608
|
message:
|
|
1292
|
-
'Token-composed boxShadow that REPLACES
|
|
1609
|
+
'Token-composed boxShadow that REPLACES surface depth — a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with the depth token for its tier (`${VX.shadowCard}, <your ring>`; a fixed/floating surface wants --vx-shadow-overlay) rather than instead of it.',
|
|
1293
1610
|
},
|
|
1294
1611
|
'css-raw-surface': {
|
|
1295
1612
|
kind: 'css-raw-surface',
|
|
@@ -1316,6 +1633,54 @@ export const GUARD_RULES = {
|
|
|
1316
1633
|
},
|
|
1317
1634
|
} as const satisfies Record<GuardKind, GuardRule>
|
|
1318
1635
|
|
|
1636
|
+
/**
|
|
1637
|
+
* The one-line remedy for `kind`, as a report reads it: `raw-hex: Route color through VX.* / …`.
|
|
1638
|
+
*
|
|
1639
|
+
* The registry has always carried a remedy per kind; `check-theme`'s `Fix:` epilogue read a
|
|
1640
|
+
* hand-duplicated subset instead, and the subset was missing every kind added at 1.20.0. Since all
|
|
1641
|
+
* five of those ship `warn` under grace, the findings whose whole argument is "this looks correct
|
|
1642
|
+
* and is not" arrived with no argument at all — just "add a `theme-allow`", which reads as advice to
|
|
1643
|
+
* waive. Sourcing the epilogue from here retires the duplicate rather than extending it.
|
|
1644
|
+
*/
|
|
1645
|
+
export function guardKindRemedy(kind: GuardKind): string {
|
|
1646
|
+
return `${kind}: ${(GUARD_RULES[kind] as GuardRule).message}`
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** JSON and its manifest dialect — the file class with no comment syntax to carry an annotation. */
|
|
1650
|
+
const JSON_FAMILY_FILE = /\.(?:webmanifest|json)$/
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* How to waive a finding **in this file's syntax** — the closer a report prints under `Fix:`.
|
|
1654
|
+
*
|
|
1655
|
+
* `check-theme` printed one closer for every file class, and from 1.20.0 (when the scan reached
|
|
1656
|
+
* `.webmanifest` / `.json`) it prescribed a `theme-allow` comment to files that cannot hold a
|
|
1657
|
+
* comment. Two consumers read that, found no route, and fell back to a blanket `exemptRules` entry —
|
|
1658
|
+
* the un-reviewable shape the release was spent retiring.
|
|
1659
|
+
*
|
|
1660
|
+
* A manifest gets the sharper answer first, because a hex there can never be *right*: a manifest
|
|
1661
|
+
* cannot reference a CSS custom property, so the value is a hand-copy of a token that drifts the day
|
|
1662
|
+
* the palette moves (which is exactly what both consumers found — two dead colours). The remedy is
|
|
1663
|
+
* to stop hand-writing the file: `basaltAppPlugin` emits it from `SURFACE.bg`. The annotation is the
|
|
1664
|
+
* fallback for a manifest basalt does not generate.
|
|
1665
|
+
*/
|
|
1666
|
+
export function guardWaiverHint(relPath: string): string {
|
|
1667
|
+
if (relPath.endsWith('.webmanifest')) {
|
|
1668
|
+
return (
|
|
1669
|
+
'A manifest cannot reference a CSS variable, so a hex here is a hand-copy that drifts: let ' +
|
|
1670
|
+
'basaltAppPlugin emit it (`manifest: { … }` in vite.config.ts) instead of hand-writing it. ' +
|
|
1671
|
+
'For a manifest basalt does not generate, declare the exception with a ' +
|
|
1672
|
+
'`"basalt:theme-allow-file": "raw-hex — <why>"` member.'
|
|
1673
|
+
)
|
|
1674
|
+
}
|
|
1675
|
+
if (JSON_FAMILY_FILE.test(relPath)) {
|
|
1676
|
+
return (
|
|
1677
|
+
'JSON has no comments — declare a deliberate exception with a ' +
|
|
1678
|
+
'`"basalt:theme-allow-file": "<rule-id> — <why>"` member.'
|
|
1679
|
+
)
|
|
1680
|
+
}
|
|
1681
|
+
return 'Add a `theme-allow <rule-id> — <why>` comment for a deliberate exception (`theme-allow-file` declares the whole file).'
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1319
1684
|
/** Whether `kind`'s rule fires for `relPath` — `appliesTo` is opt-in; absent means always applies. */
|
|
1320
1685
|
function ruleApplies(kind: GuardKind, relPath: string): boolean {
|
|
1321
1686
|
const appliesTo = (GUARD_RULES[kind] as GuardRule).appliesTo
|
|
@@ -1365,11 +1730,24 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1365
1730
|
const syntax = guardSyntaxFor(relPath)
|
|
1366
1731
|
const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
|
|
1367
1732
|
const codeLines = codeText.split('\n')
|
|
1368
|
-
const { waivers, declared } = collectAllowAnnotations(
|
|
1733
|
+
const { waivers, fileScoped, declared } = collectAllowAnnotations(
|
|
1734
|
+
lines,
|
|
1735
|
+
codeLines,
|
|
1736
|
+
cfg.allowComment,
|
|
1737
|
+
syntax,
|
|
1738
|
+
)
|
|
1369
1739
|
|
|
1370
|
-
/**
|
|
1740
|
+
/** Kinds a `theme-allow-file` declared for the whole file — resolved once, not per finding. */
|
|
1741
|
+
const fileWaived = new Set<GuardKind>()
|
|
1742
|
+
for (const annotation of fileScoped) {
|
|
1743
|
+
for (const rule of annotation.rules) {
|
|
1744
|
+
if (Object.hasOwn(GUARD_RULES, rule)) fileWaived.add(rule as GuardKind)
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the full grammar. */
|
|
1371
1749
|
const isAllowed = (line: number, kind: GuardKind): boolean =>
|
|
1372
|
-
annotationsCover(waivers.get(line - 1), kind)
|
|
1750
|
+
fileWaived.has(kind) || annotationsCover(waivers.get(line - 1), kind)
|
|
1373
1751
|
|
|
1374
1752
|
const isAllowedInRange = (start: number, end: number, kind: GuardKind): boolean => {
|
|
1375
1753
|
for (let n = start; n <= end; n++) if (isAllowed(n, kind)) return true
|
|
@@ -1409,6 +1787,18 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1409
1787
|
/** Lines the line-scoped `raw-html-layout` already owns, so `hidden-inline-style` can't double-report. */
|
|
1410
1788
|
const rawHtmlLayoutLines = new Set<number>()
|
|
1411
1789
|
|
|
1790
|
+
/** Offset of each line's start in `codeText` — lets a line-scoped match ask about its surroundings. */
|
|
1791
|
+
const lineStarts: number[] = []
|
|
1792
|
+
for (let n = 0, i = 0; i < codeLines.length; i++) {
|
|
1793
|
+
lineStarts.push(n)
|
|
1794
|
+
n += (codeLines[i] ?? '').length + 1
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/** Every identifier the file hands to a `style=`/`styles=` prop — see `isStyleObjectContext`. */
|
|
1798
|
+
const styleIdentifiers = new Set(
|
|
1799
|
+
[...codeText.matchAll(STYLE_BOUND_IDENTIFIER)].map((m) => m[1] ?? ''),
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1412
1802
|
for (let i = 0; i < lines.length; i++) {
|
|
1413
1803
|
const line = codeLines[i] ?? ''
|
|
1414
1804
|
|
|
@@ -1511,10 +1901,18 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1511
1901
|
push('raw-html-layout', i + 1, '<raw-html style>')
|
|
1512
1902
|
}
|
|
1513
1903
|
|
|
1514
|
-
// inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape
|
|
1904
|
+
// inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape and
|
|
1905
|
+
// the TS-only style-object test for a unitless number (see isStyleObjectContext).
|
|
1515
1906
|
if (GUARD_RULES['inline-spacing'].enabled!(cfg)) {
|
|
1516
1907
|
for (const m of line.matchAll(GUARD_RULES['inline-spacing'].pattern as RegExp)) {
|
|
1517
1908
|
if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
|
|
1909
|
+
const hasUnit = /^[a-z%]/.test(line.slice(m.index + m[0].length))
|
|
1910
|
+
if (
|
|
1911
|
+
syntax === 'ts' &&
|
|
1912
|
+
!hasUnit &&
|
|
1913
|
+
!isStyleObjectContext(codeText, lineStarts[i]! + m.index, styleIdentifiers)
|
|
1914
|
+
)
|
|
1915
|
+
continue
|
|
1518
1916
|
push('inline-spacing', i + 1, m[0])
|
|
1519
1917
|
}
|
|
1520
1918
|
}
|
|
@@ -1680,3 +2078,98 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1680
2078
|
severity: severityOf(f.kind, cfg),
|
|
1681
2079
|
}))
|
|
1682
2080
|
}
|
|
2081
|
+
|
|
2082
|
+
// ── Allow-annotation enumeration (check-theme --audit-allows) ─────────────────────────────────────
|
|
2083
|
+
|
|
2084
|
+
/**
|
|
2085
|
+
* One `theme-allow` annotation as WRITTEN, classified by whose reach its ids fall in.
|
|
2086
|
+
*
|
|
2087
|
+
* `guardKinds` is what `checkSource` can judge by re-running itself with the annotation
|
|
2088
|
+
* neutralized. `pluginRules` is what only an `oxlint` run can judge. `unknownRules` names no rule
|
|
2089
|
+
* at all. All three empty with `bare: true` is the legacy blanket form.
|
|
2090
|
+
*/
|
|
2091
|
+
export type AllowAnnotationSite = {
|
|
2092
|
+
/** 1-based line the annotation is written on. */
|
|
2093
|
+
readonly line: number
|
|
2094
|
+
/** `'file'` for the `theme-allow-file` declaration form, `'line'` otherwise. */
|
|
2095
|
+
readonly scope: 'line' | 'file'
|
|
2096
|
+
/** Every id the annotation names that resolves to a real rule, `basalt/` prefix stripped. */
|
|
2097
|
+
readonly rules: readonly string[]
|
|
2098
|
+
/** The subset of `rules` `checkSource` owns. */
|
|
2099
|
+
readonly guardKinds: readonly GuardKind[]
|
|
2100
|
+
/** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
|
|
2101
|
+
readonly pluginRules: readonly string[]
|
|
2102
|
+
/** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
|
|
2103
|
+
readonly unknownRules: readonly string[]
|
|
2104
|
+
/** A written reason follows the ids. */
|
|
2105
|
+
readonly hasReason: boolean
|
|
2106
|
+
/** The legacy blanket form — no ids at all, known or unknown. Only covers anything at line scope. */
|
|
2107
|
+
readonly bare: boolean
|
|
2108
|
+
/** The source line, verbatim. */
|
|
2109
|
+
readonly text: string
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* Every allow annotation in one file, with the ids it names classified by reach.
|
|
2114
|
+
*
|
|
2115
|
+
* Pure, and it shares `collectAllowAnnotations` with {@link checkSource} — so a caller enumerating
|
|
2116
|
+
* annotations sees exactly the set the scan honours, rather than a second regex that can drift from
|
|
2117
|
+
* it. `--audit-allows` carried such a mirror and it was already one alternation behind.
|
|
2118
|
+
*
|
|
2119
|
+
* A file-scoped declaration appears once, at the line it is written on.
|
|
2120
|
+
*
|
|
2121
|
+
* @example
|
|
2122
|
+
* for (const site of findAllowAnnotations(text, rel, cfg)) {
|
|
2123
|
+
* if (site.pluginRules.length > 0) probeWithOxlint(neutralizeAllowAnnotation(text, site.line, cfg))
|
|
2124
|
+
* }
|
|
2125
|
+
*/
|
|
2126
|
+
export function findAllowAnnotations(
|
|
2127
|
+
text: string,
|
|
2128
|
+
relPath: string,
|
|
2129
|
+
cfg: GuardConfig,
|
|
2130
|
+
): AllowAnnotationSite[] {
|
|
2131
|
+
const lines = text.split('\n')
|
|
2132
|
+
const syntax = guardSyntaxFor(relPath)
|
|
2133
|
+
const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
|
|
2134
|
+
const { declared } = collectAllowAnnotations(
|
|
2135
|
+
lines,
|
|
2136
|
+
codeText.split('\n'),
|
|
2137
|
+
cfg.allowComment,
|
|
2138
|
+
syntax,
|
|
2139
|
+
)
|
|
2140
|
+
return declared.map(({ line, annotation }) => ({
|
|
2141
|
+
line,
|
|
2142
|
+
scope: annotation.scope,
|
|
2143
|
+
rules: [...annotation.rules],
|
|
2144
|
+
guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
|
|
2145
|
+
pluginRules: annotation.rules.filter(
|
|
2146
|
+
(id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
|
|
2147
|
+
),
|
|
2148
|
+
unknownRules: [...annotation.unknownRules],
|
|
2149
|
+
hasReason: annotation.hasReason,
|
|
2150
|
+
bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
|
|
2151
|
+
text: lines[line - 1] ?? '',
|
|
2152
|
+
}))
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */
|
|
2156
|
+
export const NEUTRALIZED_ALLOW_TOKEN = 'basalt-audit-neutralized'
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* `text` with the allow token on ONE (1-based) line rewritten to {@link NEUTRALIZED_ALLOW_TOKEN},
|
|
2160
|
+
* leaving every other annotation intact.
|
|
2161
|
+
*
|
|
2162
|
+
* The probe half of an audit: re-scan the result and the findings that appear are exactly what that
|
|
2163
|
+
* one annotation suppresses. Shipped rather than left to each caller so the guard probe and the
|
|
2164
|
+
* oxlint probe neutralize identically — a `--audit-allows` verdict that differs between the two
|
|
2165
|
+
* halves by a substitution detail is worse than no verdict.
|
|
2166
|
+
*
|
|
2167
|
+
* A line with no token comes back unchanged.
|
|
2168
|
+
*/
|
|
2169
|
+
export function neutralizeAllowAnnotation(text: string, line: number, cfg: GuardConfig): string {
|
|
2170
|
+
const lines = text.split('\n')
|
|
2171
|
+
const target = lines[line - 1]
|
|
2172
|
+
if (target === undefined) return text
|
|
2173
|
+
lines[line - 1] = target.replaceAll(cfg.allowComment, NEUTRALIZED_ALLOW_TOKEN)
|
|
2174
|
+
return lines.join('\n')
|
|
2175
|
+
}
|