basalt-ui 1.32.1 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/MIGRATING.md +218 -25
  4. package/README.md +3 -0
  5. package/agent/rules/basalt-state.md +2 -2
  6. package/agent/rules/basalt-tokens.md +1 -1
  7. package/agent/skills/basalt-app/SKILL.md +5 -3
  8. package/configs/oxlint-basalt.json +33 -0
  9. package/configs/oxlint-plugin.js +89 -67
  10. package/configs/oxlint-plugin.test.ts +224 -19
  11. package/configs/oxlint.json +2 -30
  12. package/dist/agent/coalesce.d.ts.map +1 -1
  13. package/dist/agent/coalesce.js +1 -2
  14. package/dist/agent/merge.d.ts.map +1 -1
  15. package/dist/agent/merge.js +1 -2
  16. package/dist/agent-chat/relative-time.d.ts +9 -0
  17. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  18. package/dist/agent-chat/relative-time.js +1 -2
  19. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  20. package/dist/agent-chat/thread-message.js +1 -2
  21. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  22. package/dist/charts/kinds/BandStrip.js +3 -3
  23. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  24. package/dist/charts/kinds/Bars.js +1 -2
  25. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  26. package/dist/charts/kinds/StackedArea.js +1 -2
  27. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  28. package/dist/cli/audit-allows.d.ts +1 -1
  29. package/dist/cli/audit-allows.d.ts.map +1 -1
  30. package/dist/cli/audit-allows.js +4 -4
  31. package/dist/cli/check-theme.d.ts.map +1 -1
  32. package/dist/cli/check-theme.js +38 -38
  33. package/dist/cli/config.d.ts +197 -0
  34. package/dist/cli/config.d.ts.map +1 -0
  35. package/dist/cli/config.js +20 -0
  36. package/dist/cli/doctor.d.ts.map +1 -1
  37. package/dist/cli/doctor.js +4 -6
  38. package/dist/cli/guard-hook.d.ts.map +1 -1
  39. package/dist/cli/guard-hook.js +7 -41
  40. package/dist/cli/index.d.ts +31 -257
  41. package/dist/cli/index.d.ts.map +1 -1
  42. package/dist/cli/index.js +64 -37
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +7 -16
  45. package/dist/commands/define-commands.d.ts.map +1 -1
  46. package/dist/commands/define-commands.js +2 -2
  47. package/dist/commands/define-overlays.d.ts.map +1 -1
  48. package/dist/commands/define-overlays.js +1 -2
  49. package/dist/common/index.d.ts +0 -1
  50. package/dist/common/index.d.ts.map +1 -1
  51. package/dist/common/index.js +0 -2
  52. package/dist/common/validate.d.ts +2 -2
  53. package/dist/common/validate.d.ts.map +1 -1
  54. package/dist/common/validate.js +2 -3
  55. package/dist/common/vx-families.d.ts +7 -0
  56. package/dist/common/vx-families.d.ts.map +1 -0
  57. package/dist/common/vx-families.js +32 -0
  58. package/dist/common/vx-family.d.ts +11 -0
  59. package/dist/common/vx-family.d.ts.map +1 -0
  60. package/dist/common/vx-family.js +12 -0
  61. package/dist/content/article-card.d.ts.map +1 -1
  62. package/dist/content/article-card.js +4 -1
  63. package/dist/controls/enum-filter.d.ts.map +1 -1
  64. package/dist/controls/enum-filter.js +3 -13
  65. package/dist/controls/filter-pill.d.ts +8 -0
  66. package/dist/controls/filter-pill.d.ts.map +1 -1
  67. package/dist/controls/filter-pill.js +15 -0
  68. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  69. package/dist/controls/multi-select-filter.js +3 -13
  70. package/dist/controls/number-filter.d.ts +3 -0
  71. package/dist/controls/number-filter.d.ts.map +1 -1
  72. package/dist/controls/number-filter.js +12 -4
  73. package/dist/controls/range-filter.d.ts.map +1 -1
  74. package/dist/controls/range-filter.js +1 -2
  75. package/dist/data/data-table.d.ts.map +1 -1
  76. package/dist/data/data-table.js +2 -3
  77. package/dist/guard/index.d.ts +17 -3
  78. package/dist/guard/index.d.ts.map +1 -1
  79. package/dist/guard/index.js +62 -23
  80. package/dist/guard/types.d.ts +3 -3
  81. package/dist/guard/types.d.ts.map +1 -1
  82. package/dist/guard/vx-names.d.ts +3 -0
  83. package/dist/guard/vx-names.d.ts.map +1 -0
  84. package/dist/guard/vx-names.js +257 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +0 -2
  88. package/dist/notifications/mount-guard.d.ts.map +1 -1
  89. package/dist/notifications/mount-guard.js +1 -2
  90. package/dist/provider/index.d.ts.map +1 -1
  91. package/dist/provider/index.js +2 -3
  92. package/dist/provider/lab-theme.d.ts +5 -4
  93. package/dist/provider/lab-theme.d.ts.map +1 -1
  94. package/dist/provider/lab-theme.js +1 -3
  95. package/dist/router-tanstack/search-store.d.ts +18 -0
  96. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  97. package/dist/router-tanstack/search-store.js +33 -1
  98. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  99. package/dist/shell/app-mobile-nav.js +2 -1
  100. package/dist/shell/app-mobile-nav.module.css +12 -0
  101. package/dist/shell/app-sidebar.d.ts.map +1 -1
  102. package/dist/shell/app-sidebar.js +32 -36
  103. package/dist/shell/app-sidebar.module.css +6 -0
  104. package/dist/shell/index.d.ts +0 -1
  105. package/dist/shell/index.d.ts.map +1 -1
  106. package/dist/shell/index.js +0 -2
  107. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  108. package/dist/shell/mobile-nav-model.js +3 -4
  109. package/dist/shell/nav-glyph.d.ts +14 -0
  110. package/dist/shell/nav-glyph.d.ts.map +1 -0
  111. package/dist/shell/nav-glyph.js +21 -0
  112. package/dist/shell/nav-types.d.ts +1 -0
  113. package/dist/shell/nav-types.d.ts.map +1 -1
  114. package/dist/shell/page-bar.d.ts.map +1 -1
  115. package/dist/shell/page-bar.js +1 -2
  116. package/dist/shell/sidebar-search.d.ts.map +1 -1
  117. package/dist/shell/sidebar-search.js +1 -3
  118. package/dist/surfaces.d.ts +3 -3
  119. package/dist/surfaces.d.ts.map +1 -1
  120. package/dist/surfaces.js +4 -2
  121. package/dist/tokens/index.d.ts +1 -1
  122. package/dist/tokens/index.d.ts.map +1 -1
  123. package/dist/tokens/index.js +24 -2
  124. package/dist/tokens/size-classes.d.ts +25 -0
  125. package/dist/tokens/size-classes.d.ts.map +1 -1
  126. package/dist/tokens/size-classes.js +14 -0
  127. package/llms.txt +4 -4
  128. package/package.json +1 -1
  129. package/src/agent/coalesce.ts +1 -2
  130. package/src/agent/merge.ts +1 -2
  131. package/src/agent-chat/relative-time.ts +1 -2
  132. package/src/agent-chat/thread-message.tsx +2 -3
  133. package/src/charts/kinds/BandStrip.tsx +7 -3
  134. package/src/charts/kinds/Bars.tsx +1 -2
  135. package/src/charts/kinds/StackedArea.tsx +4 -2
  136. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  137. package/src/cli/audit-allows.ts +10 -9
  138. package/src/cli/check-theme.ts +61 -39
  139. package/src/cli/config.ts +209 -0
  140. package/src/cli/doctor.ts +5 -7
  141. package/src/cli/guard-hook.ts +9 -42
  142. package/src/cli/index.ts +101 -294
  143. package/src/cli/sync.ts +7 -16
  144. package/src/commands/define-commands.ts +2 -2
  145. package/src/commands/define-overlays.ts +1 -2
  146. package/src/common/index.ts +0 -1
  147. package/src/common/validate.ts +4 -5
  148. package/src/common/vx-families.ts +36 -0
  149. package/src/common/vx-family.ts +22 -0
  150. package/src/content/article-card.tsx +1 -0
  151. package/src/controls/enum-filter.tsx +3 -13
  152. package/src/controls/filter-pill.tsx +20 -0
  153. package/src/controls/multi-select-filter.tsx +3 -13
  154. package/src/controls/number-filter.tsx +24 -3
  155. package/src/controls/range-filter.tsx +1 -2
  156. package/src/data/data-table.tsx +2 -3
  157. package/src/guard/index.ts +134 -47
  158. package/src/guard/types.ts +3 -2
  159. package/src/guard/vx-names.ts +257 -0
  160. package/src/index.ts +0 -2
  161. package/src/notifications/mount-guard.ts +1 -2
  162. package/src/provider/index.tsx +2 -3
  163. package/src/provider/lab-theme.ts +28 -21
  164. package/src/router-tanstack/search-store.ts +73 -1
  165. package/src/shell/app-mobile-nav.module.css +12 -0
  166. package/src/shell/app-mobile-nav.tsx +8 -1
  167. package/src/shell/app-sidebar.module.css +6 -0
  168. package/src/shell/app-sidebar.tsx +52 -50
  169. package/src/shell/index.tsx +0 -1
  170. package/src/shell/mobile-nav-model.ts +3 -5
  171. package/src/shell/nav-glyph.tsx +38 -0
  172. package/src/shell/nav-types.ts +1 -0
  173. package/src/shell/page-bar.tsx +1 -4
  174. package/src/shell/sidebar-search.tsx +1 -5
  175. package/src/surfaces.ts +4 -2
  176. package/src/tokens/index.ts +34 -2
  177. package/src/tokens/size-classes.ts +27 -0
  178. package/dist/common/is-dev.d.ts +0 -3
  179. package/dist/common/is-dev.d.ts.map +0 -1
  180. package/dist/common/is-dev.js +0 -6
  181. package/dist/shell/use-breakpoint.d.ts +0 -10
  182. package/dist/shell/use-breakpoint.d.ts.map +0 -1
  183. package/dist/shell/use-breakpoint.js +0 -11
  184. package/src/common/is-dev.ts +0 -4
  185. package/src/shell/use-breakpoint.ts +0 -37
@@ -177,6 +177,7 @@ export const KNOWN_RULE_IDS = new Set([
177
177
  'in-body-page-title',
178
178
  'raw-selection-control',
179
179
  'raw-media-query',
180
+ 'unknown-vx-token',
180
181
  // Retired guard kinds — no longer in GUARD_RULES, but a `theme-allow` naming one must still
181
182
  // parse as a real (dead) waiver rather than an unknown-id typo. See RETIRED_RULE_IDS above.
182
183
  ...RETIRED_RULE_IDS,
@@ -2702,9 +2703,8 @@ const CONTROL_OUTSIDE_HOME_MESSAGE =
2702
2703
 
2703
2704
  /**
2704
2705
  * The cross-file half of C1, and the one rule here that is openly a HEURISTIC: "this control has
2705
- * no home" is a claim about layout intent, which no AST can see. It ships `warn` for that reason
2706
- * and stays warn until the playground and the five consumer repos run it with ≤3 waivers
2707
- * (docs/CONTROLS-SPEC.md §9 wave 7).
2706
+ * no home" is a claim about layout intent, which no AST can see. That is why it sat in grace from
2707
+ * 1.26.0 to 1.33.0, promoting only once basalt's tree and nine consumers measured at zero.
2708
2708
  *
2709
2709
  * Four exemptions carry the false-positive load: an overlay/settings-row/form-row ancestor
2710
2710
  * ({@link CONTROL_HOST_TAGS}, `FormRow`/`FormGroup` included), an {@link isOverlayConventionFile}
@@ -2712,7 +2712,7 @@ const CONTROL_OUTSIDE_HOME_MESSAGE =
2712
2712
  * `@mantine/form` (a form is the third home and its inputs are not filters), and the owner
2713
2713
  * exemption — a file DEFINING a basalt control cannot be told to use one.
2714
2714
  */
2715
- // Ships: warn (grace → 1.33.0)
2715
+ // Ships: error
2716
2716
  const controlOutsideHome = {
2717
2717
  meta: {
2718
2718
  type: 'suggestion',
@@ -3623,6 +3623,7 @@ const queryFnUnwrap = {
3623
3623
  * prop?: string
3624
3624
  * replacement: string
3625
3625
  * removeIn: string
3626
+ * removed?: true
3626
3627
  * fix?: false
3627
3628
  * }} DeprecatedExport
3628
3629
  */
@@ -3646,7 +3647,12 @@ const queryFnUnwrap = {
3646
3647
  * `removeIn` at least one minor out, add the `MIGRATING.md` row, and this rule starts nudging.
3647
3648
  * `oxlint-plugin.test.ts` scans every `src/**\/index.ts(x)` barrel for a JSDoc `@deprecated` and
3648
3649
  * fails when one has no row here, so the barrel half cannot be forgotten. Removing the export is a
3649
- * separate commit that deletes the row in the same change.
3650
+ * separate commit that either deletes the row (the fix is manual from then on — say so in the
3651
+ * removal's MIGRATING row) or keeps it one minor past the removal with `removed: true` and
3652
+ * `removeIn` set to the removing minor, so the message reads "was removed in X" instead of "still
3653
+ * resolves" — and never autofixes, whatever `fix` says. `scripts/check-grace.ts` refuses a release
3654
+ * at or past a `removeIn` whose export still ships — the gap that let `useBreakpoint` outlive its
3655
+ * date twice.
3650
3656
  *
3651
3657
  * @type {readonly DeprecatedExport[]}
3652
3658
  */
@@ -3662,8 +3668,8 @@ export const DEPRECATED_EXPORTS = [
3662
3668
  name: 'useBreakpoint',
3663
3669
  replacement:
3664
3670
  'useSizeClass() for shell chrome; a page’s own layout swap is a container decision — a type="container" SimpleGrid/Grid or an @container rule',
3665
- removeIn: '1.33.0',
3666
- fix: false,
3671
+ removeIn: '1.34.0',
3672
+ removed: true,
3667
3673
  },
3668
3674
  ]
3669
3675
 
@@ -3672,6 +3678,13 @@ function deprecatedExportMessage(row) {
3672
3678
  row.prop === undefined
3673
3679
  ? `\`${row.name}\` from ${row.subpath}`
3674
3680
  : `\`${row.prop}\` on <${row.name}>`
3681
+ if (row.removed === true) {
3682
+ return (
3683
+ `Removed basalt export — ${what} was removed in ${row.removeIn} and no longer resolves; use ` +
3684
+ `\`${row.replacement}\` instead (packages/basalt-ui/MIGRATING.md carries the row). ` +
3685
+ '(basalt/deprecated-export)'
3686
+ )
3687
+ }
3675
3688
  return (
3676
3689
  `Deprecated basalt export — ${what} is superseded by \`${row.replacement}\` and is removed in ` +
3677
3690
  `${row.removeIn}. It still resolves today, so nothing is broken yet; migrate before that minor ` +
@@ -3724,7 +3737,8 @@ const deprecatedExport = {
3724
3737
  context.report({
3725
3738
  node: spec,
3726
3739
  message: deprecatedExportMessage(row),
3727
- ...(row.fix === false
3740
+ // A removed row's replacement is prose for a gone import, never a drop-in name.
3741
+ ...(row.fix === false || row.removed === true
3728
3742
  ? {}
3729
3743
  : { fix: (fixer) => fixer.replaceText(spec, `${row.replacement} as ${local}`) }),
3730
3744
  })
@@ -4066,8 +4080,9 @@ const CONTAINER_ROUTED_PROPS = {
4066
4080
  * `type` has to be the string literal `"container"`; a dynamic value is not a decision this rule
4067
4081
  * can read. `Grid.Col` takes its grid's mode by walking to the nearest enclosing `Grid` element.
4068
4082
  * `consts` is the rule's same-file name→object map, so `breakpoints={map}` resolves like any value.
4083
+ * The one IMPORTED map trusted is basalt's own `CONTAINER_GRID_BREAKPOINTS` ({@link isShippedGridMap}).
4069
4084
  */
4070
- function containerGridMode(opening, mantineImports, consts) {
4085
+ function containerGridMode(opening, mantineImports, consts, sourceCode) {
4071
4086
  let tag = resolveMantineTag(opening.name, mantineImports)
4072
4087
  let grid = opening
4073
4088
  if (tag === 'Grid.Col') {
@@ -4096,12 +4111,69 @@ function containerGridMode(opening, mantineImports, consts) {
4096
4111
  const breakpoints = attr('breakpoints')
4097
4112
  if (breakpoints === undefined) return 'fallback'
4098
4113
  let map = attrValue(breakpoints)
4114
+ if (isShippedGridMap(sourceCode, map)) return 'container'
4099
4115
  if (map?.type === 'Identifier') map = consts.get(map.name)
4100
4116
  const nonEmpty =
4101
4117
  map?.type === 'ObjectExpression' && map.properties.some((prop) => prop.type === 'Property')
4102
4118
  return nonEmpty ? 'container' : 'fallback'
4103
4119
  }
4104
4120
 
4121
+ /**
4122
+ * Whether `node` reads basalt's own `CONTAINER_GRID_BREAKPOINTS` — its value is known, so it is
4123
+ * the one imported `breakpoints` map trusted. Resolved through the scope manager to the BINDING the
4124
+ * identifier reads, never by local name: a parameter or inner `const` shadowing the import is not
4125
+ * the import. The binding has to be an `ImportSpecifier` naming it from `basalt-ui/tokens` (any
4126
+ * alias, not type-only), a `.CONTAINER_GRID_BREAKPOINTS` member of that module's namespace import,
4127
+ * or a `const` whose initializer resolves the same way. Without a scope manager nothing is trusted
4128
+ * — reported, the conservative answer.
4129
+ */
4130
+ function isShippedGridMap(sourceCode, node, seen = new Set()) {
4131
+ if (node?.type === 'MemberExpression') return isShippedGridMapMember(sourceCode, node)
4132
+ if (node?.type !== 'Identifier' || seen.has(node)) return false
4133
+ seen.add(node)
4134
+ const def = soleDefinition(sourceCode, node)
4135
+ if (def?.type === 'ImportBinding') {
4136
+ const spec = def.node
4137
+ const decl = def.parent
4138
+ if (decl?.type !== 'ImportDeclaration' || decl.importKind === 'type') return false
4139
+ if (decl.source?.value !== 'basalt-ui/tokens') return false
4140
+ if (spec.type !== 'ImportSpecifier' || spec.importKind === 'type') return false
4141
+ const imported = spec.imported
4142
+ const importedName = imported.type === 'Identifier' ? imported.name : imported.value
4143
+ return importedName === 'CONTAINER_GRID_BREAKPOINTS'
4144
+ }
4145
+ if (def?.type === 'Variable' && def.parent?.kind === 'const')
4146
+ return isShippedGridMap(sourceCode, def.node.init, seen)
4147
+ return false
4148
+ }
4149
+
4150
+ /** `tokens.CONTAINER_GRID_BREAKPOINTS` (or `tokens['CONTAINER_GRID_BREAKPOINTS']`) on `import * as tokens from 'basalt-ui/tokens'`. */
4151
+ function isShippedGridMapMember(sourceCode, node) {
4152
+ const property = node.property
4153
+ const name = node.computed
4154
+ ? isStringLiteral(property) && property.value
4155
+ : property?.type === 'Identifier' && property.name
4156
+ if (name !== 'CONTAINER_GRID_BREAKPOINTS' || node.object?.type !== 'Identifier') return false
4157
+ const def = soleDefinition(sourceCode, node.object)
4158
+ return (
4159
+ def?.type === 'ImportBinding' &&
4160
+ def.node.type === 'ImportNamespaceSpecifier' &&
4161
+ def.parent?.importKind !== 'type' &&
4162
+ def.parent?.source?.value === 'basalt-ui/tokens'
4163
+ )
4164
+ }
4165
+
4166
+ /** The one definition of the variable `identifier` reads, or `undefined` (unresolved, or more than one). */
4167
+ function soleDefinition(sourceCode, identifier) {
4168
+ if (typeof sourceCode?.getScope !== 'function') return undefined
4169
+ for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
4170
+ const variable = scope.set.get(identifier.name)
4171
+ if (variable === undefined) continue
4172
+ return variable.defs.length === 1 ? variable.defs[0] : undefined
4173
+ }
4174
+ return undefined
4175
+ }
4176
+
4105
4177
  /**
4106
4178
  * The shell components that are `visibleFrom`/`hiddenFrom`'s legitimate home — the size-class
4107
4179
  * axis's one JSX escape hatch, alongside overlays. Matched by DECLARATION name, the same
@@ -4128,13 +4200,14 @@ const RAW_BREAKPOINT_EXEMPT_FILE = /(?:^|[\\/])(?:use-media-query\.ts|ChartToolt
4128
4200
  * (`CONTAINER_CLASSES`, `basalt-ui/tokens`: compact 240 / regular 480 / wide 800), never on the
4129
4201
  * viewport — `useSizeClass()` is the shell's seam, not a page's. Mantine 9's own `type="container"`
4130
4202
  * grids are the primitive: both self-wrap in an `inline-size` container, so they need no ancestor.
4131
- * The px literals are `CONTAINER_CLASSES` written out (the plugin cannot import TS source);
4203
+ * The px literals are `CONTAINER_KEYS` written out (the plugin cannot import TS source);
4132
4204
  * `oxlint-plugin.test.ts` asserts every one against it, and the MIGRATING recipe too.
4133
4205
  */
4134
4206
  export const RAW_BREAKPOINT_RECIPE =
4135
4207
  'A page’s own layout keys on its container: <SimpleGrid type="container" cols={{ base: 1, ' +
4136
- "'480px': 2, '800px': 3 }}> (px keys = CONTAINER_CLASSES, basalt-ui/tokens), or <Grid " +
4137
- 'type="container" breakpoints={…}> mapping all five xs–xl onto those px widths, with Grid.Col ' +
4208
+ '[CONTAINER_KEYS.regular]: 2, [CONTAINER_KEYS.wide]: 3 }}> (basalt-ui/tokens; the keys are ' +
4209
+ "the px lengths '480px'/'800px'), or <Grid " +
4210
+ 'type="container" breakpoints={CONTAINER_GRID_BREAKPOINTS}> (basalt-ui/tokens), with Grid.Col ' +
4138
4211
  'span={{ base: 12, sm: 6 }} keyed through that map; a Flex direction swap becomes that SimpleGrid (equal ' +
4139
4212
  'columns), a wrapping Group, or a CSS-module @container basalt-grid rule. useSizeClass() is for ' +
4140
4213
  'shell chrome only. Recipes: basalt-ui MIGRATING.md § basalt/raw-breakpoint.'
@@ -4204,7 +4277,7 @@ const RAW_BREAKPOINT_HOOK_NAMES = new Set(['useMediaQuery', 'useMatches', 'useVi
4204
4277
  * declares a shell home (or a control's own C9 swap) is exempt for all four shapes uniformly — the
4205
4278
  * shell's own responsive chrome is the sanctioned use, whatever shape it takes.
4206
4279
  */
4207
- // Ships: warn (grace → 1.33.0)
4280
+ // Ships: error
4208
4281
  const rawBreakpoint = {
4209
4282
  meta: {
4210
4283
  type: 'suggestion',
@@ -4238,6 +4311,7 @@ const rawBreakpoint = {
4238
4311
  // apart), so it is dropped from the map entirely rather than guessed at last-wins.
4239
4312
  const responsiveObjectConsts = new Map()
4240
4313
  const seenDeclaratorNames = new Set()
4314
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
4241
4315
 
4242
4316
  const noteShellHomeOwner = (name) => {
4243
4317
  if (typeof name === 'string' && SHELL_HOME_NAMES.has(name)) definesShellHome = true
@@ -4322,7 +4396,7 @@ const rawBreakpoint = {
4322
4396
  ? CONTAINER_ROUTED_PROPS[tag]
4323
4397
  : undefined
4324
4398
  const mode = routed?.has(node.name.name)
4325
- ? containerGridMode(owner, mantineImports, responsiveObjectConsts)
4399
+ ? containerGridMode(owner, mantineImports, responsiveObjectConsts, sourceCode)
4326
4400
  : undefined
4327
4401
  if (mode === 'container') continue
4328
4402
  if (!containsResponsiveObjectValue(value, responsiveObjectConsts, mode !== undefined))
@@ -4373,59 +4447,7 @@ const rawBreakpoint = {
4373
4447
  *
4374
4448
  * @type {Record<string, GraceEntry>}
4375
4449
  */
4376
- export const PLUGIN_RULE_GRACE = {
4377
- 'control-outside-home': {
4378
- since: '1.26.0',
4379
- promote: '1.33.0',
4380
- why:
4381
- "Extended 1.32.0 -> 1.33.0 at the 1.32.0 release, the last extension: basalt's own tree is at 0 (consumer-loop r1 dogfood) and argo is at 2, but RE-MEASURED 2026-09-30 the fleet carries ~33 (image-gen 11, rb 7, image-share 6, weatherorb 5, email-gateway 4, argo 2). The 1.32.0 fleet upgrade clears them while they warn, together with raw-breakpoint, so 1.33.0 promotes all four ids against a measured-clean fleet. " +
4382
- 'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
4383
- 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
4384
- 'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
4385
- 'is carried by four exemptions (overlay/settings-row ancestor, the overlay FILENAME ' +
4386
- 'convention below, @mantine/form, owner definition) rather than by certainty. The other five ' +
4387
- 'wave-6 rules promoted at 1.27.0; this one has not, across three re-datings. ' +
4388
- 'RE-MEASURED 2026-09-09, every consumer freshly migrated to 1.29.2 and basalt built from ' +
4389
- 'master: 29 incumbents across five repos — image-gen 10, rb 8, image-share 6, the playground ' +
4390
- '3, argo 2 — and zero in linewatch, meteo and obsidian. The 1.28.0 prediction held: ' +
4391
- "{@link isOverlayConventionFile} took argo's 9 cross-file overlay warns down to 2, which is " +
4392
- 'the part of the remainder that was ever going to fall to an exemption. ' +
4393
- 'Extended to 1.31.0, and the argument is NOT the count — it is that 1.30.0 is the first ' +
4394
- 'release in which this rule names an answer the 29 can reach. Every home it listed was a ' +
4395
- 'shell slot (PageBar / Section / WidgetHeader), so a consumer that mounts `BasaltProvider` ' +
4396
- 'and no `BasaltShell` had no reachable home at all: image-gen is a Tauri window with a ' +
4397
- 'hand-rolled header and carries 10 of the 29 by itself, and `bound-control-outside-home` ' +
4398
- 'closed the `ViewTabs` escape it would otherwise have taken. 1.30.0 adds the missing ' +
4399
- 'sentence to both messages ({@link SHELL_LESS_HOME_HINT} — a `PageBar` outside a shell ' +
4400
- 'renders in flow, sticky, with its own title, which is what `src/shell/page-bar.tsx` ' +
4401
- 'actually does). Promoting a rule to `error` in the same minor that first makes it ' +
4402
- 'satisfiable hands a consumer zero minors to act on the answer, which is exactly the failure ' +
4403
- 'the grace mechanism exists to prevent. 1.31.0 is when the 29 have been through the new home ' +
4404
- 'and the remainder is measured against that number. A file outside the convention that is ' +
4405
- "still an overlay's own body declares it with " +
4406
- '`theme-allow-file control-outside-home — overlay`.',
4407
- },
4408
- 'raw-breakpoint': {
4409
- since: '1.31.0',
4410
- promote: '1.33.0',
4411
- why:
4412
- 'New in the wave-11 responsive/touch guards. Catches four independent shapes at once — a ' +
4413
- 'responsive-object Mantine prop, visibleFrom/hiddenFrom outside the three shell homes, the ' +
4414
- 'three raw viewport hooks, and window.matchMedia/innerWidth — none of which basalt policed ' +
4415
- 'before this wave, so every consumer on an earlier minor has a green build with all four ' +
4416
- "shapes already in it. basalt-ui/content's own article-card.tsx (a `SimpleGrid cols={{ base: " +
4417
- "1, sm: 2, lg }}`) was one incumbent measured in basalt's own tree at ship time — a real " +
4418
- 'number, not zero, so it shipped warn rather than error. ' +
4419
- 'Extended 1.32.0 -> 1.33.0: 1.31.0 shipped the rule naming "a container query" but no ' +
4420
- 'primitive, and MIGRATING never mentioned it (argo: 42 warns, no documented target). 1.32.0 ' +
4421
- 'is the first minor with a reachable, documented answer (Mantine type="container" grids keyed ' +
4422
- 'on CONTAINER_CLASSES, exempted here), and promoting in the minor that first makes a rule ' +
4423
- 'satisfiable hands a consumer zero minors to act on it — the control-outside-home ' +
4424
- "extension's reasoning. 1.33.0 is when the incumbents are expected to have moved to a " +
4425
- 'container grid or a theme-allow with a stated reason. Promotes together with its CSS twin, ' +
4426
- 'the `raw-media-query` guard kind.',
4427
- },
4428
- }
4450
+ export const PLUGIN_RULE_GRACE = {}
4429
4451
 
4430
4452
  /**
4431
4453
  * Plugin rules that stay `warn` in the shipped preset PERMANENTLY — outside the C16 version gate,
@@ -644,7 +644,7 @@ describe('basalt/ai-sdk-major', () => {
644
644
  describe('basalt/raw-scroll-container promotion', () => {
645
645
  it('ships "error" (not "warn"/"off") in the consumer preset', () => {
646
646
  const shipped = JSON.parse(
647
- readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8'),
647
+ readFileSync(resolve(import.meta.dirname, 'oxlint-basalt.json'), 'utf8'),
648
648
  ) as { rules: Record<string, unknown> }
649
649
  expect(shipped.rules['basalt/raw-scroll-container']).toBe('error')
650
650
  })
@@ -682,7 +682,7 @@ describe('basalt agent rules promotion', () => {
682
682
  '%s ships "error" (not "warn"/"off") in the consumer preset',
683
683
  (rule) => {
684
684
  const shipped = JSON.parse(
685
- readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8'),
685
+ readFileSync(resolve(import.meta.dirname, 'oxlint-basalt.json'), 'utf8'),
686
686
  ) as { rules: Record<string, unknown> }
687
687
  expect(shipped.rules[rule]).toBe('error')
688
688
  },
@@ -1364,8 +1364,10 @@ describe('assertGraceLedger', () => {
1364
1364
  // A guard kind and a plugin rule that enforce ONE law in two lanes (CSS/text vs. JSX AST) must
1365
1365
  // promote together — a consumer failing one spelling and warned on the other is the confusion the
1366
1366
  // pairing exists to prevent. `raw-media-query` split from `basalt/raw-breakpoint` exactly that way
1367
- // (1.32.0 vs 1.33.0). Twins are DERIVED, not hand-listed, from the two places they are declared: a
1368
- // `WAIVER_ID_ALIASES` pair, and a guard entry whose `why` calls itself the twin of `basalt/<id>`.
1367
+ // (1.32.0 vs 1.33.0). Twins are DERIVED from declarations that outlive the grace window: a
1368
+ // `WAIVER_ID_ALIASES` pair, and a guard kind whose registry message names its `basalt/<id>` twin.
1369
+ // (Deriving them from the ledger, as this did until 1.33.0, went vacuous the moment the last pair
1370
+ // promoted.)
1369
1371
 
1370
1372
  const pluginGrace = PLUGIN_RULE_GRACE as Record<string, GraceEntry>
1371
1373
  const guardGrace = GRACE_PERIOD_KINDS as Record<string, GraceEntry>
@@ -1373,21 +1375,24 @@ const guardGrace = GRACE_PERIOD_KINDS as Record<string, GraceEntry>
1373
1375
  function twinLanes(): Array<[kind: string, rule: string]> {
1374
1376
  const pairs = new Map<string, string>()
1375
1377
  for (const [kind, aliases] of Object.entries(WAIVER_ID_ALIASES)) {
1376
- for (const rule of aliases) if (kind in guardGrace || rule in pluginGrace) pairs.set(kind, rule)
1378
+ if (!Object.hasOwn(GUARD_RULES, kind)) continue
1379
+ for (const rule of aliases) pairs.set(kind, rule)
1377
1380
  }
1378
- for (const [kind, entry] of Object.entries(guardGrace)) {
1379
- const match = /twin of the oxlint plugin's `basalt\/([a-z-]+)`/.exec(entry.why)
1380
- if (match?.[1] !== undefined) pairs.set(kind, match[1])
1381
+ for (const [kind, rule] of Object.entries(GUARD_RULES)) {
1382
+ for (const m of rule.message.matchAll(/basalt\/([a-z-]+)/g)) {
1383
+ if (m[1] !== undefined && m[1] in basaltPlugin.rules) pairs.set(kind, m[1])
1384
+ }
1381
1385
  }
1382
- return [...pairs].filter(([kind]) => kind in guardGrace)
1386
+ return [...pairs]
1383
1387
  }
1384
1388
 
1385
1389
  describe('twin lanes share one promotion', () => {
1386
- it('derives the raw-media-query ↔ raw-breakpoint pair (the derivation is not silently empty)', () => {
1390
+ it('derives the known pairs (the derivation is not silently empty)', () => {
1387
1391
  expect(twinLanes()).toContainEqual(['raw-media-query', 'raw-breakpoint'])
1392
+ expect(twinLanes()).toContainEqual(['raw-selection-control', 'control-outside-home'])
1388
1393
  })
1389
1394
 
1390
- it.each(twinLanes())('%s and basalt/%s are both in grace with the same promote', (kind, rule) => {
1395
+ it.each(twinLanes())('%s and basalt/%s share one grace state and promote', (kind, rule) => {
1391
1396
  expect([kind, rule, guardGrace[kind]?.promote]).toEqual([
1392
1397
  kind,
1393
1398
  rule,
@@ -1397,7 +1402,9 @@ describe('twin lanes share one promotion', () => {
1397
1402
  })
1398
1403
 
1399
1404
  describe('PLUGIN_RULE_GRACE', () => {
1400
- const shipped = JSON.parse(readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8')) as {
1405
+ const shipped = JSON.parse(
1406
+ readFileSync(resolve(import.meta.dirname, 'oxlint-basalt.json'), 'utf8'),
1407
+ ) as {
1401
1408
  rules: Record<string, unknown>
1402
1409
  }
1403
1410
  const pkgVersion = (
@@ -3352,8 +3359,26 @@ describe('basalt/query-fn-unwrap', () => {
3352
3359
 
3353
3360
  // ── deprecated-export (B4) ───────────────────────────────────────────────────
3354
3361
 
3355
- // deprecated-export's behaviour tests were removed with the last ledger row in 1.29.0; the
3356
- // ledger-shape and barrel-scan assertions below keep the mechanism honest until the next row.
3362
+ describe('basalt/deprecated-export', () => {
3363
+ it('reports a removed row as gone, never as "still resolves"', () => {
3364
+ const { code, rules, output } = run(
3365
+ `import { useBreakpoint } from 'basalt-ui'\nexport const x = useBreakpoint\n`,
3366
+ )
3367
+ expect(code).toBe(1)
3368
+ expect(rules).toContain('deprecated-export')
3369
+ expect(output).toContain('was removed in 1.34.0')
3370
+ expect(output).not.toContain('still resolves')
3371
+ })
3372
+
3373
+ // The row no longer says `fix: false`; `removed` alone must suppress the rewrite, because a
3374
+ // removed row's replacement is prose (`useSizeClass() for shell chrome; …`), not a name.
3375
+ it('never autofixes a removed row', () => {
3376
+ const source = `import { useBreakpoint } from 'basalt-ui'\nexport const x = useBreakpoint\n`
3377
+ writeFileSync(resolve(dir, 'removed-fix.tsx'), source)
3378
+ Bun.spawnSync([OXLINT_BIN, '-c', '.oxlintrc.json', '--fix', 'removed-fix.tsx'], { cwd: dir })
3379
+ expect(readFileSync(resolve(dir, 'removed-fix.tsx'), 'utf8')).toBe(source)
3380
+ })
3381
+ })
3357
3382
 
3358
3383
  // ── forms-field-key ──────────────────────────────────────────────────────────
3359
3384
 
@@ -3569,6 +3594,23 @@ describe('basalt/raw-breakpoint', () => {
3569
3594
  ).not.toContain('raw-breakpoint')
3570
3595
  })
3571
3596
 
3597
+ it('does NOT flag CONTAINER_KEYS computed keys (named, aliased, namespace)', () => {
3598
+ const keyed = (imp: string, key: string) =>
3599
+ run(
3600
+ `import { SimpleGrid } from '@mantine/core'\n${imp}\n` +
3601
+ `export const C = () => <SimpleGrid type="container" cols={{ base: 1, [${key}.regular]: 2, [${key}.wide]: 3 }} />\n`,
3602
+ ).rules
3603
+ expect(
3604
+ keyed(`import { CONTAINER_KEYS } from 'basalt-ui/tokens'`, 'CONTAINER_KEYS'),
3605
+ ).not.toContain('raw-breakpoint')
3606
+ expect(keyed(`import { CONTAINER_KEYS as CQ } from 'basalt-ui/tokens'`, 'CQ')).not.toContain(
3607
+ 'raw-breakpoint',
3608
+ )
3609
+ expect(
3610
+ keyed(`import * as tokens from 'basalt-ui/tokens'`, 'tokens.CONTAINER_KEYS'),
3611
+ ).not.toContain('raw-breakpoint')
3612
+ })
3613
+
3572
3614
  it('does NOT flag a lone `base` under type="container"', () => {
3573
3615
  expect(grid(`<SimpleGrid type="container" cols={{ base: 2 }} />`).rules).not.toContain(
3574
3616
  'raw-breakpoint',
@@ -3639,6 +3681,118 @@ describe('basalt/raw-breakpoint', () => {
3639
3681
  expect(rules).not.toContain('raw-breakpoint')
3640
3682
  })
3641
3683
 
3684
+ // The shipped map is the one IMPORTED map trusted: its value is known, so five route files no
3685
+ // longer copy the same literal. Provenance-gated on `basalt-ui/tokens`, alias-safe.
3686
+ const SPAN = `<Grid.Col span={{ base: 12, sm: 6 }}>x</Grid.Col>`
3687
+ it.each([
3688
+ [
3689
+ 'the named import',
3690
+ `import { CONTAINER_GRID_BREAKPOINTS } from 'basalt-ui/tokens'`,
3691
+ 'CONTAINER_GRID_BREAKPOINTS',
3692
+ ],
3693
+ [
3694
+ 'an aliased import',
3695
+ `import { CONTAINER_GRID_BREAKPOINTS as bp } from 'basalt-ui/tokens'`,
3696
+ 'bp',
3697
+ ],
3698
+ [
3699
+ 'a same-file alias of it',
3700
+ `import { CONTAINER_GRID_BREAKPOINTS } from 'basalt-ui/tokens'\nconst bp = CONTAINER_GRID_BREAKPOINTS`,
3701
+ 'bp',
3702
+ ],
3703
+ ])(
3704
+ 'does NOT flag Grid.Col spans under breakpoints={CONTAINER_GRID_BREAKPOINTS} (%s)',
3705
+ (_, imp, ref) => {
3706
+ const { rules } = run(
3707
+ `import { Grid } from '@mantine/core'\n${imp}\n` +
3708
+ `export const C = () => <Grid type="container" breakpoints={${ref}}>${SPAN}</Grid>\n`,
3709
+ )
3710
+ expect(rules).not.toContain('raw-breakpoint')
3711
+ },
3712
+ )
3713
+
3714
+ it.each([
3715
+ ['another module', `import { CONTAINER_GRID_BREAKPOINTS } from './tokens'`],
3716
+ ['a type-only import', `import type { CONTAINER_GRID_BREAKPOINTS } from 'basalt-ui/tokens'`],
3717
+ [
3718
+ 'another basalt-ui/tokens name',
3719
+ `import { CONTAINER_CLASSES as CONTAINER_GRID_BREAKPOINTS } from 'basalt-ui/tokens'`,
3720
+ ],
3721
+ ])('still flags an imported map of the same name from %s', (_, imp) => {
3722
+ const { rules, output } = run(
3723
+ `import { Grid } from '@mantine/core'\n${imp}\n` +
3724
+ `export const C = () => <Grid type="container" breakpoints={CONTAINER_GRID_BREAKPOINTS}>${SPAN}</Grid>\n`,
3725
+ )
3726
+ expect(rules).toContain('raw-breakpoint')
3727
+ expect(CONTAINER_MESSAGE.test(output)).toBe(true)
3728
+ })
3729
+
3730
+ const NS = `import * as tokens from 'basalt-ui/tokens'`
3731
+ it.each([
3732
+ ['a member of the namespace import', NS, 'tokens.CONTAINER_GRID_BREAKPOINTS'],
3733
+ ['a computed member', NS, `tokens['CONTAINER_GRID_BREAKPOINTS']`],
3734
+ ['a const alias of the member', `${NS}\nconst bp = tokens.CONTAINER_GRID_BREAKPOINTS`, 'bp'],
3735
+ ])('does NOT flag Grid.Col spans under %s', (_, imp, ref) => {
3736
+ const { rules } = run(
3737
+ `import { Grid } from '@mantine/core'\n${imp}\n` +
3738
+ `export const C = () => <Grid type="container" breakpoints={${ref}}>${SPAN}</Grid>\n`,
3739
+ )
3740
+ expect(rules).not.toContain('raw-breakpoint')
3741
+ })
3742
+
3743
+ it.each([
3744
+ ['another module', `import * as tokens from './tokens'`, 'tokens.CONTAINER_GRID_BREAKPOINTS'],
3745
+ ['another member', NS, 'tokens.CONTAINER_CLASSES'],
3746
+ [
3747
+ 'a parameter shadowing the namespace',
3748
+ `${NS}\nexport const Used = () => tokens.VX`,
3749
+ 'tokens.CONTAINER_GRID_BREAKPOINTS',
3750
+ '({ tokens }: { tokens: { CONTAINER_GRID_BREAKPOINTS: Record<string, string> } })',
3751
+ ],
3752
+ ])('still flags a namespace member from %s', (_, imp, ref, params = '()') => {
3753
+ const { rules, output } = run(
3754
+ `import { Grid } from '@mantine/core'\n${imp}\n` +
3755
+ `export const C = ${params} => <Grid type="container" breakpoints={${ref}}>${SPAN}</Grid>\n`,
3756
+ )
3757
+ expect(rules).toContain('raw-breakpoint')
3758
+ expect(CONTAINER_MESSAGE.test(output)).toBe(true)
3759
+ })
3760
+
3761
+ // Trust follows the BINDING, not the name: a shadow of the import is someone else's value.
3762
+ it.each([
3763
+ [
3764
+ 'a parameter',
3765
+ `export const C = ({ bp }: { bp: Record<string, string> }) => <Grid type="container" breakpoints={bp}>${SPAN}</Grid>`,
3766
+ ],
3767
+ [
3768
+ 'an inner const',
3769
+ `export const C = () => {\n const bp = pick()\n return <Grid type="container" breakpoints={bp}>${SPAN}</Grid>\n}`,
3770
+ ],
3771
+ [
3772
+ 'an inner const aliasing a parameter',
3773
+ `export function C(props: { m: Record<string, string> }) {\n const bp = props.m\n return <Grid type="container" breakpoints={bp}>${SPAN}</Grid>\n}`,
3774
+ ],
3775
+ ])('flags breakpoints={bp} when bp is %s shadowing the aliased import', (_, component) => {
3776
+ const { rules, output } = run(
3777
+ `import { Grid } from '@mantine/core'\n` +
3778
+ `import { CONTAINER_GRID_BREAKPOINTS as bp } from 'basalt-ui/tokens'\n` +
3779
+ `export const Used = () => <Grid type="container" breakpoints={bp}>x</Grid>\n` +
3780
+ `${component}\n`,
3781
+ )
3782
+ expect(rules).toContain('raw-breakpoint')
3783
+ expect(CONTAINER_MESSAGE.test(output)).toBe(true)
3784
+ })
3785
+
3786
+ it('flags a parameter shadowing the unaliased import name', () => {
3787
+ const { rules } = run(
3788
+ `import { Grid } from '@mantine/core'\n` +
3789
+ `import { CONTAINER_GRID_BREAKPOINTS } from 'basalt-ui/tokens'\n` +
3790
+ `export const C = ({ CONTAINER_GRID_BREAKPOINTS }: { CONTAINER_GRID_BREAKPOINTS: Record<string, string> }) =>\n` +
3791
+ ` <Grid type="container" breakpoints={CONTAINER_GRID_BREAKPOINTS}>${SPAN}</Grid>\n`,
3792
+ )
3793
+ expect(rules).toContain('raw-breakpoint')
3794
+ })
3795
+
3642
3796
  // Only the grid-keyed props route through the container queries; Box style props on the same
3643
3797
  // element still compile to viewport @media, so they keep the ordinary viewport message.
3644
3798
  it.each([
@@ -3880,16 +4034,39 @@ describe('DEPRECATED_EXPORTS', () => {
3880
4034
  }
3881
4035
  ).version
3882
4036
 
3883
- it('carries a replacement and a removeIn at least one minor out on every row', () => {
3884
- const [major, minor] = pkgVersion.split('.').map(Number)
3885
- const floor = `${major}.${(minor as number) + 1}.0`
4037
+ const [major, minor] = pkgVersion.split('.').map(Number)
4038
+ const floor = `${major}.${(minor as number) + 1}.0`
4039
+ const surface = JSON.parse(
4040
+ readFileSync(resolve(import.meta.dirname, '..', 'scripts', 'export-surface.json'), 'utf8'),
4041
+ ) as Record<string, string[]>
4042
+ const shipped = (row: (typeof DEPRECATED_EXPORTS)[number]) =>
4043
+ surface[row.subpath === 'basalt-ui' ? '.' : row.subpath.replace('basalt-ui', '.')]?.includes(
4044
+ row.name,
4045
+ ) === true
4046
+
4047
+ it('carries a replacement and a well-formed removeIn on every row', () => {
3886
4048
  for (const row of DEPRECATED_EXPORTS) {
3887
4049
  expect([row.name, row.replacement.length > 0]).toEqual([row.name, true])
3888
4050
  expect([row.name, /^\d+\.\d+\.\d+$/.test(row.removeIn)]).toEqual([row.name, true])
4051
+ }
4052
+ })
4053
+
4054
+ // A live row still ships, so its removal is still ahead: at least the next minor.
4055
+ it('dates every live row at least one minor out', () => {
4056
+ for (const row of DEPRECATED_EXPORTS.filter((r) => r.removed !== true)) {
3889
4057
  expect([row.name, compareSemver(row.removeIn, floor) >= 0]).toEqual([row.name, true])
3890
4058
  }
3891
4059
  })
3892
4060
 
4061
+ // A removed row is kept past the removal only for its message: the export must really be gone,
4062
+ // and the removing minor is the next one at the latest — never a date still ahead.
4063
+ it('pins every removed row to an export that no longer ships', () => {
4064
+ for (const row of DEPRECATED_EXPORTS.filter((r) => r.removed === true)) {
4065
+ expect([row.name, compareSemver(row.removeIn, floor) <= 0]).toEqual([row.name, true])
4066
+ if (row.prop === undefined) expect([row.name, shipped(row)]).toEqual([row.name, false])
4067
+ }
4068
+ })
4069
+
3893
4070
  it('names a real published subpath on every row', () => {
3894
4071
  const exports = Object.keys(
3895
4072
  (
@@ -3947,7 +4124,9 @@ describe('DEPRECATED_EXPORTS', () => {
3947
4124
  describe('the `Ships:` line above each rule', () => {
3948
4125
  const source = readFileSync(resolve(import.meta.dirname, 'oxlint-plugin.js'), 'utf8')
3949
4126
  const lines = source.split('\n')
3950
- const shipped = JSON.parse(readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8')) as {
4127
+ const shipped = JSON.parse(
4128
+ readFileSync(resolve(import.meta.dirname, 'oxlint-basalt.json'), 'utf8'),
4129
+ ) as {
3951
4130
  rules: Record<string, unknown>
3952
4131
  }
3953
4132
  // The registry block maps rule id → the `const` implementing it.
@@ -3960,7 +4139,7 @@ describe('the `Ships:` line above each rule', () => {
3960
4139
  })
3961
4140
 
3962
4141
  for (const { id, binding } of registry) {
3963
- it(`${id}: the claim matches oxlint.json`, () => {
4142
+ it(`${id}: the claim matches oxlint-basalt.json`, () => {
3964
4143
  const at = lines.indexOf(`const ${binding} = {`)
3965
4144
  expect([id, at]).not.toEqual([id, -1])
3966
4145
  const claim = /^\/\/ Ships: (.+)$/.exec(lines[at - 1] as string)?.[1]
@@ -3985,3 +4164,29 @@ describe('the `Ships:` line above each rule', () => {
3985
4164
  })
3986
4165
  }
3987
4166
  })
4167
+
4168
+ // emailgw r4b: wiring the full preset on a repo with history forced unrelated jsx-a11y/unicorn
4169
+ // errors before a single basalt/* rule could land. The guards-only preset is the guard lane alone,
4170
+ // and the full preset extends it, so every basalt/* level is stated in exactly one file.
4171
+ describe('configs/oxlint-basalt.json — the guards-only preset', () => {
4172
+ const read = (file: string) =>
4173
+ JSON.parse(readFileSync(resolve(import.meta.dirname, file), 'utf8')) as Record<string, unknown>
4174
+ const guards = read('oxlint-basalt.json')
4175
+ const full = read('oxlint.json')
4176
+
4177
+ it('carries the plugin and basalt/* rules, nothing from the generic preset', () => {
4178
+ expect(Object.keys(guards).toSorted()).toEqual(['jsPlugins', 'rules'])
4179
+ expect(guards['jsPlugins']).toEqual(['./oxlint-plugin.js'])
4180
+ const ids = Object.keys(guards['rules'] as object)
4181
+ expect(ids.filter((id) => !id.startsWith('basalt/'))).toEqual([])
4182
+ expect(ids.length).toBeGreaterThan(0)
4183
+ })
4184
+
4185
+ it('is extended by the full preset, which states no basalt/* level of its own', () => {
4186
+ expect(full['extends']).toEqual(['./oxlint-basalt.json'])
4187
+ expect(full['jsPlugins']).toBeUndefined()
4188
+ expect(Object.keys(full['rules'] as object).filter((id) => id.startsWith('basalt/'))).toEqual(
4189
+ [],
4190
+ )
4191
+ })
4192
+ })
@@ -1,6 +1,6 @@
1
1
  {
2
+ "extends": ["./oxlint-basalt.json"],
2
3
  "plugins": ["react", "typescript", "import", "unicorn", "jsx-a11y", "promise", "oxc"],
3
- "jsPlugins": ["./oxlint-plugin.js"],
4
4
  "categories": {
5
5
  "correctness": "error",
6
6
  "suspicious": "warn"
@@ -22,35 +22,7 @@
22
22
  {
23
23
  "allow": ["__APP_VERSION__"]
24
24
  }
25
- ],
26
- "basalt/no-raw-font-size": "error",
27
- "basalt/raw-size-literal": "error",
28
- "basalt/card-inset": "error",
29
- "basalt/chart-in-raw-surface": "error",
30
- "basalt/hand-rolled-plot": "error",
31
- "basalt/chart-legend-literal": "error",
32
- "basalt/shadow-basalt-export": "warn",
33
- "basalt/hand-rolled-shell": "error",
34
- "basalt/raw-scroll-container": "error",
35
- "basalt/hand-rolled-filter": "error",
36
- "basalt/control-outside-home": "warn",
37
- "basalt/bound-control-outside-home": "error",
38
- "basalt/control-size-literal": "error",
39
- "basalt/page-bar-budget": "error",
40
- "basalt/in-body-page-title": "error",
41
- "basalt/responsive-twin": "error",
42
- "basalt/search-literal-link": "error",
43
- "basalt/use-search-from-literal": "error",
44
- "basalt/provider-above-router": "error",
45
- "basalt/query-fn-unwrap": "error",
46
- "basalt/deprecated-export": "warn",
47
- "basalt/forms-field-key": "error",
48
- "basalt/visx-boundary": "error",
49
- "basalt/visx-tooltip": "error",
50
- "basalt/agent-resume-guard": "error",
51
- "basalt/agent-no-raw-usechat": "error",
52
- "basalt/ai-sdk-major": "error",
53
- "basalt/raw-breakpoint": "warn"
25
+ ]
54
26
  },
55
27
  "overrides": [
56
28
  {
@@ -1 +1 @@
1
- {"version":3,"file":"coalesce.d.ts","sourceRoot":"","sources":["../../src/agent/coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,KAAK,EAAE,SAAS,EAAyC,MAAM,SAAS,CAAA;AA+I/E,wBAAgB,aAAa,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EAAE,SAAS,KAAK,EAAE,GAAG,KAAK,EAAE,CA+CvF"}
1
+ {"version":3,"file":"coalesce.d.ts","sourceRoot":"","sources":["../../src/agent/coalesce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,KAAK,EAAE,SAAS,EAAyC,MAAM,SAAS,CAAA;AA8I/E,wBAAgB,aAAa,CAAC,KAAK,SAAS,SAAS,EAAE,KAAK,EAAE,SAAS,KAAK,EAAE,GAAG,KAAK,EAAE,CA+CvF"}