create-backbone-template 0.1.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 (182) hide show
  1. package/README.md +33 -0
  2. package/bin/create-backbone-template.js +5 -0
  3. package/package.json +30 -0
  4. package/src/create-backbone-template.js +204 -0
  5. package/template/.agents/skills/agent-browser/SKILL.md +55 -0
  6. package/template/.agents/skills/create-plan/SKILL.md +52 -0
  7. package/template/.agents/skills/create-plan/agents/openai.yaml +4 -0
  8. package/template/.agents/skills/create-pr-presentation/SKILL.md +86 -0
  9. package/template/.agents/skills/create-pr-presentation/agents/openai.yaml +4 -0
  10. package/template/.agents/skills/implement-plan/SKILL.md +26 -0
  11. package/template/.agents/skills/implement-plan/agents/openai.yaml +4 -0
  12. package/template/.agents/skills/review-plan/SKILL.md +38 -0
  13. package/template/.agents/skills/review-plan/agents/openai.yaml +4 -0
  14. package/template/.env.schema +30 -0
  15. package/template/.env.test +6 -0
  16. package/template/.oxlintrc.json +67 -0
  17. package/template/.vscode/extensions.json +3 -0
  18. package/template/.vscode/settings.json +23 -0
  19. package/template/AGENTS.md +55 -0
  20. package/template/Cargo.lock +2648 -0
  21. package/template/Cargo.toml +29 -0
  22. package/template/Justfile +140 -0
  23. package/template/README.md +72 -0
  24. package/template/TODO.md +1 -0
  25. package/template/_gitignore +12 -0
  26. package/template/buf.gen.yaml +7 -0
  27. package/template/buf.yaml +10 -0
  28. package/template/client/.oxfmtrc.json +8 -0
  29. package/template/client/.oxlintrc.json +57 -0
  30. package/template/client/README.md +19 -0
  31. package/template/client/_gitignore +5 -0
  32. package/template/client/index.html +12 -0
  33. package/template/client/package.json +47 -0
  34. package/template/client/packages/design-system/package.json +19 -0
  35. package/template/client/packages/design-system/src/index.ts +2 -0
  36. package/template/client/packages/design-system-basic/package.json +18 -0
  37. package/template/client/packages/design-system-basic/src/button.stories.tsx +50 -0
  38. package/template/client/packages/design-system-basic/src/button.tsx +26 -0
  39. package/template/client/packages/design-system-basic/src/empty-state.stories.tsx +18 -0
  40. package/template/client/packages/design-system-basic/src/empty-state.tsx +17 -0
  41. package/template/client/packages/design-system-basic/src/form-field.stories.tsx +15 -0
  42. package/template/client/packages/design-system-basic/src/form-field.tsx +10 -0
  43. package/template/client/packages/design-system-basic/src/form.stories.tsx +27 -0
  44. package/template/client/packages/design-system-basic/src/form.tsx +9 -0
  45. package/template/client/packages/design-system-basic/src/heading.stories.tsx +14 -0
  46. package/template/client/packages/design-system-basic/src/heading.tsx +25 -0
  47. package/template/client/packages/design-system-basic/src/index.tsx +15 -0
  48. package/template/client/packages/design-system-basic/src/inline.stories.tsx +13 -0
  49. package/template/client/packages/design-system-basic/src/inline.tsx +5 -0
  50. package/template/client/packages/design-system-basic/src/layout.stories.tsx +24 -0
  51. package/template/client/packages/design-system-basic/src/layout.tsx +14 -0
  52. package/template/client/packages/design-system-basic/src/loader.stories.tsx +8 -0
  53. package/template/client/packages/design-system-basic/src/loader.tsx +11 -0
  54. package/template/client/packages/design-system-basic/src/navigation.stories.tsx +16 -0
  55. package/template/client/packages/design-system-basic/src/navigation.tsx +18 -0
  56. package/template/client/packages/design-system-basic/src/notice.stories.tsx +13 -0
  57. package/template/client/packages/design-system-basic/src/notice.tsx +5 -0
  58. package/template/client/packages/design-system-basic/src/stack.stories.tsx +17 -0
  59. package/template/client/packages/design-system-basic/src/stack.tsx +5 -0
  60. package/template/client/packages/design-system-basic/src/styles.css +254 -0
  61. package/template/client/packages/design-system-basic/src/text-input.stories.tsx +13 -0
  62. package/template/client/packages/design-system-basic/src/text-input.tsx +5 -0
  63. package/template/client/packages/design-system-basic/src/text.stories.tsx +21 -0
  64. package/template/client/packages/design-system-basic/src/text.tsx +5 -0
  65. package/template/client/packages/design-system-contract/package.json +15 -0
  66. package/template/client/packages/design-system-contract/src/button.ts +10 -0
  67. package/template/client/packages/design-system-contract/src/empty-state.ts +9 -0
  68. package/template/client/packages/design-system-contract/src/form-field.ts +9 -0
  69. package/template/client/packages/design-system-contract/src/form.ts +9 -0
  70. package/template/client/packages/design-system-contract/src/heading.ts +9 -0
  71. package/template/client/packages/design-system-contract/src/index.ts +13 -0
  72. package/template/client/packages/design-system-contract/src/inline.ts +7 -0
  73. package/template/client/packages/design-system-contract/src/layout.ts +8 -0
  74. package/template/client/packages/design-system-contract/src/loader.ts +7 -0
  75. package/template/client/packages/design-system-contract/src/navigation.ts +13 -0
  76. package/template/client/packages/design-system-contract/src/notice.ts +8 -0
  77. package/template/client/packages/design-system-contract/src/stack.ts +8 -0
  78. package/template/client/packages/design-system-contract/src/text-input.ts +5 -0
  79. package/template/client/packages/design-system-contract/src/text.ts +9 -0
  80. package/template/client/packages/design-system-lint/fixtures/invalid/external-ui-import.tsx +5 -0
  81. package/template/client/packages/design-system-lint/fixtures/invalid/raw-dom-jsx.tsx +3 -0
  82. package/template/client/packages/design-system-lint/fixtures/invalid/two-violations.tsx +7 -0
  83. package/template/client/packages/design-system-lint/fixtures/valid/design-system-only.tsx +13 -0
  84. package/template/client/packages/design-system-lint/package.json +23 -0
  85. package/template/client/packages/design-system-lint/src/check-design-system-architecture.ts +22 -0
  86. package/template/client/packages/design-system-lint/src/design-system-architecture.ts +286 -0
  87. package/template/client/packages/design-system-lint/src/oxlint-plugin.ts +11 -0
  88. package/template/client/packages/design-system-lint/src/page-architecture.ts +382 -0
  89. package/template/client/packages/design-system-lint/src/rules.ts +111 -0
  90. package/template/client/packages/design-system-lint/test/design-system-architecture.test.ts +243 -0
  91. package/template/client/packages/design-system-lint/test/oxlint-fixtures.test.ts +159 -0
  92. package/template/client/packages/design-system-lint/test/page-architecture.test.ts +175 -0
  93. package/template/client/packages/design-system-lint/test/rules.test.ts +65 -0
  94. package/template/client/packages/design-system-lint/tsconfig.json +29 -0
  95. package/template/client/src/App.tsx +77 -0
  96. package/template/client/src/design-system-components.test.tsx +75 -0
  97. package/template/client/src/gen/helloworld/v1/helloworld_pb.ts +63 -0
  98. package/template/client/src/main.tsx +18 -0
  99. package/template/client/src/pages/hello/hello-page.stories.tsx +20 -0
  100. package/template/client/src/pages/hello/hello-page.test.tsx +90 -0
  101. package/template/client/src/pages/hello/hello-page.tsx +126 -0
  102. package/template/client/src/pages/page.ts +20 -0
  103. package/template/client/src/testing/create-preview-events.test.ts +36 -0
  104. package/template/client/src/testing/create-preview-events.ts +30 -0
  105. package/template/client/src/vite-env.d.ts +1 -0
  106. package/template/client/tsconfig.json +32 -0
  107. package/template/client/vite.config.ts +21 -0
  108. package/template/client/vite.ladle.config.ts +5 -0
  109. package/template/e2e/.gherkin-lintrc +20 -0
  110. package/template/e2e/.oxfmtrc.json +15 -0
  111. package/template/e2e/.oxlintrc.json +37 -0
  112. package/template/e2e/_gitignore +4 -0
  113. package/template/e2e/features/helloworld.feature +10 -0
  114. package/template/e2e/package.json +42 -0
  115. package/template/e2e/playwright.config.ts +16 -0
  116. package/template/e2e/support/app-gherkin.ts +4 -0
  117. package/template/e2e/support/fixtures.ts +236 -0
  118. package/template/e2e/support/gherkin-fixtures/duplicate-id.feature +9 -0
  119. package/template/e2e/support/gherkin-fixtures/duplicate-id.spec.ts +7 -0
  120. package/template/e2e/support/gherkin-fixtures/extra-implementation.spec.ts +7 -0
  121. package/template/e2e/support/gherkin-fixtures/extra-step.spec.ts +10 -0
  122. package/template/e2e/support/gherkin-fixtures/happy-path.spec.ts +4 -0
  123. package/template/e2e/support/gherkin-fixtures/missing-id.feature +4 -0
  124. package/template/e2e/support/gherkin-fixtures/missing-id.spec.ts +7 -0
  125. package/template/e2e/support/gherkin-fixtures/missing-implementation.spec.ts +7 -0
  126. package/template/e2e/support/gherkin-fixtures/missing-step.spec.ts +7 -0
  127. package/template/e2e/support/gherkin-fixtures/playwright.config.ts +7 -0
  128. package/template/e2e/support/gherkin-fixtures/scenario-outline.feature +9 -0
  129. package/template/e2e/support/gherkin-fixtures/scenario-outline.spec.ts +7 -0
  130. package/template/e2e/support/gherkin-fixtures/step-mismatch.spec.ts +9 -0
  131. package/template/e2e/support/gherkin-fixtures/valid-implementations.ts +23 -0
  132. package/template/e2e/support/gherkin-fixtures/valid-scenarios.feature +26 -0
  133. package/template/e2e/support/gherkin.test.ts +184 -0
  134. package/template/e2e/support/gherkin.ts +321 -0
  135. package/template/e2e/support/oxlint-plugin.test.ts +328 -0
  136. package/template/e2e/support/oxlint-plugin.ts +485 -0
  137. package/template/e2e/tests/helloworld.spec.ts +39 -0
  138. package/template/e2e/tsconfig.json +26 -0
  139. package/template/e2e/tsconfig.oxlint-plugin.json +12 -0
  140. package/template/package.json +9 -0
  141. package/template/pnpm-lock.yaml +10723 -0
  142. package/template/pnpm-workspace.yaml +8 -0
  143. package/template/pr-slide/README.md +95 -0
  144. package/template/pr-slide/package.json +23 -0
  145. package/template/pr-slide/src/cli.js +262 -0
  146. package/template/pr-slide/src/generate-pr-deck.js +833 -0
  147. package/template/pr-slide/src/git-context.js +91 -0
  148. package/template/pr-slide/src/presentation-paths.js +9 -0
  149. package/template/pr-slide/src/presentations.js +53 -0
  150. package/template/pr-slide/test/generate-pr-deck.test.js +118 -0
  151. package/template/pr-slide/test/presentation-paths.test.js +14 -0
  152. package/template/pr-slide/test/presentations.test.js +50 -0
  153. package/template/proto/helloworld/v1/helloworld.proto +15 -0
  154. package/template/scripts/run-e2e.sh +10 -0
  155. package/template/server/Cargo.toml +26 -0
  156. package/template/server/build.rs +9 -0
  157. package/template/server/dylint/backbone_server_lints/.cargo/config.toml +6 -0
  158. package/template/server/dylint/backbone_server_lints/Cargo.lock +1581 -0
  159. package/template/server/dylint/backbone_server_lints/Cargo.toml +21 -0
  160. package/template/server/dylint/backbone_server_lints/README.md +5 -0
  161. package/template/server/dylint/backbone_server_lints/_gitignore +1 -0
  162. package/template/server/dylint/backbone_server_lints/rust-toolchain +3 -0
  163. package/template/server/dylint/backbone_server_lints/src/lib.rs +612 -0
  164. package/template/server/dylint/backbone_server_lints/ui/lib.rs +4 -0
  165. package/template/server/dylint/backbone_server_lints/ui/lib.stderr +10 -0
  166. package/template/server/dylint/backbone_server_lints/ui/long_file.rs +303 -0
  167. package/template/server/dylint/backbone_server_lints/ui/long_file.stderr +6 -0
  168. package/template/server/dylint/backbone_server_lints/ui/main.rs +59 -0
  169. package/template/server/dylint/backbone_server_lints/ui/main.stderr +85 -0
  170. package/template/server/migrations/20260520120000_create_projects.sql +12 -0
  171. package/template/server/migrations/20260524160000_create_hello_world_inputs.sql +12 -0
  172. package/template/server/src/config.rs +27 -0
  173. package/template/server/src/db/hello_world.rs +34 -0
  174. package/template/server/src/db/hello_world_tests.rs +11 -0
  175. package/template/server/src/db/mod.rs +39 -0
  176. package/template/server/src/lib.rs +10 -0
  177. package/template/server/src/main.rs +43 -0
  178. package/template/server/src/rpc/greeter/mod.rs +31 -0
  179. package/template/server/src/rpc/greeter/say_hello.rs +27 -0
  180. package/template/server/src/rpc/mod.rs +8 -0
  181. package/template/server/src/state.rs +13 -0
  182. package/template/skills-lock.json +11 -0
@@ -0,0 +1,243 @@
1
+ import assert from "node:assert/strict"
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import test from "node:test"
6
+ import { checkDesignSystemArchitecture } from "../src/design-system-architecture.js"
7
+
8
+ test("accepts the app design-system architecture", () => {
9
+ const result = checkDesignSystemArchitecture({
10
+ contractSrcDir: path.resolve("../design-system-contract/src"),
11
+ implementationSrcDir: path.resolve("../design-system-basic/src"),
12
+ })
13
+
14
+ assert.deepEqual(result.errors, [])
15
+ })
16
+
17
+ test("rejects missing matching contract and implementation component files", () => {
18
+ const fixture = createFixture({
19
+ contract: {
20
+ "button.ts": "export type ButtonProps = unknown\nexport type ButtonComponent = unknown\n",
21
+ "card.ts": "export type CardProps = unknown\nexport type CardComponent = unknown\n",
22
+ "index.ts": "export type * from './button'\nexport type * from './card'\n",
23
+ },
24
+ implementation: {
25
+ "button.stories.tsx": "export const Default = () => null\n",
26
+ "button.tsx": "export const Button = () => null\n",
27
+ "index.tsx": "export * from './button'\n",
28
+ },
29
+ })
30
+
31
+ try {
32
+ const result = checkDesignSystemArchitecture(fixture)
33
+
34
+ assert.deepEqual(result.errors, [
35
+ 'Component "card" exists in contract but is missing from implementation.',
36
+ ])
37
+ } finally {
38
+ removeFixture(fixture)
39
+ }
40
+ })
41
+
42
+ test("rejects implementation component files without colocated stories", () => {
43
+ const fixture = createFixture({
44
+ contract: {
45
+ "button.ts": "export type ButtonProps = unknown\nexport type ButtonComponent = unknown\n",
46
+ "index.ts": "export type * from './button'\n",
47
+ },
48
+ implementation: {
49
+ "button.tsx": "export const Button = () => null\n",
50
+ "index.tsx": "export * from './button'\n",
51
+ },
52
+ })
53
+
54
+ try {
55
+ const result = checkDesignSystemArchitecture(fixture)
56
+
57
+ assert.deepEqual(result.errors, [
58
+ 'Implementation component "button" must have a colocated story file at "button.stories.tsx".',
59
+ ])
60
+ } finally {
61
+ removeFixture(fixture)
62
+ }
63
+ })
64
+
65
+ test("rejects contract files without matching props and component exports", () => {
66
+ const fixture = createFixture({
67
+ contract: {
68
+ "button.ts": "export type ButtonComponent = unknown\nexport type ButtonConfig = unknown\n",
69
+ "index.ts": "export type * from './button'\n",
70
+ },
71
+ implementation: {
72
+ "button.stories.tsx": "export const Default = () => null\n",
73
+ "button.tsx": "export const Button = () => null\n",
74
+ "index.tsx": "export * from './button'\n",
75
+ },
76
+ })
77
+
78
+ try {
79
+ const result = checkDesignSystemArchitecture(fixture)
80
+
81
+ assert.deepEqual(result.errors, [
82
+ 'Contract component file "button.ts" must export exactly "ButtonComponent" and "ButtonProps".',
83
+ ])
84
+ } finally {
85
+ removeFixture(fixture)
86
+ }
87
+ })
88
+
89
+ test("rejects multiple named exports from an implementation component file", () => {
90
+ const fixture = createFixture({
91
+ contract: {
92
+ "button.ts": "export type ButtonProps = unknown\nexport type ButtonComponent = unknown\n",
93
+ "index.ts": "export type * from './button'\n",
94
+ },
95
+ implementation: {
96
+ "button.stories.tsx": "export const Default = () => null\n",
97
+ "button.tsx": "export const Button = () => null\nexport const ButtonIcon = () => null\n",
98
+ "index.tsx": "export * from './button'\n",
99
+ },
100
+ })
101
+
102
+ try {
103
+ const result = checkDesignSystemArchitecture(fixture)
104
+
105
+ assert.deepEqual(result.errors, [
106
+ 'Implementation component file "button.tsx" must export exactly "Button".',
107
+ ])
108
+ } finally {
109
+ removeFixture(fixture)
110
+ }
111
+ })
112
+
113
+ test("rejects implementation exports that do not match the component file name", () => {
114
+ const fixture = createFixture({
115
+ contract: {
116
+ "text-input.ts":
117
+ "export type TextInputProps = unknown\nexport type TextInputComponent = unknown\n",
118
+ "index.ts": "export type * from './text-input'\n",
119
+ },
120
+ implementation: {
121
+ "index.tsx": "export * from './text-input'\n",
122
+ "text-input.stories.tsx": "export const Default = () => null\n",
123
+ "text-input.tsx": "export const Input = () => null\n",
124
+ },
125
+ })
126
+
127
+ try {
128
+ const result = checkDesignSystemArchitecture(fixture)
129
+
130
+ assert.deepEqual(result.errors, [
131
+ 'Implementation component file "text-input.tsx" must export exactly "TextInput".',
132
+ ])
133
+ } finally {
134
+ removeFixture(fixture)
135
+ }
136
+ })
137
+
138
+ test("rejects mismatched subfolder structure", () => {
139
+ const fixture = createFixture({
140
+ contract: {
141
+ "forms/button.ts":
142
+ "export type ButtonProps = unknown\nexport type ButtonComponent = unknown\n",
143
+ "index.ts": "export type * from './forms/button'\n",
144
+ },
145
+ implementation: {
146
+ "button.stories.tsx": "export const Default = () => null\n",
147
+ "button.tsx": "export const Button = () => null\n",
148
+ "index.tsx": "export * from './button'\n",
149
+ },
150
+ })
151
+
152
+ try {
153
+ const result = checkDesignSystemArchitecture(fixture)
154
+
155
+ assert.deepEqual(result.errors, [
156
+ 'Component "button" exists in implementation but is missing from contract.',
157
+ 'Component "forms/button" exists in contract but is missing from implementation.',
158
+ ])
159
+ } finally {
160
+ removeFixture(fixture)
161
+ }
162
+ })
163
+
164
+ test("rejects missing barrel exports", () => {
165
+ const fixture = createFixture({
166
+ contract: {
167
+ "button.ts": "export type ButtonProps = unknown\nexport type ButtonComponent = unknown\n",
168
+ "text-input.ts":
169
+ "export type TextInputProps = unknown\nexport type TextInputComponent = unknown\n",
170
+ "index.ts": "export type * from './button'\n",
171
+ },
172
+ implementation: {
173
+ "button.stories.tsx": "export const Default = () => null\n",
174
+ "button.tsx": "export const Button = () => null\n",
175
+ "index.tsx": "export * from './button'\n",
176
+ "text-input.stories.tsx": "export const Default = () => null\n",
177
+ "text-input.tsx": "export const TextInput = () => null\n",
178
+ },
179
+ })
180
+
181
+ try {
182
+ const result = checkDesignSystemArchitecture(fixture)
183
+
184
+ assert.deepEqual(result.errors, [
185
+ 'Contract barrel "index.ts" must export "./text-input".',
186
+ 'Implementation barrel "index.tsx" must export "./text-input".',
187
+ ])
188
+ } finally {
189
+ removeFixture(fixture)
190
+ }
191
+ })
192
+
193
+ test("rejects extra barrel exports", () => {
194
+ const fixture = createFixture({
195
+ contract: {
196
+ "button.ts": "export type ButtonProps = unknown\nexport type ButtonComponent = unknown\n",
197
+ "index.ts": "export type * from './button'\nexport type * from './card'\n",
198
+ },
199
+ implementation: {
200
+ "button.stories.tsx": "export const Default = () => null\n",
201
+ "button.tsx": "export const Button = () => null\n",
202
+ "index.tsx": "export * from './button'\nexport * from './button.stories'\n",
203
+ },
204
+ })
205
+
206
+ try {
207
+ const result = checkDesignSystemArchitecture(fixture)
208
+
209
+ assert.deepEqual(result.errors, [
210
+ 'Contract barrel "index.ts" must not export "./card".',
211
+ 'Implementation barrel "index.tsx" must not export "./button.stories".',
212
+ ])
213
+ } finally {
214
+ removeFixture(fixture)
215
+ }
216
+ })
217
+
218
+ function createFixture(files: {
219
+ contract: Record<string, string>
220
+ implementation: Record<string, string>
221
+ }) {
222
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "backbone-design-system-architecture-"))
223
+ const contractSrcDir = path.join(rootDir, "contract")
224
+ const implementationSrcDir = path.join(rootDir, "implementation")
225
+
226
+ writeFiles(contractSrcDir, files.contract)
227
+ writeFiles(implementationSrcDir, files.implementation)
228
+
229
+ return { contractSrcDir, implementationSrcDir, rootDir }
230
+ }
231
+
232
+ function writeFiles(rootDir: string, files: Record<string, string>) {
233
+ for (const [filePath, content] of Object.entries(files)) {
234
+ const absolutePath = path.join(rootDir, filePath)
235
+
236
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
237
+ fs.writeFileSync(absolutePath, content)
238
+ }
239
+ }
240
+
241
+ function removeFixture(fixture: { rootDir: string }) {
242
+ fs.rmSync(fixture.rootDir, { force: true, recursive: true })
243
+ }
@@ -0,0 +1,159 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawnSync } from "node:child_process"
3
+ import fs from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import test from "node:test"
7
+
8
+ type OxlintDiagnostic = {
9
+ code?: string
10
+ filename?: string
11
+ labels?: Array<{
12
+ message?: string
13
+ span?: {
14
+ column?: number
15
+ line?: number
16
+ }
17
+ }>
18
+ message?: string
19
+ }
20
+
21
+ type OxlintJsonOutput = {
22
+ diagnostics?: OxlintDiagnostic[]
23
+ }
24
+
25
+ const packageRoot = process.cwd()
26
+
27
+ test("fixture files without boundary violations pass real Oxlint", () => {
28
+ const result = runOxlint("fixtures/valid")
29
+
30
+ assert.equal(result.status, 0)
31
+ assert.deepEqual(result.diagnostics, [])
32
+ })
33
+
34
+ test("fixture files with boundary violations report stable diagnostics", () => {
35
+ const result = runOxlint("fixtures/invalid")
36
+
37
+ assert.equal(result.status, 1)
38
+ assert.deepEqual(
39
+ result.diagnostics.map((diagnostic) => ({
40
+ code: diagnostic.code,
41
+ column: diagnostic.column,
42
+ file: path.basename(diagnostic.filename ?? ""),
43
+ line: diagnostic.line,
44
+ message: diagnostic.message,
45
+ })),
46
+ [
47
+ {
48
+ code: "backbone-design-system/no-external-ui-imports",
49
+ column: 24,
50
+ file: "external-ui-import.tsx",
51
+ line: 1,
52
+ message: 'Import "@mui/material" bypasses the design system boundary.',
53
+ },
54
+ {
55
+ code: "backbone-design-system/no-raw-dom-jsx",
56
+ column: 11,
57
+ file: "raw-dom-jsx.tsx",
58
+ line: 2,
59
+ message:
60
+ "Raw JSX element <button> is not allowed in app code; use @backbone/design-system components.",
61
+ },
62
+ {
63
+ code: "backbone-design-system/no-external-ui-imports",
64
+ column: 20,
65
+ file: "two-violations.tsx",
66
+ line: 1,
67
+ message: 'Import "styled-components" bypasses the design system boundary.',
68
+ },
69
+ {
70
+ code: "backbone-design-system/no-raw-dom-jsx",
71
+ column: 11,
72
+ file: "two-violations.tsx",
73
+ line: 6,
74
+ message:
75
+ "Raw JSX element <main> is not allowed in app code; use @backbone/design-system components.",
76
+ },
77
+ ],
78
+ )
79
+ })
80
+
81
+ function runOxlint(fixturePath: string) {
82
+ const { configDir, configPath } = writeOxlintConfig()
83
+
84
+ try {
85
+ const result = spawnSync(
86
+ process.platform === "win32" ? "pnpm.cmd" : "pnpm",
87
+ ["exec", "oxlint", "--config", configPath, "--format", "json", fixturePath],
88
+ {
89
+ cwd: packageRoot,
90
+ encoding: "utf8",
91
+ },
92
+ )
93
+
94
+ if (result.error !== undefined && result.status === null) {
95
+ throw result.error
96
+ }
97
+
98
+ const diagnostics = parseDiagnostics(result.stdout)
99
+
100
+ return {
101
+ diagnostics,
102
+ status: result.status,
103
+ stderr: result.stderr,
104
+ stdout: result.stdout,
105
+ }
106
+ } finally {
107
+ fs.rmSync(configDir, { force: true, recursive: true })
108
+ }
109
+ }
110
+
111
+ function writeOxlintConfig() {
112
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "backbone-design-system-oxlint-"))
113
+ const configPath = path.join(configDir, "oxlint.json")
114
+
115
+ fs.writeFileSync(
116
+ configPath,
117
+ JSON.stringify({
118
+ jsPlugins: [path.join(packageRoot, "dist/src/oxlint-plugin.js")],
119
+ rules: {
120
+ "backbone-design-system/no-external-ui-imports": "error",
121
+ "backbone-design-system/no-raw-dom-jsx": "error",
122
+ },
123
+ }),
124
+ )
125
+
126
+ return { configDir, configPath }
127
+ }
128
+
129
+ function parseDiagnostics(output: string) {
130
+ if (output.trim() === "") {
131
+ return []
132
+ }
133
+
134
+ const parsed = JSON.parse(output) as OxlintJsonOutput
135
+
136
+ return (parsed.diagnostics ?? [])
137
+ .filter((diagnostic) => diagnostic.code?.startsWith("backbone-design-system"))
138
+ .map((diagnostic) => ({
139
+ code: normalizeRuleCode(diagnostic.code),
140
+ column: diagnostic.labels?.[0]?.span?.column,
141
+ filename: diagnostic.filename,
142
+ line: diagnostic.labels?.[0]?.span?.line,
143
+ message: diagnostic.message ?? diagnostic.labels?.[0]?.message,
144
+ }))
145
+ .sort((left, right) => {
146
+ const leftFile = left.filename ?? ""
147
+ const rightFile = right.filename ?? ""
148
+
149
+ if (leftFile !== rightFile) {
150
+ return leftFile.localeCompare(rightFile)
151
+ }
152
+
153
+ return (left.code ?? "").localeCompare(right.code ?? "")
154
+ })
155
+ }
156
+
157
+ function normalizeRuleCode(code: string | undefined) {
158
+ return code?.replace(/^backbone-design-system\((.+)\)$/, "backbone-design-system/$1")
159
+ }
@@ -0,0 +1,175 @@
1
+ import assert from "node:assert/strict"
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import test from "node:test"
6
+ import { checkPageArchitecture } from "../src/page-architecture.js"
7
+
8
+ const validHelloPage = `
9
+ import { Button } from "@backbone/design-system"
10
+ import type { Page } from "../page"
11
+
12
+ export type HelloPageStaticProps = {
13
+ label: string
14
+ }
15
+
16
+ export type HelloPageDynamicProps = {
17
+ onSubmitted(): void
18
+ }
19
+
20
+ export const helloPageDynamicPropKeys = ["onSubmitted"] as const
21
+
22
+ export const HelloPage: Page<HelloPageStaticProps, HelloPageDynamicProps> = ({
23
+ label,
24
+ onSubmitted,
25
+ }) => <Button onClick={onSubmitted}>{label}</Button>
26
+ `
27
+
28
+ test("accepts the app page architecture", () => {
29
+ const result = checkPageArchitecture({
30
+ pagesSrcDir: path.resolve("../../src/pages"),
31
+ })
32
+
33
+ assert.deepEqual(result.errors, [])
34
+ })
35
+
36
+ test("accepts pages that follow the named page contract", () => {
37
+ const fixture = createFixture({
38
+ "page.ts":
39
+ "export type Page<StaticProps, DynamicProps> = (props: StaticProps & DynamicProps) => unknown\n",
40
+ "hello/hello-page.tsx": validHelloPage,
41
+ })
42
+
43
+ try {
44
+ const result = checkPageArchitecture({ pagesSrcDir: fixture.pagesSrcDir })
45
+
46
+ assert.deepEqual(result.errors, [])
47
+ } finally {
48
+ removeFixture(fixture)
49
+ }
50
+ })
51
+
52
+ test("rejects pages missing named exports", () => {
53
+ const fixture = createFixture({
54
+ "hello/hello-page.tsx": `
55
+ import type { Page } from "../page"
56
+ export type StaticProps = { label: string }
57
+ export type DynamicProps = { onSubmitted(): void }
58
+ export const HelloPage: Page<StaticProps, DynamicProps> = () => null
59
+ `,
60
+ })
61
+
62
+ try {
63
+ const result = checkPageArchitecture({ pagesSrcDir: fixture.pagesSrcDir })
64
+
65
+ assert.deepEqual(result.errors, [
66
+ 'Page file "hello/hello-page.tsx" must export type "HelloPageStaticProps".',
67
+ 'Page file "hello/hello-page.tsx" must export type "HelloPageDynamicProps".',
68
+ 'Page file "hello/hello-page.tsx" must export const "helloPageDynamicPropKeys".',
69
+ 'Page component "HelloPage" in "hello/hello-page.tsx" must be typed as "Page<HelloPageStaticProps, HelloPageDynamicProps>".',
70
+ ])
71
+ } finally {
72
+ removeFixture(fixture)
73
+ }
74
+ })
75
+
76
+ test("rejects dynamic props that are not on-prefixed callbacks", () => {
77
+ const fixture = createFixture({
78
+ "hello/hello-page.tsx": `
79
+ import type { Page } from "../page"
80
+ export type HelloPageStaticProps = { label: string }
81
+ export type HelloPageDynamicProps = {
82
+ submitted(): void
83
+ isSubmitting: boolean
84
+ }
85
+ export const helloPageDynamicPropKeys = ["submitted", "isSubmitting"] as const
86
+ export const HelloPage: Page<HelloPageStaticProps, HelloPageDynamicProps> = () => null
87
+ `,
88
+ })
89
+
90
+ try {
91
+ const result = checkPageArchitecture({ pagesSrcDir: fixture.pagesSrcDir })
92
+
93
+ assert.deepEqual(result.errors, [
94
+ 'Dynamic prop "submitted" in "hello/hello-page.tsx" must start with "on" followed by an uppercase letter.',
95
+ 'Dynamic prop "isSubmitting" in "hello/hello-page.tsx" must be a function callback.',
96
+ 'Dynamic prop "isSubmitting" in "hello/hello-page.tsx" must start with "on" followed by an uppercase letter.',
97
+ ])
98
+ } finally {
99
+ removeFixture(fixture)
100
+ }
101
+ })
102
+
103
+ test("rejects dynamic prop key mismatches", () => {
104
+ const fixture = createFixture({
105
+ "hello/hello-page.tsx": `
106
+ import type { Page } from "../page"
107
+ export type HelloPageStaticProps = { label: string }
108
+ export type HelloPageDynamicProps = {
109
+ onSubmitted(): void
110
+ onCancelled(): void
111
+ }
112
+ export const helloPageDynamicPropKeys = ["onSubmitted", "onUnknown"] as const
113
+ export const HelloPage: Page<HelloPageStaticProps, HelloPageDynamicProps> = () => null
114
+ `,
115
+ })
116
+
117
+ try {
118
+ const result = checkPageArchitecture({ pagesSrcDir: fixture.pagesSrcDir })
119
+
120
+ assert.deepEqual(result.errors, [
121
+ 'Dynamic prop keys in "hello/hello-page.tsx" must include "onCancelled".',
122
+ 'Dynamic prop keys in "hello/hello-page.tsx" must not include unknown key "onUnknown".',
123
+ ])
124
+ } finally {
125
+ removeFixture(fixture)
126
+ }
127
+ })
128
+
129
+ test("rejects page template imports outside the page boundary", () => {
130
+ const fixture = createFixture({
131
+ "hello/hello-page.tsx": `
132
+ import { Button } from "@backbone/design-system"
133
+ import type { Page } from "../page"
134
+ import { createClient } from "@connectrpc/connect"
135
+ import { helper } from "./helper"
136
+ export type HelloPageStaticProps = { label: string }
137
+ export type HelloPageDynamicProps = { onSubmitted(): void }
138
+ export const helloPageDynamicPropKeys = ["onSubmitted"] as const
139
+ export const HelloPage: Page<HelloPageStaticProps, HelloPageDynamicProps> = () => null
140
+ `,
141
+ })
142
+
143
+ try {
144
+ const result = checkPageArchitecture({ pagesSrcDir: fixture.pagesSrcDir })
145
+
146
+ assert.deepEqual(result.errors, [
147
+ 'Page file "hello/hello-page.tsx" must not import "@connectrpc/connect"; page templates may only import @backbone/design-system and ../page.',
148
+ 'Page file "hello/hello-page.tsx" must not import "./helper"; page templates may only import @backbone/design-system and ../page.',
149
+ ])
150
+ } finally {
151
+ removeFixture(fixture)
152
+ }
153
+ })
154
+
155
+ function createFixture(files: Record<string, string>) {
156
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "backbone-page-architecture-"))
157
+ const pagesSrcDir = path.join(rootDir, "pages")
158
+
159
+ writeFiles(pagesSrcDir, files)
160
+
161
+ return { pagesSrcDir, rootDir }
162
+ }
163
+
164
+ function writeFiles(rootDir: string, files: Record<string, string>) {
165
+ for (const [filePath, content] of Object.entries(files)) {
166
+ const absolutePath = path.join(rootDir, filePath)
167
+
168
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
169
+ fs.writeFileSync(absolutePath, content)
170
+ }
171
+ }
172
+
173
+ function removeFixture(fixture: { rootDir: string }) {
174
+ fs.rmSync(fixture.rootDir, { force: true, recursive: true })
175
+ }
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { noExternalUiImports, noRawDomJsx } from "../src/rules.js"
4
+
5
+ type RuleReport = {
6
+ message: string
7
+ node: unknown
8
+ }
9
+
10
+ function runRule<VisitorName extends string, Node>(
11
+ rule: {
12
+ create(context: { report(report: RuleReport): void }): Record<VisitorName, (node: Node) => void>
13
+ },
14
+ visitorName: VisitorName,
15
+ node: Node,
16
+ ) {
17
+ const reports: RuleReport[] = []
18
+ const context = {
19
+ report(report: RuleReport) {
20
+ reports.push(report)
21
+ },
22
+ }
23
+
24
+ rule.create(context)[visitorName](node)
25
+
26
+ return reports
27
+ }
28
+
29
+ test("no-raw-dom-jsx flags raw DOM JSX in app code", () => {
30
+ const reports = runRule(noRawDomJsx, "JSXOpeningElement", {
31
+ name: { type: "JSXIdentifier", name: "button" },
32
+ })
33
+
34
+ assert.equal(reports.length, 1)
35
+ const report = reports[0]
36
+ assert.ok(report)
37
+ assert.match(report.message, /Raw JSX element <button>/)
38
+ })
39
+
40
+ test("no-raw-dom-jsx allows design system components", () => {
41
+ const reports = runRule(noRawDomJsx, "JSXOpeningElement", {
42
+ name: { type: "JSXIdentifier", name: "Button" },
43
+ })
44
+
45
+ assert.deepEqual(reports, [])
46
+ })
47
+
48
+ test("no-external-ui-imports flags direct imports from external UI libraries", () => {
49
+ const reports = runRule(noExternalUiImports, "ImportDeclaration", {
50
+ source: { value: "@mui/material" },
51
+ })
52
+
53
+ assert.equal(reports.length, 1)
54
+ const report = reports[0]
55
+ assert.ok(report)
56
+ assert.match(report.message, /bypasses the design system boundary/)
57
+ })
58
+
59
+ test("no-external-ui-imports allows imports from the design system", () => {
60
+ const reports = runRule(noExternalUiImports, "ImportDeclaration", {
61
+ source: { value: "@backbone/design-system" },
62
+ })
63
+
64
+ assert.deepEqual(reports, [])
65
+ })
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "exactOptionalPropertyTypes": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noImplicitOverride": true,
11
+ "noPropertyAccessFromIndexSignature": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noUnusedLocals": true,
15
+ "noUnusedParameters": true,
16
+ "noUncheckedSideEffectImports": true,
17
+ "verbatimModuleSyntax": true,
18
+ "moduleDetection": "force",
19
+ "allowUnreachableCode": false,
20
+ "allowUnusedLabels": false,
21
+ "declaration": true,
22
+ "outDir": "dist",
23
+ "rootDir": ".",
24
+ "skipLibCheck": true,
25
+ "forceConsistentCasingInFileNames": true,
26
+ "types": ["node"]
27
+ },
28
+ "include": ["src/**/*.ts", "test/**/*.ts"]
29
+ }