@awarebydefault/display-case 1.0.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/LICENSE +21 -0
- package/README.md +309 -0
- package/display-case.prompt.md +64 -0
- package/docs/ai-agents.md +126 -0
- package/docs/cli.md +99 -0
- package/docs/configuration.md +410 -0
- package/docs/documentation-panel.md +50 -0
- package/docs/examples/README.md +14 -0
- package/docs/examples/multi-variant.case.tsx +30 -0
- package/docs/examples/plain.case.tsx +22 -0
- package/docs/examples/tweak-control.placard.md +80 -0
- package/docs/examples/tweaks.case.tsx +39 -0
- package/docs/hierarchy.md +59 -0
- package/docs/quick-start.md +78 -0
- package/docs/style-engines.md +180 -0
- package/docs/testing.md +245 -0
- package/docs/theming.md +97 -0
- package/docs/tweaks.md +75 -0
- package/docs/writing-cases.md +144 -0
- package/docs/writing-placard-docs.md +194 -0
- package/package.json +113 -0
- package/skills/display-case-author-case/README.md +20 -0
- package/skills/display-case-author-case/SKILL.md +40 -0
- package/skills/display-case-author-placard-doc/README.md +24 -0
- package/skills/display-case-author-placard-doc/SKILL.md +65 -0
- package/skills/display-case-review/README.md +19 -0
- package/skills/display-case-review/SKILL.md +30 -0
- package/skills/display-case-snapshot/README.md +20 -0
- package/skills/display-case-snapshot/SKILL.md +29 -0
- package/src/checks/a11y-scanner.test.ts +240 -0
- package/src/checks/a11y-scanner.ts +410 -0
- package/src/checks/check-text.test.ts +53 -0
- package/src/checks/check-text.ts +78 -0
- package/src/checks/check.test.ts +194 -0
- package/src/checks/check.ts +473 -0
- package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
- package/src/checks/providers/pixelmatch-diff.ts +30 -0
- package/src/checks/providers/playwright-driver.ts +104 -0
- package/src/checks/ssr-check.test.ts +73 -0
- package/src/checks/ssr-check.ts +96 -0
- package/src/checks/structure-check.cross-package.test.ts +165 -0
- package/src/checks/structure-check.test.ts +651 -0
- package/src/checks/structure-check.ts +988 -0
- package/src/checks/tokens-check.test.ts +159 -0
- package/src/checks/tokens-check.ts +162 -0
- package/src/cli.ts +218 -0
- package/src/commands/agents.test.ts +24 -0
- package/src/commands/agents.ts +28 -0
- package/src/commands/init-run.test.ts +123 -0
- package/src/commands/init.test.ts +63 -0
- package/src/commands/init.ts +412 -0
- package/src/commands/publish.test.ts +210 -0
- package/src/commands/publish.ts +292 -0
- package/src/core/affected.test.ts +99 -0
- package/src/core/affected.ts +144 -0
- package/src/core/catalog.test.ts +152 -0
- package/src/core/catalog.ts +92 -0
- package/src/core/discovery.test.ts +184 -0
- package/src/core/discovery.ts +250 -0
- package/src/core/manifest.ts +41 -0
- package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
- package/src/core/mdx-lite/index.ts +393 -0
- package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
- package/src/core/mdx-plugin.test.ts +60 -0
- package/src/core/mdx-plugin.ts +30 -0
- package/src/flow-step.test-d.ts +39 -0
- package/src/index.test.ts +100 -0
- package/src/index.ts +564 -0
- package/src/render/collect-styles.emotion.test.tsx +114 -0
- package/src/render/collect-styles.test.tsx +72 -0
- package/src/render/collect-styles.ts +33 -0
- package/src/render/documents.test.ts +184 -0
- package/src/render/documents.ts +88 -0
- package/src/render/render-node.test.tsx +160 -0
- package/src/render/render-node.tsx +133 -0
- package/src/render/ssr-primer.test.tsx +25 -0
- package/src/render/ssr-primer.tsx +54 -0
- package/src/render/ssr-render.test.tsx +142 -0
- package/src/render/ssr-render.tsx +63 -0
- package/src/render/ssr-shell.test.tsx +57 -0
- package/src/render/ssr-shell.tsx +54 -0
- package/src/server/prod-server.ts +237 -0
- package/src/server/server.test.ts +117 -0
- package/src/server/server.ts +1039 -0
- package/src/style-engine.test-d.ts +37 -0
- package/src/testing/test-helpers.ts +27 -0
- package/src/types/pixelmatch.d.ts +12 -0
- package/src/ui/browser-entry.tsx +51 -0
- package/src/ui/chrome.css +485 -0
- package/src/ui/design-system/README.md +88 -0
- package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
- package/src/ui/design-system/components/controls/Button.css +89 -0
- package/src/ui/design-system/components/controls/Button.placard.md +14 -0
- package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
- package/src/ui/design-system/components/controls/Button.tsx +41 -0
- package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
- package/src/ui/design-system/components/controls/IconButton.css +67 -0
- package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
- package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
- package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
- package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
- package/src/ui/design-system/components/controls/Input.css +52 -0
- package/src/ui/design-system/components/controls/Input.placard.md +12 -0
- package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
- package/src/ui/design-system/components/controls/Input.tsx +45 -0
- package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
- package/src/ui/design-system/components/controls/Select.css +44 -0
- package/src/ui/design-system/components/controls/Select.placard.md +15 -0
- package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
- package/src/ui/design-system/components/controls/Select.tsx +58 -0
- package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
- package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
- package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
- package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
- package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
- package/src/ui/design-system/components/index.ts +66 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
- package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
- package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
- package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
- package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
- package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
- package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
- package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
- package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
- package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
- package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
- package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
- package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
- package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
- package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
- package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
- package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
- package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
- package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
- package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
- package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
- package/src/ui/design-system/components/showcase/Chip.css +51 -0
- package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
- package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
- package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
- package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
- package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
- package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
- package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
- package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
- package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
- package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
- package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
- package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
- package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
- package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
- package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
- package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
- package/src/ui/design-system/components/showcase/NavItem.css +111 -0
- package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
- package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
- package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
- package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
- package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
- package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
- package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
- package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
- package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
- package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
- package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
- package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
- package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
- package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
- package/src/ui/design-system/components/showcase/Stage.css +91 -0
- package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
- package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
- package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
- package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
- package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
- package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
- package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
- package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
- package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
- package/src/ui/design-system/primer-specimens/components.tsx +308 -0
- package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
- package/src/ui/design-system/primer-specimens/index.ts +25 -0
- package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
- package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
- package/src/ui/design-system/primer-specimens/type.tsx +75 -0
- package/src/ui/design-system/primer.mdx +236 -0
- package/src/ui/design-system/styles.css +14 -0
- package/src/ui/design-system/tokens/colors.css +172 -0
- package/src/ui/design-system/tokens/fonts.css +18 -0
- package/src/ui/design-system/tokens/spacing.css +48 -0
- package/src/ui/design-system/tokens/typography.css +49 -0
- package/src/ui/markdown.test.tsx +54 -0
- package/src/ui/markdown.tsx +19 -0
- package/src/ui/primer-mount.tsx +76 -0
- package/src/ui/primer.css +175 -0
- package/src/ui/primer.tsx +277 -0
- package/src/ui/render-mount.tsx +284 -0
- package/src/ui/shell-core.test.ts +340 -0
- package/src/ui/shell-core.ts +295 -0
- package/src/ui/shell.tsx +60 -0
- package/src/ui/test-ids.ts +53 -0
- package/src/ui/use-shell.ts +1230 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
2
|
+
import { Glob } from 'bun'
|
|
3
|
+
import { slugify } from '../core/catalog'
|
|
4
|
+
import {
|
|
5
|
+
discoverCaseFiles,
|
|
6
|
+
loadModules,
|
|
7
|
+
resolveConfig,
|
|
8
|
+
} from '../core/discovery'
|
|
9
|
+
import { segmentMdx } from '../core/mdx-lite'
|
|
10
|
+
import type {
|
|
11
|
+
CaseModule,
|
|
12
|
+
DisplayCaseConfig,
|
|
13
|
+
HierarchyLevel,
|
|
14
|
+
StructureRuleId,
|
|
15
|
+
StructureRuleOptions,
|
|
16
|
+
StructureSeverity,
|
|
17
|
+
} from '../index'
|
|
18
|
+
import { HIERARCHY_LEVELS } from '../index'
|
|
19
|
+
import { blankComments } from './check-text'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Static "structure" best-practice checks for a Display-Case-ingested package.
|
|
23
|
+
*
|
|
24
|
+
* Browser-free and side-effect-free: every rule reads files, the resolved
|
|
25
|
+
* config, and the loaded case modules — never a render or a server. The rules
|
|
26
|
+
* fall into three groups (file/config, catalog-integrity, composition), each
|
|
27
|
+
* independently disablable and severity-tunable via `config.check.structure`.
|
|
28
|
+
* See `openspec/specs/display-case/spec.md` and the change's design.md.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface StructureFinding {
|
|
32
|
+
rule: StructureRuleId
|
|
33
|
+
severity: StructureSeverity
|
|
34
|
+
/** Absolute path of the file the finding is attributed to. */
|
|
35
|
+
file: string
|
|
36
|
+
message: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StructureCheckResult {
|
|
40
|
+
findings: StructureFinding[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a rule returns: a finding minus its severity (filled in from the rule's
|
|
45
|
+
* resolved severity), unless the rule pins one itself — e.g. the scoped
|
|
46
|
+
* "unresolved showcase import" notice is always a warning even under an
|
|
47
|
+
* error-severity composition rule.
|
|
48
|
+
*/
|
|
49
|
+
type RuleFinding = Omit<StructureFinding, 'severity'> & {
|
|
50
|
+
severity?: StructureSeverity
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RuleDefault {
|
|
54
|
+
enabled: boolean
|
|
55
|
+
severity: StructureSeverity
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Out-of-the-box enabled-state + severity for every rule. */
|
|
59
|
+
const RULE_DEFAULTS: Record<StructureRuleId, RuleDefault> = {
|
|
60
|
+
'case-placard-coverage': { enabled: true, severity: 'error' },
|
|
61
|
+
'no-orphaned-placard-doc': { enabled: true, severity: 'error' },
|
|
62
|
+
'primer-present-and-used': { enabled: true, severity: 'error' },
|
|
63
|
+
'setup-present': { enabled: true, severity: 'error' },
|
|
64
|
+
'config-paths-exist': { enabled: true, severity: 'error' },
|
|
65
|
+
'levels-classified': { enabled: true, severity: 'error' },
|
|
66
|
+
'cases-load': { enabled: true, severity: 'error' },
|
|
67
|
+
'flow-transitions-resolve': { enabled: true, severity: 'error' },
|
|
68
|
+
'flow-multi-step': { enabled: true, severity: 'error' },
|
|
69
|
+
'unique-slugs': { enabled: true, severity: 'error' },
|
|
70
|
+
'tweak-defaults-valid': { enabled: true, severity: 'error' },
|
|
71
|
+
'interactive-cases-keyed': { enabled: true, severity: 'error' },
|
|
72
|
+
'atom-purity': { enabled: false, severity: 'error' },
|
|
73
|
+
'no-downward-dependency': { enabled: false, severity: 'error' },
|
|
74
|
+
'composes-lower-level': { enabled: false, severity: 'warn' },
|
|
75
|
+
'level-fit': { enabled: false, severity: 'warn' },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `level-fit` default per-level promotion thresholds (lower-level child count). */
|
|
79
|
+
const DEFAULT_THRESHOLDS: Partial<Record<HierarchyLevel, number>> = {
|
|
80
|
+
molecule: 6,
|
|
81
|
+
organism: 12,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ResolvedRule {
|
|
85
|
+
enabled: boolean
|
|
86
|
+
severity: StructureSeverity
|
|
87
|
+
options: StructureRuleOptions
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveRule(
|
|
91
|
+
id: StructureRuleId,
|
|
92
|
+
config: DisplayCaseConfig,
|
|
93
|
+
): ResolvedRule {
|
|
94
|
+
const def = RULE_DEFAULTS[id]
|
|
95
|
+
const setting = config.check?.structure?.rules?.[id]
|
|
96
|
+
if (setting === undefined) {
|
|
97
|
+
return { enabled: def.enabled, severity: def.severity, options: {} }
|
|
98
|
+
}
|
|
99
|
+
if (setting === false) {
|
|
100
|
+
return { enabled: false, severity: def.severity, options: {} }
|
|
101
|
+
}
|
|
102
|
+
if (setting === 'warn' || setting === 'error') {
|
|
103
|
+
return { enabled: true, severity: setting, options: {} }
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
enabled: true,
|
|
107
|
+
severity: setting.severity ?? def.severity,
|
|
108
|
+
options: setting,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const levelIndex = (l: HierarchyLevel | null): number =>
|
|
113
|
+
l ? HIERARCHY_LEVELS.indexOf(l) : HIERARCHY_LEVELS.length
|
|
114
|
+
|
|
115
|
+
/** A `display-case: <token>` marker for any of the given tokens. */
|
|
116
|
+
function hasMarker(text: string, tokens: string[]): boolean {
|
|
117
|
+
return tokens.some((t) =>
|
|
118
|
+
new RegExp(`display-case:\\s*${t}(\\s|$)`).test(text),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Shared inputs ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
interface SharedInputs {
|
|
125
|
+
pkgDir: string
|
|
126
|
+
config: DisplayCaseConfig
|
|
127
|
+
configPath: string
|
|
128
|
+
caseFiles: string[]
|
|
129
|
+
modules: { file: string; module: CaseModule }[]
|
|
130
|
+
loadErrors: { file: string; error: string }[]
|
|
131
|
+
/** Where the checks' own tooling resolves from (the display-case package). */
|
|
132
|
+
toolingDir: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── File / config rules ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** Component-module globs implied by `*.case.tsx` roots (sibling `*.tsx`). */
|
|
138
|
+
function componentGlobs(config: DisplayCaseConfig): string[] {
|
|
139
|
+
return config.roots
|
|
140
|
+
.filter((r) => r.endsWith('.case.tsx'))
|
|
141
|
+
.map((r) => r.replace(/\.case\.tsx$/, '.tsx'))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isNonComponent(file: string): boolean {
|
|
145
|
+
return (
|
|
146
|
+
file.endsWith('.case.tsx') ||
|
|
147
|
+
file.endsWith('.d.ts') ||
|
|
148
|
+
file.endsWith('.test.tsx') ||
|
|
149
|
+
file.endsWith('.test.ts')
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function ruleCasePlacardCoverage(
|
|
154
|
+
s: SharedInputs,
|
|
155
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
156
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
157
|
+
for (const pattern of componentGlobs(s.config)) {
|
|
158
|
+
const glob = new Glob(pattern)
|
|
159
|
+
for await (const file of glob.scan({ cwd: s.pkgDir, absolute: true })) {
|
|
160
|
+
if (file.includes('/node_modules/') || isNonComponent(file)) continue
|
|
161
|
+
const text = await Bun.file(file).text()
|
|
162
|
+
// `no-case` declares a module non-showcasable (existing convention), so it
|
|
163
|
+
// is fully exempt — a non-component needs neither a case nor a prompt.
|
|
164
|
+
if (hasMarker(text, ['no-case', 'allow-case-placard-coverage'])) continue
|
|
165
|
+
const base = file.replace(/\.tsx$/, '')
|
|
166
|
+
const needsPrompt = !hasMarker(text, ['no-placard'])
|
|
167
|
+
if (!(await Bun.file(`${base}.case.tsx`).exists())) {
|
|
168
|
+
out.push({
|
|
169
|
+
rule: 'case-placard-coverage',
|
|
170
|
+
file,
|
|
171
|
+
message:
|
|
172
|
+
'missing colocated case file (expected a sibling *.case.tsx)',
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
if (needsPrompt && !(await Bun.file(`${base}.placard.md`).exists())) {
|
|
176
|
+
out.push({
|
|
177
|
+
rule: 'case-placard-coverage',
|
|
178
|
+
file,
|
|
179
|
+
message:
|
|
180
|
+
'missing colocated usage doc (expected a sibling *.placard.md)',
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function ruleNoOrphanedPlacardDoc(
|
|
189
|
+
s: SharedInputs,
|
|
190
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
191
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
192
|
+
const placardGlobs = s.config.roots
|
|
193
|
+
.filter((r) => r.endsWith('.case.tsx'))
|
|
194
|
+
.map((r) => r.replace(/\.case\.tsx$/, '.placard.md'))
|
|
195
|
+
const seen = new Set<string>()
|
|
196
|
+
for (const pattern of placardGlobs) {
|
|
197
|
+
const glob = new Glob(pattern)
|
|
198
|
+
for await (const file of glob.scan({ cwd: s.pkgDir, absolute: true })) {
|
|
199
|
+
if (file.includes('/node_modules/') || seen.has(file)) continue
|
|
200
|
+
seen.add(file)
|
|
201
|
+
const text = await Bun.file(file).text()
|
|
202
|
+
if (hasMarker(text, ['allow-orphan', 'allow-no-orphaned-placard-doc'])) {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
const casePath = file.replace(/\.placard\.md$/, '.case.tsx')
|
|
206
|
+
if (!(await Bun.file(casePath).exists())) {
|
|
207
|
+
out.push({
|
|
208
|
+
rule: 'no-orphaned-placard-doc',
|
|
209
|
+
file,
|
|
210
|
+
message: 'orphaned usage doc (no sibling *.case.tsx)',
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function ruleConfigPathsExist(
|
|
219
|
+
s: SharedInputs,
|
|
220
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
221
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
222
|
+
for (const rel of s.config.globalStyles ?? []) {
|
|
223
|
+
if (!(await Bun.file(resolve(s.pkgDir, rel)).exists())) {
|
|
224
|
+
out.push({
|
|
225
|
+
rule: 'config-paths-exist',
|
|
226
|
+
file: s.configPath,
|
|
227
|
+
message: `globalStyles entry does not exist: ${rel}`,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (s.config.baselineDir) {
|
|
232
|
+
const dir = resolve(s.pkgDir, s.config.baselineDir)
|
|
233
|
+
// A baseline dir may legitimately not exist yet (nothing recorded); only an
|
|
234
|
+
// absolute/explicit path that resolves to a *file* is wrong. Skip silently
|
|
235
|
+
// when absent — recording creates it — so this just guards a misconfigured
|
|
236
|
+
// path that collides with a file.
|
|
237
|
+
const f = Bun.file(dir)
|
|
238
|
+
if ((await f.exists()) && (await f.stat()).isFile()) {
|
|
239
|
+
out.push({
|
|
240
|
+
rule: 'config-paths-exist',
|
|
241
|
+
file: s.configPath,
|
|
242
|
+
message: `baselineDir points at a file, not a directory: ${s.config.baselineDir}`,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function ruleSetupPresent(
|
|
250
|
+
s: SharedInputs,
|
|
251
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
252
|
+
if (s.config.providers?.driver || s.config.providers?.diff) return []
|
|
253
|
+
const required = ['playwright', '@axe-core/playwright', 'pixelmatch', 'pngjs']
|
|
254
|
+
// The default backend resolves the toolchain relative to the display-case
|
|
255
|
+
// package (see check.ts → providers/*), NOT the consumer. Probe both: the
|
|
256
|
+
// setup is present if either the showcase or the checks' own tooling can
|
|
257
|
+
// resolve it, so a consumer that gets the toolchain transitively via
|
|
258
|
+
// display-case is not falsely flagged.
|
|
259
|
+
const probeDirs = [s.pkgDir, s.toolingDir]
|
|
260
|
+
const resolvable = (pkg: string): boolean =>
|
|
261
|
+
probeDirs.some((dir) => {
|
|
262
|
+
try {
|
|
263
|
+
Bun.resolveSync(pkg, dir)
|
|
264
|
+
return true
|
|
265
|
+
} catch {
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
const missing = required.filter((pkg) => !resolvable(pkg))
|
|
270
|
+
if (missing.length === 0) return []
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
rule: 'setup-present',
|
|
274
|
+
file: s.configPath,
|
|
275
|
+
message:
|
|
276
|
+
`render checks cannot run: missing ${missing.join(', ')}. Install the ` +
|
|
277
|
+
'default toolchain (or run `display-case init --with-visual`), or set ' +
|
|
278
|
+
'`providers.driver`/`providers.diff` in the config.',
|
|
279
|
+
},
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function rulePrimerPresentAndUsed(
|
|
284
|
+
s: SharedInputs,
|
|
285
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
286
|
+
const find = (message: string): Omit<StructureFinding, 'severity'>[] => [
|
|
287
|
+
{ rule: 'primer-present-and-used', file: s.configPath, message },
|
|
288
|
+
]
|
|
289
|
+
if (!s.config.primer) return find('no primer is configured')
|
|
290
|
+
const path = resolve(s.pkgDir, s.config.primer)
|
|
291
|
+
if (!(await Bun.file(path).exists())) {
|
|
292
|
+
return find(`configured primer does not exist: ${s.config.primer}`)
|
|
293
|
+
}
|
|
294
|
+
const text = await Bun.file(path).text()
|
|
295
|
+
let blocks: ReturnType<typeof segmentMdx>
|
|
296
|
+
try {
|
|
297
|
+
blocks = segmentMdx(text)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return find(
|
|
300
|
+
`could not parse primer: ${err instanceof Error ? err.message : String(err)}`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
// Count <Display> specimens across the document's block-level JSX, and confirm
|
|
304
|
+
// the primer has at least one prose block alongside them.
|
|
305
|
+
const displays = blocks
|
|
306
|
+
.filter((b) => b.kind === 'jsx')
|
|
307
|
+
.reduce((n, b) => n + b.tags.filter((t) => t === 'Display').length, 0)
|
|
308
|
+
const hasContent = blocks.some((b) => b.kind === 'markdown')
|
|
309
|
+
if (displays === 0) {
|
|
310
|
+
return find('primer embeds no <Display> specimen')
|
|
311
|
+
}
|
|
312
|
+
if (!hasContent) {
|
|
313
|
+
return find('primer has specimens but no prose content')
|
|
314
|
+
}
|
|
315
|
+
return []
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Catalog-integrity rules ──────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
function ruleCasesLoad(s: SharedInputs): Omit<StructureFinding, 'severity'>[] {
|
|
321
|
+
return s.loadErrors.map((e) => ({
|
|
322
|
+
rule: 'cases-load' as const,
|
|
323
|
+
file: e.file,
|
|
324
|
+
message: `case file failed to load: ${e.error}`,
|
|
325
|
+
}))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function ruleLevelsClassified(
|
|
329
|
+
s: SharedInputs,
|
|
330
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
331
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
332
|
+
for (const { file, module } of s.modules) {
|
|
333
|
+
if (module.level) continue
|
|
334
|
+
const text = await Bun.file(file).text()
|
|
335
|
+
if (hasMarker(text, ['unclassified', 'allow-levels-classified'])) continue
|
|
336
|
+
out.push({
|
|
337
|
+
rule: 'levels-classified',
|
|
338
|
+
file,
|
|
339
|
+
message: `component "${module.component}" declares no hierarchy level (unclassified)`,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
return out
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function ruleFlowTransitionsResolve(
|
|
346
|
+
s: SharedInputs,
|
|
347
|
+
): Omit<StructureFinding, 'severity'>[] {
|
|
348
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
349
|
+
for (const { file, module } of s.modules) {
|
|
350
|
+
if (!module.isFlow) continue
|
|
351
|
+
const stepIds = new Set(Object.keys(module.cases).map(slugify))
|
|
352
|
+
for (const [stepName, step] of Object.entries(module.cases)) {
|
|
353
|
+
if (typeof step === 'function' || !('transitions' in step)) continue
|
|
354
|
+
for (const target of step.transitions ?? []) {
|
|
355
|
+
if (!stepIds.has(slugify(target))) {
|
|
356
|
+
out.push({
|
|
357
|
+
rule: 'flow-transitions-resolve',
|
|
358
|
+
file,
|
|
359
|
+
message: `flow "${module.component}" step "${stepName}" transitions to unknown step "${target}"`,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return out
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function ruleFlowMultiStep(
|
|
369
|
+
s: SharedInputs,
|
|
370
|
+
): Omit<StructureFinding, 'severity'>[] {
|
|
371
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
372
|
+
for (const { file, module } of s.modules) {
|
|
373
|
+
if (module.isFlow && Object.keys(module.cases).length <= 1) {
|
|
374
|
+
out.push({
|
|
375
|
+
rule: 'flow-multi-step',
|
|
376
|
+
file,
|
|
377
|
+
message: `flow "${module.component}" has ${Object.keys(module.cases).length} step(s); a flow needs more than one (use defineCases for a single state)`,
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return out
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function ruleUniqueSlugs(
|
|
385
|
+
s: SharedInputs,
|
|
386
|
+
): Omit<StructureFinding, 'severity'>[] {
|
|
387
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
388
|
+
const byComponentSlug = new Map<string, { file: string; name: string }[]>()
|
|
389
|
+
for (const { file, module } of s.modules) {
|
|
390
|
+
const slug = slugify(module.component)
|
|
391
|
+
const arr = byComponentSlug.get(slug) ?? []
|
|
392
|
+
arr.push({ file, name: module.component })
|
|
393
|
+
byComponentSlug.set(slug, arr)
|
|
394
|
+
// Case-slug collisions within this component.
|
|
395
|
+
const caseSlugs = new Map<string, string[]>()
|
|
396
|
+
for (const name of Object.keys(module.cases)) {
|
|
397
|
+
const cs = slugify(name)
|
|
398
|
+
const names = caseSlugs.get(cs) ?? []
|
|
399
|
+
names.push(name)
|
|
400
|
+
caseSlugs.set(cs, names)
|
|
401
|
+
}
|
|
402
|
+
for (const [cs, names] of caseSlugs) {
|
|
403
|
+
if (names.length > 1) {
|
|
404
|
+
out.push({
|
|
405
|
+
rule: 'unique-slugs',
|
|
406
|
+
file,
|
|
407
|
+
message: `component "${module.component}" has cases colliding on slug "${cs}": ${names.join(', ')}`,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
for (const [slug, entries] of byComponentSlug) {
|
|
413
|
+
if (entries.length > 1) {
|
|
414
|
+
out.push({
|
|
415
|
+
rule: 'unique-slugs',
|
|
416
|
+
file: entries[0].file,
|
|
417
|
+
message: `components collide on slug "${slug}": ${entries.map((e) => e.name).join(', ')}`,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return out
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function ruleTweakDefaultsValid(
|
|
425
|
+
s: SharedInputs,
|
|
426
|
+
): Omit<StructureFinding, 'severity'>[] {
|
|
427
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
428
|
+
for (const { file, module } of s.modules) {
|
|
429
|
+
for (const [caseName, c] of Object.entries(module.cases)) {
|
|
430
|
+
const tweaks = typeof c === 'function' ? null : c.tweaks
|
|
431
|
+
if (!tweaks) continue
|
|
432
|
+
for (const [tweakName, t] of Object.entries(tweaks)) {
|
|
433
|
+
if (t.kind === 'choice' && !t.options.includes(t.default as string)) {
|
|
434
|
+
out.push({
|
|
435
|
+
rule: 'tweak-defaults-valid',
|
|
436
|
+
file,
|
|
437
|
+
message: `${module.component} / ${caseName}: choice tweak "${tweakName}" default "${t.default}" is not one of its options`,
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return out
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Case-content rules ───────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Find the end of a JSX opening tag that starts at `from` (`<`), tolerating
|
|
450
|
+
* `>` inside `{…}` attribute expressions (e.g. `onClick={() => a > b}`) and the
|
|
451
|
+
* `>` of an arrow `=>`. Returns the index of the tag-closing `>`.
|
|
452
|
+
*/
|
|
453
|
+
function openingTagEnd(text: string, from: number): number {
|
|
454
|
+
let depth = 0
|
|
455
|
+
for (let i = from; i < text.length; i++) {
|
|
456
|
+
const c = text[i]
|
|
457
|
+
if (c === '{') depth++
|
|
458
|
+
else if (c === '}') depth--
|
|
459
|
+
else if (c === '>' && depth === 0 && text[i - 1] !== '=') return i
|
|
460
|
+
}
|
|
461
|
+
return text.length
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Names of locally-defined components whose body calls a React state hook. */
|
|
465
|
+
function statefulLocalComponents(text: string): Set<string> {
|
|
466
|
+
// Every local component definition and where it starts.
|
|
467
|
+
const defs: { name: string; index: number }[] = []
|
|
468
|
+
const defRe = /(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*=)/g
|
|
469
|
+
for (let m = defRe.exec(text); m; m = defRe.exec(text)) {
|
|
470
|
+
defs.push({ name: m[1] ?? m[2], index: m.index })
|
|
471
|
+
}
|
|
472
|
+
// Attribute each `useState`/`useReducer` to the closest preceding definition —
|
|
473
|
+
// that component holds the state. (A pure helper in the same file is ignored.)
|
|
474
|
+
const stateful = new Set<string>()
|
|
475
|
+
const hookRe = /\buse(?:State|Reducer)\b/g
|
|
476
|
+
for (let h = hookRe.exec(text); h; h = hookRe.exec(text)) {
|
|
477
|
+
let owner: string | null = null
|
|
478
|
+
for (const d of defs) {
|
|
479
|
+
if (d.index < h.index) owner = d.name
|
|
480
|
+
else break
|
|
481
|
+
}
|
|
482
|
+
if (owner) stateful.add(owner)
|
|
483
|
+
}
|
|
484
|
+
return stateful
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The browse chrome swaps cases *in place* — `root.render()` with no remount
|
|
489
|
+
* (render-mount.tsx) — so a stateful specimen rendered at the same tree position
|
|
490
|
+
* across cases keeps its `useState` value unless given a distinct `key`. Between
|
|
491
|
+
* cases whose props differ (a different selected id, a disjoint option set) the
|
|
492
|
+
* leaked state shows the wrong selection — or none at all — on switch. Flag a
|
|
493
|
+
* locally-defined stateful specimen rendered in ≥2 cases where any usage omits a
|
|
494
|
+
* `key`. (Single-use specimens always remount — the sibling case renders a
|
|
495
|
+
* different element — so they are safe.)
|
|
496
|
+
*/
|
|
497
|
+
async function ruleInteractiveCasesKeyed(
|
|
498
|
+
s: SharedInputs,
|
|
499
|
+
): Promise<RuleFinding[]> {
|
|
500
|
+
const out: RuleFinding[] = []
|
|
501
|
+
for (const file of s.caseFiles) {
|
|
502
|
+
if (file.includes('/node_modules/')) continue
|
|
503
|
+
const raw = await Bun.file(file).text()
|
|
504
|
+
if (hasMarker(raw, ['allow-interactive-cases-keyed'])) continue
|
|
505
|
+
// Scan with comments blanked (offsets preserved) so a `<Demo>` mentioned in
|
|
506
|
+
// prose — like this rule's own guidance comment — is never miscounted.
|
|
507
|
+
const text = blankComments(raw, false)
|
|
508
|
+
const stateful = statefulLocalComponents(text)
|
|
509
|
+
if (stateful.size === 0) continue
|
|
510
|
+
// Only the case thunks (which follow `defineCases(`) render specimens; the
|
|
511
|
+
// component definitions above it hold their own internal JSX, which is not a
|
|
512
|
+
// per-case mount and must not be counted.
|
|
513
|
+
const dc = text.search(/\bdefineCases\s*\(/)
|
|
514
|
+
if (dc < 0) continue
|
|
515
|
+
const region = text.slice(dc)
|
|
516
|
+
for (const name of stateful) {
|
|
517
|
+
const useRe = new RegExp(`<${name}(?![A-Za-z0-9_])`, 'g')
|
|
518
|
+
let total = 0
|
|
519
|
+
let unkeyed = 0
|
|
520
|
+
for (let m = useRe.exec(region); m; m = useRe.exec(region)) {
|
|
521
|
+
total++
|
|
522
|
+
const tag = region.slice(m.index, openingTagEnd(region, m.index) + 1)
|
|
523
|
+
if (!/\bkey\s*=/.test(tag)) unkeyed++
|
|
524
|
+
}
|
|
525
|
+
if (total >= 2 && unkeyed > 0) {
|
|
526
|
+
out.push({
|
|
527
|
+
rule: 'interactive-cases-keyed',
|
|
528
|
+
file,
|
|
529
|
+
message: `interactive specimen <${name}> is rendered in ${total} cases but ${unkeyed} omit a \`key\` — the browse chrome swaps cases in place (no remount), so React reuses <${name}>'s state across them, leaving a stale or empty selection on switch. Give each case's <${name}> a distinct \`key\` (see docs/writing-cases.md), or waive with a \`display-case: allow-interactive-cases-keyed\` comment.`,
|
|
530
|
+
})
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return out
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Composition (import-graph) rules ─────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
/** Map an absolute component-source path to its declared level (built per pkg). */
|
|
540
|
+
type LevelMap = Map<string, HierarchyLevel | null>
|
|
541
|
+
|
|
542
|
+
function buildLevelMap(modules: SharedInputs['modules']): LevelMap {
|
|
543
|
+
const map: LevelMap = new Map()
|
|
544
|
+
for (const { file, module } of modules) {
|
|
545
|
+
map.set(file.replace(/\.case\.tsx$/, '.tsx'), module.level ?? null)
|
|
546
|
+
}
|
|
547
|
+
return map
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
interface ParsedImport {
|
|
551
|
+
source: string
|
|
552
|
+
names: string[]
|
|
553
|
+
typeOnly: boolean
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const IMPORT_RE = /import\s+(type\s+)?([\s\S]*?)\s+from\s*['"]([^'"]+)['"]/g
|
|
557
|
+
|
|
558
|
+
// Reused across files. Bun's transpiler resolves which imports are *real* —
|
|
559
|
+
// ignoring commented-out and string-literal imports, and dropping (erased)
|
|
560
|
+
// type-only ones — so a composition dependency can't be conjured by a comment.
|
|
561
|
+
const IMPORT_SCANNER = new Bun.Transpiler({ loader: 'tsx' })
|
|
562
|
+
|
|
563
|
+
/** The set of genuine runtime import paths Bun's parser sees, or null if the
|
|
564
|
+
* file has syntax the scanner rejects (then the regex stands alone). */
|
|
565
|
+
function realImportPaths(code: string): Set<string> | null {
|
|
566
|
+
try {
|
|
567
|
+
return new Set(
|
|
568
|
+
IMPORT_SCANNER.scan(code)
|
|
569
|
+
.imports.filter((i) => i.kind === 'import-statement')
|
|
570
|
+
.map((i) => i.path),
|
|
571
|
+
)
|
|
572
|
+
} catch {
|
|
573
|
+
// Bun's scanner throws on a few JSX shapes (e.g. `key` after a spread). Fall
|
|
574
|
+
// back to the regex alone rather than dropping the file's imports entirely.
|
|
575
|
+
return null
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Parse import statements for source + named bindings (skips bare side-effect
|
|
580
|
+
* imports). Bun's transpiler supplies the authoritative set of real imports; the
|
|
581
|
+
* regex contributes the named bindings it doesn't expose. */
|
|
582
|
+
function parseImports(code: string): ParsedImport[] {
|
|
583
|
+
const real = realImportPaths(code)
|
|
584
|
+
const out: ParsedImport[] = []
|
|
585
|
+
IMPORT_RE.lastIndex = 0
|
|
586
|
+
for (let m = IMPORT_RE.exec(code); m; m = IMPORT_RE.exec(code)) {
|
|
587
|
+
const typeOnly = Boolean(m[1])
|
|
588
|
+
const clause = m[2]
|
|
589
|
+
const source = m[3]
|
|
590
|
+
// When the scanner ran, trust it: keep only statements it confirmed are real
|
|
591
|
+
// runtime imports (this drops type-only, commented, and string-literal
|
|
592
|
+
// matches). When it threw, `real` is null and every regex match is kept.
|
|
593
|
+
if (real && !real.has(source)) continue
|
|
594
|
+
const names: string[] = []
|
|
595
|
+
const braced = clause.match(/\{([^}]*)\}/)
|
|
596
|
+
if (braced) {
|
|
597
|
+
for (const part of braced[1].split(',')) {
|
|
598
|
+
const name = part
|
|
599
|
+
.trim()
|
|
600
|
+
.split(/\s+as\s+/)[0]
|
|
601
|
+
.trim()
|
|
602
|
+
if (name && name !== 'type') names.push(name)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
out.push({ source, names, typeOnly })
|
|
606
|
+
}
|
|
607
|
+
return out
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const RESOLVE_EXTS = ['.tsx', '.ts', '.jsx', '.js']
|
|
611
|
+
function candidatePaths(base: string): string[] {
|
|
612
|
+
return [
|
|
613
|
+
base,
|
|
614
|
+
...RESOLVE_EXTS.map((e) => base + e),
|
|
615
|
+
...RESOLVE_EXTS.map((e) => join(base, `index${e}`)),
|
|
616
|
+
]
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Foreign workspace showcase catalog, cached per package root. */
|
|
620
|
+
interface ForeignShowcase {
|
|
621
|
+
pkgDir: string
|
|
622
|
+
levelMap: LevelMap
|
|
623
|
+
/** Re-export name → absolute target file (best-effort, common barrel form). */
|
|
624
|
+
reexports: Map<string, string>
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const foreignCache = new Map<string, ForeignShowcase | null>()
|
|
628
|
+
|
|
629
|
+
async function loadForeignShowcase(
|
|
630
|
+
pkgDir: string,
|
|
631
|
+
): Promise<ForeignShowcase | null> {
|
|
632
|
+
if (foreignCache.has(pkgDir)) return foreignCache.get(pkgDir) ?? null
|
|
633
|
+
let result: ForeignShowcase | null = null
|
|
634
|
+
try {
|
|
635
|
+
const hasConfig =
|
|
636
|
+
(await Bun.file(join(pkgDir, 'display-case.config.ts')).exists()) ||
|
|
637
|
+
(await Bun.file(join(pkgDir, 'display-case.config.tsx')).exists())
|
|
638
|
+
if (hasConfig) {
|
|
639
|
+
const { config } = await resolveConfig(pkgDir)
|
|
640
|
+
const files = await discoverCaseFiles(pkgDir, config)
|
|
641
|
+
const { modules } = await loadModules(files)
|
|
642
|
+
const levelMap = buildLevelMap(
|
|
643
|
+
modules.map((m) => ({ file: m.file, module: m.module })),
|
|
644
|
+
)
|
|
645
|
+
result = { pkgDir, levelMap, reexports: new Map() }
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
result = null
|
|
649
|
+
}
|
|
650
|
+
foreignCache.set(pkgDir, result)
|
|
651
|
+
return result
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const REEXPORT_RE = /export\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g
|
|
655
|
+
|
|
656
|
+
/** Resolve a named re-export through a barrel entry to a target component file. */
|
|
657
|
+
async function followReexport(
|
|
658
|
+
entryFile: string,
|
|
659
|
+
name: string,
|
|
660
|
+
): Promise<string | null> {
|
|
661
|
+
let text: string
|
|
662
|
+
try {
|
|
663
|
+
text = await Bun.file(entryFile).text()
|
|
664
|
+
} catch {
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
REEXPORT_RE.lastIndex = 0
|
|
668
|
+
for (let m = REEXPORT_RE.exec(text); m; m = REEXPORT_RE.exec(text)) {
|
|
669
|
+
const exported = m[1].split(',').map((p) =>
|
|
670
|
+
p
|
|
671
|
+
.trim()
|
|
672
|
+
.split(/\s+as\s+/)
|
|
673
|
+
.pop()
|
|
674
|
+
?.trim(),
|
|
675
|
+
)
|
|
676
|
+
if (!exported.includes(name)) continue
|
|
677
|
+
for (const cand of candidatePaths(resolve(dirname(entryFile), m[2]))) {
|
|
678
|
+
if (await Bun.file(cand).exists()) return cand
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return null
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
interface DepResolution {
|
|
685
|
+
/** Levels of resolved showcased components this import contributes. */
|
|
686
|
+
levels: (HierarchyLevel | null)[]
|
|
687
|
+
/** True when it looked like a workspace showcase import but couldn't resolve. */
|
|
688
|
+
unresolvedShowcase: boolean
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function resolveDependency(
|
|
692
|
+
imp: ParsedImport,
|
|
693
|
+
fromFile: string,
|
|
694
|
+
pkgDir: string,
|
|
695
|
+
levelMap: LevelMap,
|
|
696
|
+
): Promise<DepResolution> {
|
|
697
|
+
const res: DepResolution = { levels: [], unresolvedShowcase: false }
|
|
698
|
+
if (imp.typeOnly) return res
|
|
699
|
+
|
|
700
|
+
// Relative import: resolve straight to a component file.
|
|
701
|
+
if (imp.source.startsWith('.')) {
|
|
702
|
+
const base = resolve(dirname(fromFile), imp.source)
|
|
703
|
+
for (const cand of candidatePaths(base)) {
|
|
704
|
+
if (levelMap.has(cand)) {
|
|
705
|
+
res.levels.push(levelMap.get(cand) ?? null)
|
|
706
|
+
return res
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return res
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Bare specifier: cross-package. Only workspace showcases expose levels.
|
|
713
|
+
let entry: string
|
|
714
|
+
try {
|
|
715
|
+
entry = Bun.resolveSync(imp.source, pkgDir)
|
|
716
|
+
} catch {
|
|
717
|
+
return res
|
|
718
|
+
}
|
|
719
|
+
// Walk up from the resolved entry to a package root holding a showcase config.
|
|
720
|
+
let dir = dirname(entry)
|
|
721
|
+
let foreign: ForeignShowcase | null = null
|
|
722
|
+
for (let i = 0; i < 12; i++) {
|
|
723
|
+
if (await Bun.file(join(dir, 'package.json')).exists()) {
|
|
724
|
+
foreign = await loadForeignShowcase(dir)
|
|
725
|
+
break
|
|
726
|
+
}
|
|
727
|
+
const parent = dirname(dir)
|
|
728
|
+
if (parent === dir) break
|
|
729
|
+
dir = parent
|
|
730
|
+
}
|
|
731
|
+
if (!foreign) return res
|
|
732
|
+
for (const name of imp.names) {
|
|
733
|
+
const target = await followReexport(entry, name)
|
|
734
|
+
if (target && foreign.levelMap.has(target)) {
|
|
735
|
+
res.levels.push(foreign.levelMap.get(target) ?? null)
|
|
736
|
+
} else {
|
|
737
|
+
res.unresolvedShowcase = true
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return res
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
interface CompositionContext {
|
|
744
|
+
s: SharedInputs
|
|
745
|
+
levelMap: LevelMap
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Per-component resolved dependency levels (shared across composition rules). */
|
|
749
|
+
async function resolveComponentDeps(ctx: CompositionContext): Promise<
|
|
750
|
+
{
|
|
751
|
+
file: string
|
|
752
|
+
module: CaseModule
|
|
753
|
+
depLevels: (HierarchyLevel | null)[]
|
|
754
|
+
unresolvedShowcase: boolean
|
|
755
|
+
}[]
|
|
756
|
+
> {
|
|
757
|
+
const out = []
|
|
758
|
+
for (const { file, module } of ctx.s.modules) {
|
|
759
|
+
if (module.isFlow || !module.level) continue
|
|
760
|
+
const compFile = file.replace(/\.case\.tsx$/, '.tsx')
|
|
761
|
+
if (!(await Bun.file(compFile).exists())) continue
|
|
762
|
+
const code = await Bun.file(compFile).text()
|
|
763
|
+
const imports = parseImports(code)
|
|
764
|
+
const depLevels: (HierarchyLevel | null)[] = []
|
|
765
|
+
let unresolvedShowcase = false
|
|
766
|
+
for (const imp of imports) {
|
|
767
|
+
const dep = await resolveDependency(
|
|
768
|
+
imp,
|
|
769
|
+
compFile,
|
|
770
|
+
ctx.s.pkgDir,
|
|
771
|
+
ctx.levelMap,
|
|
772
|
+
)
|
|
773
|
+
depLevels.push(...dep.levels)
|
|
774
|
+
if (dep.unresolvedShowcase) unresolvedShowcase = true
|
|
775
|
+
}
|
|
776
|
+
out.push({ file: compFile, module, depLevels, unresolvedShowcase })
|
|
777
|
+
}
|
|
778
|
+
return out
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function markerExempt(code: string, id: StructureRuleId): boolean {
|
|
782
|
+
return hasMarker(code, [`allow-${id}`])
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function ruleAtomPurity(ctx: CompositionContext): Promise<RuleFinding[]> {
|
|
786
|
+
const out: RuleFinding[] = []
|
|
787
|
+
const deps = await resolveComponentDeps(ctx)
|
|
788
|
+
for (const d of deps) {
|
|
789
|
+
if (d.module.level !== 'atom') continue
|
|
790
|
+
const code = await Bun.file(d.file).text()
|
|
791
|
+
if (markerExempt(code, 'atom-purity')) continue
|
|
792
|
+
if (d.depLevels.length > 0) {
|
|
793
|
+
out.push({
|
|
794
|
+
rule: 'atom-purity',
|
|
795
|
+
file: d.file,
|
|
796
|
+
message: `atom "${d.module.component}" imports ${d.depLevels.length} other showcased component(s); atoms must be leaves`,
|
|
797
|
+
})
|
|
798
|
+
}
|
|
799
|
+
if (d.unresolvedShowcase) {
|
|
800
|
+
out.push({
|
|
801
|
+
rule: 'atom-purity',
|
|
802
|
+
severity: 'warn',
|
|
803
|
+
file: d.file,
|
|
804
|
+
message: `atom "${d.module.component}" imports a workspace showcase component that could not be resolved (skipped)`,
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return out
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function ruleNoDownwardDependency(
|
|
812
|
+
ctx: CompositionContext,
|
|
813
|
+
): Promise<RuleFinding[]> {
|
|
814
|
+
const out: RuleFinding[] = []
|
|
815
|
+
const deps = await resolveComponentDeps(ctx)
|
|
816
|
+
for (const d of deps) {
|
|
817
|
+
const own = levelIndex(d.module.level ?? null)
|
|
818
|
+
const code = await Bun.file(d.file).text()
|
|
819
|
+
if (markerExempt(code, 'no-downward-dependency')) continue
|
|
820
|
+
for (const dl of d.depLevels) {
|
|
821
|
+
if (levelIndex(dl) > own) {
|
|
822
|
+
out.push({
|
|
823
|
+
rule: 'no-downward-dependency',
|
|
824
|
+
file: d.file,
|
|
825
|
+
message: `${d.module.level} "${d.module.component}" imports a higher-level (${dl}) component; composition must flow upward`,
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (d.unresolvedShowcase) {
|
|
830
|
+
out.push({
|
|
831
|
+
rule: 'no-downward-dependency',
|
|
832
|
+
severity: 'warn',
|
|
833
|
+
file: d.file,
|
|
834
|
+
message: `"${d.module.component}" imports a workspace showcase component that could not be resolved (skipped)`,
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return out
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function ruleComposesLowerLevel(
|
|
842
|
+
ctx: CompositionContext,
|
|
843
|
+
): Promise<RuleFinding[]> {
|
|
844
|
+
const out: RuleFinding[] = []
|
|
845
|
+
const deps = await resolveComponentDeps(ctx)
|
|
846
|
+
for (const d of deps) {
|
|
847
|
+
if (d.module.level === 'atom') continue
|
|
848
|
+
const own = levelIndex(d.module.level ?? null)
|
|
849
|
+
const code = await Bun.file(d.file).text()
|
|
850
|
+
if (markerExempt(code, 'composes-lower-level')) continue
|
|
851
|
+
const hasLower = d.depLevels.some((dl) => levelIndex(dl) < own)
|
|
852
|
+
if (hasLower) continue
|
|
853
|
+
if (d.unresolvedShowcase) {
|
|
854
|
+
// Can't confirm composition through an unfollowable workspace import — warn
|
|
855
|
+
// rather than assert the component composes nothing lower.
|
|
856
|
+
out.push({
|
|
857
|
+
rule: 'composes-lower-level',
|
|
858
|
+
severity: 'warn',
|
|
859
|
+
file: d.file,
|
|
860
|
+
message: `${d.module.level} "${d.module.component}" imports a workspace showcase component that could not be resolved; cannot confirm lower-level composition`,
|
|
861
|
+
})
|
|
862
|
+
} else {
|
|
863
|
+
out.push({
|
|
864
|
+
rule: 'composes-lower-level',
|
|
865
|
+
file: d.file,
|
|
866
|
+
message: `${d.module.level} "${d.module.component}" composes no lower-level showcased component`,
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return out
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function ruleLevelFit(
|
|
874
|
+
ctx: CompositionContext,
|
|
875
|
+
options: StructureRuleOptions,
|
|
876
|
+
): Promise<Omit<StructureFinding, 'severity'>[]> {
|
|
877
|
+
const out: Omit<StructureFinding, 'severity'>[] = []
|
|
878
|
+
const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds ?? {}) }
|
|
879
|
+
const deps = await resolveComponentDeps(ctx)
|
|
880
|
+
for (const d of deps) {
|
|
881
|
+
const level = d.module.level
|
|
882
|
+
if (!level) continue
|
|
883
|
+
const threshold = thresholds[level]
|
|
884
|
+
if (threshold === undefined) continue
|
|
885
|
+
const code = await Bun.file(d.file).text()
|
|
886
|
+
if (markerExempt(code, 'level-fit')) continue
|
|
887
|
+
const own = levelIndex(level)
|
|
888
|
+
const lower = d.depLevels.filter((dl) => levelIndex(dl) < own).length
|
|
889
|
+
if (lower > threshold) {
|
|
890
|
+
out.push({
|
|
891
|
+
rule: 'level-fit',
|
|
892
|
+
file: d.file,
|
|
893
|
+
message: `${level} "${d.module.component}" composes ${lower} lower-level components (> ${threshold}); consider promoting it`,
|
|
894
|
+
})
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return out
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ── Orchestrator ─────────────────────────────────────────────────────────────
|
|
901
|
+
|
|
902
|
+
function matchesIgnore(
|
|
903
|
+
file: string,
|
|
904
|
+
pkgDir: string,
|
|
905
|
+
globs: string[] | undefined,
|
|
906
|
+
): boolean {
|
|
907
|
+
if (!globs?.length) return false
|
|
908
|
+
const rel = relative(pkgDir, file)
|
|
909
|
+
return globs.some((g) => new Glob(g).match(rel))
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export interface StructureOptions {
|
|
913
|
+
/** Treat all warnings as errors (CLI `--strict`). Merged with config.check.structure.strict. */
|
|
914
|
+
strict?: boolean
|
|
915
|
+
/**
|
|
916
|
+
* Directory the checks' own tooling resolves from, for `setup-present`'s
|
|
917
|
+
* second probe. Defaults to the display-case package (where the visual backend
|
|
918
|
+
* actually resolves the toolchain). Overridable for tests.
|
|
919
|
+
*/
|
|
920
|
+
toolingDir?: string
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
export async function checkStructure(
|
|
924
|
+
pkgDir: string,
|
|
925
|
+
opts: StructureOptions = {},
|
|
926
|
+
): Promise<StructureCheckResult> {
|
|
927
|
+
const { config, configPath } = await resolveConfig(pkgDir)
|
|
928
|
+
const caseFiles = await discoverCaseFiles(pkgDir, config)
|
|
929
|
+
const { modules, errors } = await loadModules(caseFiles)
|
|
930
|
+
const shared: SharedInputs = {
|
|
931
|
+
pkgDir,
|
|
932
|
+
config,
|
|
933
|
+
configPath,
|
|
934
|
+
caseFiles,
|
|
935
|
+
modules: modules.map((m) => ({ file: m.file, module: m.module })),
|
|
936
|
+
loadErrors: errors,
|
|
937
|
+
// import.meta.dir is the display-case package's src — the same resolution
|
|
938
|
+
// scope the visual backend (providers/*) loads the toolchain from.
|
|
939
|
+
toolingDir: opts.toolingDir ?? resolve(import.meta.dir, '..'),
|
|
940
|
+
}
|
|
941
|
+
const levelMap = buildLevelMap(shared.modules)
|
|
942
|
+
const ctx: CompositionContext = { s: shared, levelMap }
|
|
943
|
+
|
|
944
|
+
// Each entry: rule id → produce its (severity-less) findings.
|
|
945
|
+
const runners: Record<
|
|
946
|
+
StructureRuleId,
|
|
947
|
+
(o: StructureRuleOptions) => RuleFinding[] | Promise<RuleFinding[]>
|
|
948
|
+
> = {
|
|
949
|
+
'case-placard-coverage': () => ruleCasePlacardCoverage(shared),
|
|
950
|
+
'no-orphaned-placard-doc': () => ruleNoOrphanedPlacardDoc(shared),
|
|
951
|
+
'primer-present-and-used': () => rulePrimerPresentAndUsed(shared),
|
|
952
|
+
'setup-present': () => ruleSetupPresent(shared),
|
|
953
|
+
'config-paths-exist': () => ruleConfigPathsExist(shared),
|
|
954
|
+
'levels-classified': () => ruleLevelsClassified(shared),
|
|
955
|
+
'cases-load': () => ruleCasesLoad(shared),
|
|
956
|
+
'flow-transitions-resolve': () => ruleFlowTransitionsResolve(shared),
|
|
957
|
+
'flow-multi-step': () => ruleFlowMultiStep(shared),
|
|
958
|
+
'unique-slugs': () => ruleUniqueSlugs(shared),
|
|
959
|
+
'tweak-defaults-valid': () => ruleTweakDefaultsValid(shared),
|
|
960
|
+
'interactive-cases-keyed': () => ruleInteractiveCasesKeyed(shared),
|
|
961
|
+
'atom-purity': () => ruleAtomPurity(ctx),
|
|
962
|
+
'no-downward-dependency': () => ruleNoDownwardDependency(ctx),
|
|
963
|
+
'composes-lower-level': () => ruleComposesLowerLevel(ctx),
|
|
964
|
+
'level-fit': (o) => ruleLevelFit(ctx, o),
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const ids = Object.keys(runners) as StructureRuleId[]
|
|
968
|
+
const perRule = await Promise.all(
|
|
969
|
+
ids.map(async (id) => {
|
|
970
|
+
const rule = resolveRule(id, config)
|
|
971
|
+
if (!rule.enabled) return [] as StructureFinding[]
|
|
972
|
+
const raw = await runners[id](rule.options)
|
|
973
|
+
return raw
|
|
974
|
+
.filter((f) => !matchesIgnore(f.file, pkgDir, rule.options.ignore))
|
|
975
|
+
.map((f) => ({ ...f, severity: f.severity ?? rule.severity }))
|
|
976
|
+
}),
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
const strict = opts.strict || config.check?.structure?.strict
|
|
980
|
+
const findings = perRule
|
|
981
|
+
.flat()
|
|
982
|
+
.map((f) => (strict ? { ...f, severity: 'error' as const } : f))
|
|
983
|
+
.sort(
|
|
984
|
+
(a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
return { findings }
|
|
988
|
+
}
|