@canonical/code-standards 0.1.0 → 0.1.2

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.
@@ -0,0 +1,643 @@
1
+ # Packaging Standards
2
+
3
+ Standards for packaging development.
4
+
5
+ ## packaging/application/structure
6
+
7
+ Application packages (CLIs, servers, MCP tools, build scripts, workers) execute behavior through entry points. They are not imported as dependencies by other packages. Their internal code must be organized around execution domains (commands, routes, tools, operations) rather than library conventions like `src/lib/` or barrel re-exports. Each domain folder must have its own barrel file (`index.ts`) that defines the domain's internal API — sibling domains import through the barrel, never from individual files.
8
+
9
+ ### Do
10
+
11
+ Recognise an application package by its `package.json` signals — `bin` field, `private: true`, no `main`/`exports`
12
+ ```json
13
+ {
14
+ "name": "@scope/my-cli",
15
+ "private": true,
16
+ "bin": { "my-cli": "./dist/index.js" }
17
+ }
18
+ ```
19
+
20
+ Organize code around execution domains. Each domain folder gets its own barrel (`index.ts`) that exposes the domain's internal API to sibling domains
21
+ ```
22
+ packages/my-cli/
23
+ ├── src/
24
+ │ ├── commands/ # CLI command handlers
25
+ │ │ ├── build.ts
26
+ │ │ ├── deploy.ts
27
+ │ │ └── index.ts # Barrel: exports all commands
28
+ │ ├── operations/ # Shared internal logic across commands
29
+ │ │ ├── resolveConfig.ts
30
+ │ │ ├── runValidation.ts
31
+ │ │ └── index.ts # Barrel: exports shared operations
32
+ │ ├── utils/ # Internal helpers
33
+ │ │ ├── formatOutput.ts
34
+ │ │ └── index.ts # Barrel: exports utilities
35
+ │ └── index.ts # Entry point (bootstraps CLI)
36
+ └── package.json
37
+ ```
38
+
39
+ Import from sibling domains through their barrel, not from individual files
40
+ ```typescript
41
+ // src/commands/deploy.ts
42
+ import { resolveConfig, runValidation } from "../operations/index.js";
43
+ import { formatOutput } from "../utils/index.js";
44
+ ```
45
+
46
+ Use domain-appropriate folder names that reflect what the code does — `commands/`, `tools/`, `routes/`, `operations/`, `handlers/`
47
+ ```
48
+ packages/my-mcp/
49
+ ├── src/
50
+ │ ├── tools/ # MCP tool handlers
51
+ │ │ ├── query.ts
52
+ │ │ ├── mutate.ts
53
+ │ │ └── index.ts # Barrel: exports tool handlers
54
+ │ ├── operations/ # Shared internal logic
55
+ │ │ ├── executeQuery.ts
56
+ │ │ └── index.ts # Barrel: exports shared operations
57
+ │ └── index.ts # MCP server entry point
58
+ └── package.json
59
+ ```
60
+
61
+ For **hybrid** packages (primarily application but exporting a small reusable surface), isolate the exported surface in `src/lib/` following library rules while keeping the rest as application structure
62
+ ```
63
+ packages/my-mcp/
64
+ ├── src/
65
+ │ ├── lib/ # Only the reusable exported surface
66
+ │ │ ├── types.ts
67
+ │ │ └── index.ts # Barrel: public API for external consumers
68
+ │ ├── tools/ # MCP tool handlers (not exported)
69
+ │ │ ├── query.ts
70
+ │ │ ├── mutate.ts
71
+ │ │ └── index.ts # Barrel: exports tool handlers
72
+ │ ├── operations/ # Shared internal logic
73
+ │ │ ├── executeQuery.ts
74
+ │ │ └── index.ts # Barrel: exports shared operations
75
+ │ └── index.ts # MCP server entry point
76
+ └── package.json
77
+ ```
78
+
79
+ ### Don't
80
+
81
+ Apply library structure (`src/lib/`, barrel re-exports) to application packages
82
+ ```
83
+ // Bad: CLI tool forced into library layout
84
+ packages/my-cli/
85
+ ├── src/
86
+ │ ├── lib/ # Wrong: this is a CLI, not a library
87
+ │ │ ├── commands/
88
+ │ │ └── index.ts # Barrel re-export — nobody imports this
89
+ │ └── index.ts
90
+ ```
91
+
92
+ Create a `src/lib/` folder for internal shared operations. Use domain-appropriate names instead
93
+ ```
94
+ // Bad: "lib" implies external consumption
95
+ packages/my-cli/
96
+ ├── src/
97
+ │ ├── lib/
98
+ │ │ └── resolveConfig.ts # Only used internally
99
+
100
+ // Good: "operations" or "shared" makes intent clear
101
+ packages/my-cli/
102
+ ├── src/
103
+ │ ├── operations/
104
+ │ │ └── resolveConfig.ts # Internal shared logic
105
+ ```
106
+
107
+ Import directly from files inside a sibling domain — always go through the domain barrel
108
+ ```typescript
109
+ // Bad: reaching into a sibling domain's internals
110
+ import { resolveConfig } from "../operations/resolveConfig.js";
111
+ import { formatOutput } from "../utils/formatOutput.js";
112
+
113
+ // Good: import through the domain barrel
114
+ import { resolveConfig } from "../operations/index.js";
115
+ import { formatOutput } from "../utils/index.js";
116
+ ```
117
+
118
+ Omit barrels from domain folders
119
+ ```
120
+ // Bad: no barrels — every consumer imports individual files
121
+ packages/my-cli/
122
+ ├── src/
123
+ │ ├── commands/
124
+ │ │ ├── build.ts
125
+ │ │ └── deploy.ts # No index.ts — siblings must know file names
126
+ │ ├── operations/
127
+ │ │ ├── resolveConfig.ts
128
+ │ │ └── runValidation.ts # No index.ts — fragile cross-domain imports
129
+ ```
130
+
131
+ Default to library structure just because the package lives in a monorepo
132
+ ```
133
+ // Bad: reflexively adding lib/ to a build script package
134
+ packages/build-tools/
135
+ ├── src/
136
+ │ ├── lib/ # This is a build script, not a library
137
+ │ │ └── runBuild.ts
138
+ │ └── index.ts
139
+ ```
140
+
141
+ ---
142
+
143
+ ## packaging/export/barrel
144
+
145
+ Every domain folder must have a barrel file (`index.ts`) that defines its API surface. In library packages, barrels define the public API for external consumers. In application packages, barrels define the internal API between sibling domains. Within a domain, implementation files import from siblings directly; cross-domain imports always go through the barrel.
146
+
147
+ ### Do
148
+
149
+ Use a barrel at the package entry point to define the public API (library packages)
150
+ ```typescript
151
+ // src/index.ts
152
+ export { Button } from "./components/Button.js";
153
+ export type { ButtonProps } from "./components/Button.types.js";
154
+ ```
155
+
156
+ Use a barrel at each domain folder to define the domain's API (application packages)
157
+ ```typescript
158
+ // src/operations/index.ts
159
+ export { resolveConfig } from "./resolveConfig.js";
160
+ export { runValidation } from "./runValidation.js";
161
+ ```
162
+
163
+ Import internal implementation code within a domain from the owning file directly
164
+ ```typescript
165
+ // src/build/buildTheme.ts — same domain, import the file
166
+ import { computeDeltas } from "./computeDeltas.js";
167
+ import { recoverPrimitiveRef } from "./recoverPrimitiveRef.js";
168
+ ```
169
+
170
+ Import cross-domain code through the sibling domain's barrel
171
+ ```typescript
172
+ // src/commands/deploy.ts — different domain, import through barrel
173
+ import { resolveConfig } from "../operations/index.js";
174
+ ```
175
+
176
+ Keep compatibility barrels thin and documented as public facades
177
+ ```typescript
178
+ /** Public compatibility surface. */
179
+ export { computeDeltas } from "./computeDeltas.js";
180
+ export { formatDelta } from "./formatDelta.js";
181
+ ```
182
+
183
+ ### Don't
184
+
185
+ Route internal same-domain code through its own barrel
186
+ ```typescript
187
+ // Bad: internal implementation depends on its own barrel
188
+ // src/build/applyTheme.ts
189
+ import { computeDeltas, recoverPrimitiveRef } from "./index.js";
190
+ ```
191
+
192
+ Import cross-domain code by reaching into individual files
193
+ ```typescript
194
+ // Bad: bypassing the domain barrel
195
+ import { resolveConfig } from "../operations/resolveConfig.js";
196
+
197
+ // Good: through the barrel
198
+ import { resolveConfig } from "../operations/index.js";
199
+ ```
200
+
201
+ Hide domain ownership behind convenience barrels
202
+ ```typescript
203
+ // Bad: types appear to belong to context.ts instead of their owning domains
204
+ import type { Artifact, CSSNode, OverlayToken } from "./context.js";
205
+ ```
206
+
207
+ Omit the barrel from a domain folder
208
+ ```
209
+ // Bad: no index.ts — consumers must know internal file names
210
+ src/operations/
211
+ ├── resolveConfig.ts
212
+ └── runValidation.ts
213
+ ```
214
+
215
+ ---
216
+
217
+ ## packaging/export/declaration
218
+
219
+ Exports must be declared on the definition itself (e.g. `export default function` or `export const`), not gathered at the end of the file. When a wrapper (HOC, decorator, middleware) is applied, declare the unwrapped binding with `const`, then export the wrapped result as default on a separate line.
220
+
221
+ ### Do
222
+
223
+ Declare the export directly on the function or class definition
224
+ ```typescript
225
+ // formatCurrency.ts
226
+ export default function formatCurrency(value: number): string {
227
+ return `$${value.toFixed(2)}`;
228
+ }
229
+ ```
230
+
231
+ Declare the export directly on a named constant
232
+ ```typescript
233
+ // MAX_RETRIES.ts
234
+ export const MAX_RETRIES = 3;
235
+ ```
236
+
237
+ When a wrapper is involved, declare the raw binding with `const`, then export the wrapped result as default
238
+ ```typescript
239
+ // UserProfile.tsx
240
+ const UserProfile = ({ user }: UserProfileProps) => {
241
+ return <div>{user.name}</div>;
242
+ };
243
+
244
+ export default memo(UserProfile);
245
+ ```
246
+
247
+ Apply the same pattern for higher-order functions and middleware
248
+ ```typescript
249
+ // connectDatabase.ts
250
+ const connectDatabase = (config: DbConfig): Connection => {
251
+ return new Connection(config);
252
+ };
253
+
254
+ export default withRetry(connectDatabase);
255
+ ```
256
+
257
+ ### Don't
258
+
259
+ Define a symbol and then export it at the end of the file
260
+ ```typescript
261
+ // Bad: export is separated from definition
262
+ function formatCurrency(value: number): string {
263
+ return `$${value.toFixed(2)}`;
264
+ }
265
+
266
+ export default formatCurrency;
267
+ ```
268
+
269
+ Gather multiple exports at the bottom of the file
270
+ ```typescript
271
+ // Bad: exports collected at the end
272
+ const helperA = () => {};
273
+ const helperB = () => {};
274
+
275
+ export { helperA, helperB };
276
+ ```
277
+
278
+ Use `export default` on the raw binding when a wrapper is needed
279
+ ```typescript
280
+ // Bad: exports the unwrapped version, then wraps elsewhere
281
+ export default function UserProfile({ user }: UserProfileProps) {
282
+ return <div>{user.name}</div>;
283
+ }
284
+
285
+ // consumer.ts — has to wrap it themselves
286
+ import UserProfile from "./UserProfile.js";
287
+ const Memoized = memo(UserProfile);
288
+ ```
289
+
290
+ ---
291
+
292
+ ## packaging/export/shape
293
+
294
+ Files must use either a single default export or multiple named exports. When using multiple named exports, all exports must have the same type or shape.
295
+
296
+ ### Do
297
+
298
+ Use a single default export for files implementing a single component or function
299
+ ```typescript
300
+ // ComponentName.tsx
301
+ export default ComponentName;
302
+ ```
303
+
304
+ Name atomic function files after the function they export
305
+ ```typescript
306
+ // assignElement.ts
307
+ export default function assignElement(target: Element, source: Partial<Element>) {
308
+ // ...
309
+ }
310
+
311
+ // formatCurrency.ts
312
+ export default function formatCurrency(value: number) {
313
+ // ...
314
+ }
315
+ ```
316
+
317
+ Use multiple named exports for files providing a public API or a collection of related items, and ensure all exports have the same type
318
+ ```typescript
319
+ // index.ts
320
+ export { ComponentA, ComponentB };
321
+
322
+ // types.ts
323
+ export type TypeA = { ... };
324
+ export type TypeB = { ... };
325
+
326
+ // Consistent export shape:
327
+ export const myFuncA = (value: string) => {};
328
+ export const myFuncB = (value: string) => {};
329
+ export const myFuncC = (value: string) => {};
330
+ ```
331
+
332
+ ### Don't
333
+
334
+ Mix default and unrelated named exports in a way that confuses the file's purpose
335
+ ```typescript
336
+ export default ComponentName;
337
+ export const helper = () => {};
338
+ ```
339
+
340
+ Name an atomic function file with a name that doesn't match its exported function
341
+ ```typescript
342
+ // helpers.ts — wrong: generic name instead of function name
343
+ export default function assignElement(target: Element, source: Partial<Element>) {
344
+ // ...
345
+ }
346
+
347
+ // utils.ts — wrong: generic name instead of function name
348
+ export default function formatCurrency(value: number) {
349
+ // ...
350
+ }
351
+ ```
352
+
353
+ Provide multiple unrelated exports from a file meant for a single domain
354
+ ```typescript
355
+ export default debounce;
356
+ export const throttle = () => {};
357
+ export const logger = () => {};
358
+ ```
359
+
360
+ Export objects of different types or shapes from the same file
361
+ ```typescript
362
+ export const transformer = (value: string) => {};
363
+ export const reducer = (map: string[]) => {};
364
+ class ABC {}
365
+ export { ABC };
366
+ ```
367
+
368
+ ---
369
+
370
+ ## packaging/import/cross-domain
371
+
372
+ Intra-package cross-domain imports must use Node.js subpath imports (the `imports` field in `package.json`) with the `#` prefix convention. Each `#` alias points directly to the domain's barrel file — no wildcard splats, no path suffixes, no extension mapping. With `moduleResolution: "nodenext"` (or `"node16"`), bare specifiers like `error/index.js` are treated as package names, not path-relative imports. The `paths` compiler option is a manual alias table — brittle and verbose. Subpath imports are the official Node.js mechanism, work natively with Node, Bun, vitest, and tsc, and require no plugins. TypeScript resolves them when `resolvePackageJsonImports` is enabled (on by default with `moduleResolution` set to `node16`, `nodenext`, or `bundler`).
373
+
374
+ ### Do
375
+
376
+ Map each `#` alias directly to the domain barrel in `package.json` — no wildcards, no path suffixes
377
+ ```json
378
+ {
379
+ "imports": {
380
+ "#config": "./src/config/index.ts",
381
+ "#error": "./src/error/index.ts",
382
+ "#pipeline": "./src/pipeline/index.ts",
383
+ "#package-manager": "./src/package-manager/index.ts",
384
+ "#constants": "./src/constants.ts"
385
+ }
386
+ }
387
+ ```
388
+
389
+ Import cross-domain modules using the `#` alias — clean, no path suffixes
390
+ ```typescript
391
+ // src/pipeline/runPipeline.ts
392
+ import { PragmaError } from "#error";
393
+ import { resolveConfig } from "#config";
394
+ ```
395
+
396
+ Use `moduleResolution: "nodenext"` (or `"node16"` / `"bundler"`) in `tsconfig.json` so TypeScript resolves subpath imports via `resolvePackageJsonImports` (enabled by default with these settings)
397
+ ```json
398
+ {
399
+ "compilerOptions": {
400
+ "moduleResolution": "nodenext"
401
+ }
402
+ }
403
+ ```
404
+
405
+ Use conditional subpath imports when you need different resolutions for different environments
406
+ ```json
407
+ {
408
+ "imports": {
409
+ "#db": {
410
+ "development": "./src/db/mock.ts",
411
+ "default": "./src/db/real.ts"
412
+ }
413
+ }
414
+ }
415
+ ```
416
+
417
+ ### Don't
418
+
419
+ Use wildcard splats or path suffixes in `#` aliases — point directly to the barrel instead
420
+ ```json
421
+ // Bad: wildcard mapping forces consumers to know internal file structure
422
+ {
423
+ "imports": {
424
+ "#config/*": "./src/config/*",
425
+ "#error/*": "./src/error/*"
426
+ }
427
+ }
428
+
429
+ // Bad: path suffix leaks barrel structure into every import site
430
+ import { PragmaError } from "#error/index.js";
431
+ ```
432
+
433
+ Use bare specifiers without the `#` prefix for intra-package imports — they resolve as package names under `nodenext`
434
+ ```typescript
435
+ // Bad: "error/index.js" resolves as the npm package "error", not ./src/error/
436
+ import { PragmaError } from "error/index.js";
437
+ ```
438
+
439
+ Use `paths` in `tsconfig.json` as a substitute for subpath imports — it's a manual alias table that doesn't participate in Node.js resolution
440
+ ```json
441
+ // Bad: brittle, verbose, requires a bundler or ts-patch to work at runtime
442
+ {
443
+ "compilerOptions": {
444
+ "baseUrl": ".",
445
+ "paths": {
446
+ "@error/*": ["src/error/*"],
447
+ "@config/*": ["src/config/*"],
448
+ "@pipeline/*": ["src/pipeline/*"]
449
+ }
450
+ }
451
+ }
452
+ ```
453
+
454
+ Use deep relative paths to reach across domains — they break when folders move and obscure the dependency graph
455
+ ```typescript
456
+ // Bad: fragile, hard to refactor
457
+ import { PragmaError } from "../../error/PragmaError.js";
458
+ import { resolveConfig } from "../../../config/resolveConfig.js";
459
+ ```
460
+
461
+ Import specific files through the `#` alias — go through the barrel instead
462
+ ```typescript
463
+ // Bad: bypasses the barrel, couples to internal file structure
464
+ import { readConfig } from "#config/readConfig.js";
465
+
466
+ // Good: import through the barrel
467
+ import { readConfig } from "#config";
468
+ ```
469
+
470
+ ---
471
+
472
+ ## packaging/library/structure
473
+
474
+ Library packages export reusable code (components, utilities, hooks, types) for consumption by other packages. They must organize their exportable code in a `src/lib/` folder and re-export through a root barrel. This convention ensures consistency across the monorepo and clearly separates public API code from non-exported concerns like storybook configuration or test utilities. Each domain folder within `lib/` must have its own barrel (`index.ts`).
475
+
476
+ ### Do
477
+
478
+ Recognise a library package by its `package.json` signals — `main`/`exports` field, publishable, no `bin`
479
+ ```json
480
+ {
481
+ "name": "@scope/design-system",
482
+ "main": "./dist/index.js",
483
+ "exports": { ".": "./dist/index.js" }
484
+ }
485
+ ```
486
+
487
+ Place all reusable/exportable code in `src/lib/`, with a barrel in each domain folder
488
+ ```
489
+ packages/my-package/
490
+ ├── src/
491
+ │ ├── lib/
492
+ │ │ ├── Button/
493
+ │ │ │ ├── Button.tsx
494
+ │ │ │ ├── Button.tests.tsx
495
+ │ │ │ ├── types.ts
496
+ │ │ │ └── index.ts # Barrel: exports Button public API
497
+ │ │ ├── hooks/
498
+ │ │ │ ├── useToggle.ts
499
+ │ │ │ └── index.ts # Barrel: exports hooks
500
+ │ │ ├── types/
501
+ │ │ │ └── index.ts # Barrel: exports shared types
502
+ │ │ └── index.ts # Barrel: lib public API
503
+ │ ├── storybook/ # Storybook-specific files (not exported)
504
+ │ └── index.ts # Re-exports from lib
505
+ └── package.json
506
+ ```
507
+
508
+ Re-export from the lib folder in the package entry point
509
+ ```typescript
510
+ // src/index.ts
511
+ export * from "./lib/index.js";
512
+ ```
513
+
514
+ Use the lib barrel to compose the package's public API from domain barrels
515
+ ```typescript
516
+ // src/lib/index.ts
517
+ export * from "./Button/index.js";
518
+ export * from "./hooks/index.js";
519
+ export type * from "./types/index.js";
520
+ ```
521
+
522
+ ### Don't
523
+
524
+ Use alternative folder names like `ui`, `components`, or `utils` for exportable code at the package level
525
+ ```
526
+ // Bad: Using 'ui' instead of 'lib'
527
+ packages/my-package/
528
+ ├── src/
529
+ │ ├── ui/ # Wrong: should be 'lib'
530
+ │ │ └── Button/
531
+ │ └── index.ts
532
+ ```
533
+
534
+ Mix exportable and non-exportable code at the same level
535
+ ```
536
+ // Bad: Mixing concerns
537
+ packages/my-package/
538
+ ├── src/
539
+ │ ├── Button/ # Component mixed with...
540
+ │ ├── storybook/ # ...non-exportable storybook config
541
+ │ └── test-utils/ # ...and test utilities
542
+ ```
543
+
544
+ Create deeply nested lib-like structures; keep lib at the top level of src
545
+ ```
546
+ // Bad: Nested lib folders
547
+ packages/my-package/
548
+ ├── src/
549
+ │ ├── features/
550
+ │ │ └── lib/ # Wrong: lib should be at src level
551
+ ```
552
+
553
+ Omit barrels from domain folders within lib
554
+ ```
555
+ // Bad: no barrels — consumers must know individual file paths
556
+ packages/my-package/
557
+ ├── src/
558
+ │ ├── lib/
559
+ │ │ ├── Button/
560
+ │ │ │ ├── Button.tsx
561
+ │ │ │ └── types.ts # No index.ts — fragile imports
562
+ │ │ ├── hooks/
563
+ │ │ │ └── useToggle.ts # No index.ts
564
+ │ │ └── index.ts
565
+ ```
566
+
567
+ ---
568
+
569
+ ## packaging/naming/single-export-file
570
+
571
+ Files that contain a single export must be named after that export. This applies to functions, classes, types, constants, and any other single-export module. The file name must match the exported identifier exactly, preserving its casing (camelCase for functions/variables, PascalCase for classes/types/components).
572
+
573
+ ### Do
574
+
575
+ Name files after the single function they export
576
+ ```typescript
577
+ // isAccepted.ts
578
+ export const isAccepted = (status: string): boolean => {
579
+ return status === "accepted";
580
+ };
581
+
582
+ // formatCurrency.ts
583
+ export default function formatCurrency(value: number): string {
584
+ return `$${value.toFixed(2)}`;
585
+ }
586
+ ```
587
+
588
+ Name files after the single type or interface they export
589
+ ```typescript
590
+ // ConnectionConfig.ts
591
+ export interface ConnectionConfig {
592
+ host: string;
593
+ port: number;
594
+ }
595
+
596
+ // UserRole.ts
597
+ export type UserRole = "admin" | "editor" | "viewer";
598
+ ```
599
+
600
+ Name files after the single class they export
601
+ ```typescript
602
+ // EventEmitter.ts
603
+ export class EventEmitter {
604
+ // ...
605
+ }
606
+ ```
607
+
608
+ Name files after the single constant they export
609
+ ```typescript
610
+ // DEFAULT_TIMEOUT.ts
611
+ export const DEFAULT_TIMEOUT = 5000;
612
+ ```
613
+
614
+ ### Don't
615
+
616
+ Use generic names like `helpers.ts`, `utils.ts`, or `types.ts` for files with a single export
617
+ ```typescript
618
+ // helpers.ts — wrong: should be isAccepted.ts
619
+ export const isAccepted = (status: string): boolean => {
620
+ return status === "accepted";
621
+ };
622
+
623
+ // utils.ts — wrong: should be formatCurrency.ts
624
+ export default function formatCurrency(value: number): string {
625
+ return `$${value.toFixed(2)}`;
626
+ }
627
+ ```
628
+
629
+ Use names that describe the domain instead of the export
630
+ ```typescript
631
+ // validation.ts — wrong: should be isAccepted.ts
632
+ export const isAccepted = (status: string): boolean => {
633
+ return status === "accepted";
634
+ };
635
+
636
+ // network.ts — wrong: should be ConnectionConfig.ts
637
+ export interface ConnectionConfig {
638
+ host: string;
639
+ port: number;
640
+ }
641
+ ```
642
+
643
+ ---