mikoshi-construct 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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/cli.js +1492 -0
  4. package/package.json +73 -0
  5. package/templates/ai/claude/CLAUDE.md.eta +18 -0
  6. package/templates/ai/claude/CLAUDE.md.existing.eta +15 -0
  7. package/templates/ai/claude/_claude/agents/architect.md +38 -0
  8. package/templates/ai/claude/_claude/agents/harness.md +40 -0
  9. package/templates/ai/claude/_claude/agents/implementer.md +40 -0
  10. package/templates/ai/claude/_claude/commands/plan.md +24 -0
  11. package/templates/ai/claude/_claude/skills/implement/SKILL.md +42 -0
  12. package/templates/ai/claude/scripts/construct/implement.workflow.mjs +185 -0
  13. package/templates/ai/cursor/_cursor/rules/construct.mdc +19 -0
  14. package/templates/ai/review/_github/workflows/claude-review.yml.eta +51 -0
  15. package/templates/ai/shared/AGENTS.md.eta +123 -0
  16. package/templates/ai/shared/AGENTS.md.existing.eta +50 -0
  17. package/templates/ai/shared/_claude/commands/construct-discover.md +75 -0
  18. package/templates/ai/shared/_claude/rules/conventions.md +63 -0
  19. package/templates/ai/shared/_claude/rules/secrets.md +8 -0
  20. package/templates/ai/shared/_claude/rules/tests.md +6 -0
  21. package/templates/base/_editorconfig +12 -0
  22. package/templates/base/_github/workflows/security.yml +41 -0
  23. package/templates/base/_gitignore +11 -0
  24. package/templates/base/_gitleaks.toml +2 -0
  25. package/templates/base/_nvmrc.eta +1 -0
  26. package/templates/base/_vscode/settings.json +22 -0
  27. package/templates/base/architecture/checklists.md +42 -0
  28. package/templates/base/architecture/principles.md +113 -0
  29. package/templates/base/architecture/security-invariants.md +17 -0
  30. package/templates/harness/_github/workflows/ci.yml.eta +31 -0
  31. package/templates/harness/eslint.config.mjs +13 -0
  32. package/templates/harness/package.json.eta +29 -0
  33. package/templates/harness/pnpm-workspace.yaml +11 -0
  34. package/templates/harness/scripts/composition/check.ts +19 -0
  35. package/templates/harness/scripts/composition/files.ts +34 -0
  36. package/templates/harness/scripts/composition/model.ts +137 -0
  37. package/templates/harness/scripts/composition/render.ts +72 -0
  38. package/templates/harness/scripts/composition/sync-docs.ts +11 -0
  39. package/templates/harness/scripts/tests/composition/files.test.ts +16 -0
  40. package/templates/harness/scripts/tests/composition/model.test.ts +41 -0
  41. package/templates/harness/scripts/tests/composition/render.test.ts +54 -0
  42. package/templates/harness/tsconfig.base.json +14 -0
  43. package/templates/harness/tsconfig.json +5 -0
  44. package/templates/harness/vitest.config.ts +8 -0
  45. package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +100 -0
  46. package/templates/presets/monorepo/baseline/package.json.eta +18 -0
  47. package/templates/presets/monorepo/baseline/packages/shared/package.json.eta +22 -0
  48. package/templates/presets/monorepo/baseline/packages/shared/src/api/openapi.ts +81 -0
  49. package/templates/presets/monorepo/baseline/packages/shared/src/index.ts +1 -0
  50. package/templates/presets/monorepo/baseline/packages/shared/tsconfig.build.json +10 -0
  51. package/templates/presets/monorepo/baseline/packages/shared/tsconfig.json +4 -0
  52. package/templates/presets/monorepo/baseline/pnpm-workspace.yaml.eta +31 -0
  53. package/templates/presets/monorepo/baseline/tsconfig.json +12 -0
  54. package/templates/presets/monorepo/baseline/vitest.config.ts +8 -0
  55. package/templates/presets/monorepo/sample/apps/api/package.json.eta +23 -0
  56. package/templates/presets/monorepo/sample/apps/api/tsconfig.build.json +10 -0
  57. package/templates/presets/monorepo/sample/apps/api/tsconfig.json +4 -0
  58. package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +36 -0
  59. package/templates/presets/node-backend/baseline/eslint.config.mjs +59 -0
  60. package/templates/presets/node-backend/baseline/package.json.eta +20 -0
  61. package/templates/presets/node-backend/baseline/src/contracts/openapi.ts +81 -0
  62. package/templates/presets/node-backend/baseline/tsconfig.build.json +10 -0
  63. package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +31 -0
  64. package/templates/presets/node-frontend/baseline/_claude/rules/css.md +158 -0
  65. package/templates/presets/node-frontend/baseline/eslint.config.mjs +44 -0
  66. package/templates/presets/node-frontend/baseline/package.json.eta +15 -0
  67. package/templates/presets/node-frontend/baseline/tsconfig.json +11 -0
  68. package/templates/presets/node-frontend/baseline/vitest.config.ts +10 -0
  69. package/templates/presets/node-frontend/sample/architecture/app.md +37 -0
  70. package/templates/presets/node-frontend/sample/architecture/composition/app.yaml +41 -0
  71. package/templates/presets/node-frontend/sample/index.html.eta +12 -0
  72. package/templates/presets/node-frontend/sample/src/app.ts +32 -0
  73. package/templates/presets/node-frontend/sample/src/main.ts +9 -0
  74. package/templates/presets/node-frontend/sample/src/styles/app.css +43 -0
  75. package/templates/presets/node-frontend/sample/src/styles/tokens.css +16 -0
  76. package/templates/presets/node-frontend/sample/tests/app.test.ts +21 -0
  77. package/templates/stacks/express-api/app/src/app.ts +27 -0
  78. package/templates/stacks/express-api/app/src/config.ts.eta +30 -0
  79. package/templates/stacks/express-api/app/src/contracts/types.ts.eta +1 -0
  80. package/templates/stacks/express-api/app/src/health/health.controller.ts +12 -0
  81. package/templates/stacks/express-api/app/src/health/health.service.ts +23 -0
  82. package/templates/stacks/express-api/app/src/http/api-contract.middleware.ts +13 -0
  83. package/templates/stacks/express-api/app/src/http/app-error.ts +14 -0
  84. package/templates/stacks/express-api/app/src/http/async-handler.middleware.ts +7 -0
  85. package/templates/stacks/express-api/app/src/http/error-handler.middleware.ts +50 -0
  86. package/templates/stacks/express-api/app/src/server.ts +9 -0
  87. package/templates/stacks/express-api/app/tests/health.test.ts +28 -0
  88. package/templates/stacks/express-api/app/tests/http/error-handler.middleware.test.ts +48 -0
  89. package/templates/stacks/express-api/repo/architecture/composition/http.yaml.eta +45 -0
  90. package/templates/stacks/express-api/repo/architecture/http.md +39 -0
  91. package/templates/stacks/http-contract/_github/workflows/api-contract.yml +36 -0
  92. package/templates/stacks/http-contract/contracts/api/openapi.yaml.eta +62 -0
  93. package/templates/stacks/http-contract/package.json.eta +13 -0
  94. package/templates/stacks/http-contract/redocly.yaml.eta +8 -0
  95. package/templates/stacks/http-contract/scripts/contracts/types.mjs.eta +20 -0
  96. package/templates/stacks/http-contract/scripts/tests/contracts/security.test.ts.eta +86 -0
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ {{#if pnpmVersion}}
7
+ "packageManager": "pnpm@{{pnpmVersion}}",
8
+ {{/if}}
9
+ "scripts": {
10
+ "composition:render": "tsx scripts/composition/sync-docs.ts",
11
+ "composition:check": "tsx scripts/composition/check.ts",
12
+ "lint": "eslint .",
13
+ "lint:fix": "eslint --fix .",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "quality": "pnpm composition:check && pnpm lint && pnpm typecheck && pnpm test",
18
+ "ci": "pnpm run quality"
19
+ },
20
+ "devDependencies": {
21
+ "@antfu/eslint-config": "^9.5.1",
22
+ "@types/node": "^{{nodeMajor}}.0.0",
23
+ "eslint": "^10.10.0",
24
+ "tsx": "^4.23.13",
25
+ "typescript": "^5.9.3",
26
+ "vitest": "^5.0.0",
27
+ "yaml": "^2.9.1"
28
+ }
29
+ }
@@ -0,0 +1,11 @@
1
+ minimumReleaseAgeExcludePrune: true
2
+
3
+ trustPolicy: no-downgrade
4
+ trustPolicyExclude:
5
+ - chokidar@4.0.3
6
+ - undici-types@6.21.0
7
+
8
+ shellEmulator: true
9
+
10
+ allowBuilds:
11
+ esbuild: true
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import process from 'node:process'
3
+ import { loadCompositionModels, missingPaths } from './files.js'
4
+ import { extractEmbedded, renderEmbedded } from './render.js'
5
+
6
+ const problems: string[] = []
7
+
8
+ for (const { model, file, docPath } of loadCompositionModels()) {
9
+ for (const missing of missingPaths(model))
10
+ problems.push(`${file}: path "${missing}" does not exist`)
11
+ const doc = readFileSync(docPath, 'utf8')
12
+ if (extractEmbedded(doc, model, model.doc) !== renderEmbedded(model))
13
+ problems.push(`${model.doc}: the ${model.id} diagram is out of date with ${file}; run pnpm composition:render`)
14
+ }
15
+
16
+ if (problems.length > 0) {
17
+ console.error(problems.join('\n'))
18
+ process.exit(1)
19
+ }
@@ -0,0 +1,34 @@
1
+ import type { CompositionModel } from './model.js'
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { parseCompositionModel } from './model.js'
6
+
7
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
8
+ export const COMPOSITION_DIR = path.join(REPO_ROOT, 'architecture/composition')
9
+
10
+ export interface LoadedModel {
11
+ model: CompositionModel
12
+ file: string
13
+ docPath: string
14
+ }
15
+
16
+ export function loadCompositionModels(dir = COMPOSITION_DIR): LoadedModel[] {
17
+ if (!existsSync(dir))
18
+ return []
19
+ return readdirSync(dir)
20
+ .filter(file => file.endsWith('.yaml'))
21
+ .sort()
22
+ .map((file) => {
23
+ const model = parseCompositionModel(readFileSync(path.join(dir, file), 'utf8'), file)
24
+ if (`${model.id}.yaml` !== file)
25
+ throw new Error(`${file}: the file must be named after its id "${model.id}"`)
26
+ return { model, file, docPath: path.join(REPO_ROOT, model.doc) }
27
+ })
28
+ }
29
+
30
+ export function missingPaths(model: CompositionModel, root = REPO_ROOT): string[] {
31
+ return model.nodes
32
+ .flatMap(node => (node.path == null ? [] : [node.path]))
33
+ .filter(candidate => !existsSync(path.join(root, candidate)))
34
+ }
@@ -0,0 +1,137 @@
1
+ import { parse } from 'yaml'
2
+
3
+ export const EDGE_KINDS = ['sequence', 'parallel', 'route', 'fan-out', 'fan-in', 'wires'] as const
4
+ export type EdgeKind = (typeof EDGE_KINDS)[number]
5
+
6
+ export interface Boundary {
7
+ id: string
8
+ label: string
9
+ }
10
+
11
+ export interface CompositionNode {
12
+ id: string
13
+ label: string
14
+ path?: string
15
+ boundary?: string
16
+ }
17
+
18
+ export interface CompositionEdge {
19
+ from: string
20
+ to: string
21
+ kind: EdgeKind
22
+ label?: string
23
+ }
24
+
25
+ export interface CompositionModel {
26
+ id: string
27
+ title: string
28
+ description?: string
29
+ doc: string
30
+ boundaries: Boundary[]
31
+ nodes: CompositionNode[]
32
+ edges: CompositionEdge[]
33
+ }
34
+
35
+ const IDENTIFIER = /^[A-Z]\w*$/i
36
+
37
+ function fail(name: string, message: string): never {
38
+ throw new Error(`${name}: ${message}`)
39
+ }
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === 'object' && value != null && !Array.isArray(value)
43
+ }
44
+
45
+ function text(name: string, record: Record<string, unknown>, key: string, where: string): string {
46
+ const value = record[key]
47
+ if (typeof value !== 'string' || value.trim() === '')
48
+ fail(name, `${where} needs a non-empty "${key}"`)
49
+ return value
50
+ }
51
+
52
+ function optionalText(name: string, record: Record<string, unknown>, key: string, where: string): string | undefined {
53
+ if (record[key] === undefined)
54
+ return undefined
55
+ return text(name, record, key, where)
56
+ }
57
+
58
+ function identifier(name: string, value: string, where: string): string {
59
+ if (!IDENTIFIER.test(value))
60
+ fail(name, `${where} id "${value}" must match ${IDENTIFIER}`)
61
+ return value
62
+ }
63
+
64
+ function list(name: string, record: Record<string, unknown>, key: string): Record<string, unknown>[] {
65
+ const value = record[key] ?? []
66
+ if (!Array.isArray(value) || !value.every(isRecord))
67
+ fail(name, `"${key}" must be a list of objects`)
68
+ return value
69
+ }
70
+
71
+ export function parseCompositionModel(source: string, name: string): CompositionModel {
72
+ const raw: unknown = parse(source)
73
+ if (!isRecord(raw))
74
+ fail(name, 'the document must be a mapping')
75
+
76
+ const boundaries = list(name, raw, 'boundaries').map((entry, index) => ({
77
+ id: identifier(name, text(name, entry, 'id', `boundaries[${index}]`), `boundaries[${index}]`),
78
+ label: text(name, entry, 'label', `boundaries[${index}]`),
79
+ }))
80
+ const boundaryIds = new Set(boundaries.map(boundary => boundary.id))
81
+ if (boundaryIds.size !== boundaries.length)
82
+ fail(name, 'boundary ids must be unique')
83
+
84
+ const nodes = list(name, raw, 'nodes').map((entry, index) => {
85
+ const where = `nodes[${index}]`
86
+ const node: CompositionNode = {
87
+ id: identifier(name, text(name, entry, 'id', where), where),
88
+ label: text(name, entry, 'label', where),
89
+ }
90
+ const path = optionalText(name, entry, 'path', where)
91
+ if (path != null)
92
+ node.path = path
93
+ const boundary = optionalText(name, entry, 'boundary', where)
94
+ if (boundary != null) {
95
+ if (!boundaryIds.has(boundary))
96
+ fail(name, `${where} refers to unknown boundary "${boundary}"`)
97
+ node.boundary = boundary
98
+ }
99
+ return node
100
+ })
101
+ const nodeIds = new Set(nodes.map(node => node.id))
102
+ if (nodeIds.size !== nodes.length)
103
+ fail(name, 'node ids must be unique')
104
+ if (nodes.length === 0)
105
+ fail(name, 'at least one node is required')
106
+
107
+ const edges = list(name, raw, 'edges').map((entry, index) => {
108
+ const where = `edges[${index}]`
109
+ const from = text(name, entry, 'from', where)
110
+ const to = text(name, entry, 'to', where)
111
+ for (const endpoint of [from, to]) {
112
+ if (!nodeIds.has(endpoint))
113
+ fail(name, `${where} refers to unknown node "${endpoint}"`)
114
+ }
115
+ const kind = text(name, entry, 'kind', where)
116
+ if (!(EDGE_KINDS as readonly string[]).includes(kind))
117
+ fail(name, `${where} kind "${kind}" is not one of ${EDGE_KINDS.join(', ')}`)
118
+ const edge: CompositionEdge = { from, to, kind: kind as EdgeKind }
119
+ const label = optionalText(name, entry, 'label', where)
120
+ if (label != null)
121
+ edge.label = label
122
+ return edge
123
+ })
124
+
125
+ const model: CompositionModel = {
126
+ id: identifier(name, text(name, raw, 'id', 'the document'), 'the document'),
127
+ title: text(name, raw, 'title', 'the document'),
128
+ doc: text(name, raw, 'doc', 'the document'),
129
+ boundaries,
130
+ nodes,
131
+ edges,
132
+ }
133
+ const description = optionalText(name, raw, 'description', 'the document')
134
+ if (description != null)
135
+ model.description = description
136
+ return model
137
+ }
@@ -0,0 +1,72 @@
1
+ import type { CompositionEdge, CompositionModel } from './model.js'
2
+
3
+ const ARROWS: Record<CompositionEdge['kind'], string> = {
4
+ 'sequence': '-->',
5
+ 'parallel': '==>',
6
+ 'route': '-->',
7
+ 'fan-out': '-->',
8
+ 'fan-in': '-->',
9
+ 'wires': '-.->',
10
+ }
11
+
12
+ const IMPLICIT_LABELS: Partial<Record<CompositionEdge['kind'], string>> = {
13
+ 'parallel': 'parallel',
14
+ 'fan-out': 'fan-out',
15
+ 'fan-in': 'fan-in',
16
+ }
17
+
18
+ function quoted(label: string): string {
19
+ return `"${label.replaceAll('"', '#quot;')}"`
20
+ }
21
+
22
+ function edgeLine(edge: CompositionEdge): string {
23
+ const label = edge.label ?? IMPLICIT_LABELS[edge.kind]
24
+ const link = label == null ? ARROWS[edge.kind] : `${ARROWS[edge.kind]}|${quoted(label)}|`
25
+ return ` ${edge.from} ${link} ${edge.to}`
26
+ }
27
+
28
+ export function renderMermaid(model: CompositionModel): string {
29
+ const lines = ['flowchart LR']
30
+ for (const boundary of model.boundaries) {
31
+ lines.push(` subgraph b_${boundary.id}[${quoted(boundary.label)}]`)
32
+ for (const node of model.nodes.filter(candidate => candidate.boundary === boundary.id))
33
+ lines.push(` ${node.id}[${quoted(node.label)}]`)
34
+ lines.push(' end')
35
+ }
36
+ for (const node of model.nodes.filter(candidate => candidate.boundary == null))
37
+ lines.push(` ${node.id}[${quoted(node.label)}]`)
38
+ for (const edge of model.edges)
39
+ lines.push(edgeLine(edge))
40
+ return `${lines.join('\n')}\n`
41
+ }
42
+
43
+ export function openingMarker(id: string): string {
44
+ return `<!-- composition:${id} -->`
45
+ }
46
+
47
+ export function closingMarker(id: string): string {
48
+ return `<!-- /composition:${id} -->`
49
+ }
50
+
51
+ function markerBounds(doc: string, id: string, docName: string): { start: number, end: number } {
52
+ const start = doc.indexOf(openingMarker(id))
53
+ const end = doc.indexOf(closingMarker(id))
54
+ if (start === -1 || end === -1 || end < start)
55
+ throw new Error(`${docName} is missing the ${openingMarker(id)} … ${closingMarker(id)} block`)
56
+ return { start: start + openingMarker(id).length, end }
57
+ }
58
+
59
+ export function renderEmbedded(model: CompositionModel): string {
60
+ const description = model.description == null ? '' : `${model.description}\n\n`
61
+ return `\n${description}\`\`\`mermaid\n${renderMermaid(model)}\`\`\`\n`
62
+ }
63
+
64
+ export function embed(doc: string, model: CompositionModel, docName: string): string {
65
+ const { start, end } = markerBounds(doc, model.id, docName)
66
+ return doc.slice(0, start) + renderEmbedded(model) + doc.slice(end)
67
+ }
68
+
69
+ export function extractEmbedded(doc: string, model: CompositionModel, docName: string): string {
70
+ const { start, end } = markerBounds(doc, model.id, docName)
71
+ return doc.slice(start, end)
72
+ }
@@ -0,0 +1,11 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { loadCompositionModels } from './files.js'
3
+ import { embed } from './render.js'
4
+
5
+ for (const { model, docPath } of loadCompositionModels()) {
6
+ const doc = readFileSync(docPath, 'utf8')
7
+ const next = embed(doc, model, model.doc)
8
+ if (next !== doc)
9
+ writeFileSync(docPath, next)
10
+ console.warn(`[composition] ${model.id} → ${model.doc}`)
11
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { COMPOSITION_DIR, loadCompositionModels, missingPaths, REPO_ROOT } from '../../composition/files.js'
3
+
4
+ describe('composition models on disk', () => {
5
+ it('are named after their id and point only at files that exist', () => {
6
+ for (const { model, file } of loadCompositionModels(COMPOSITION_DIR)) {
7
+ expect(file).toBe(`${model.id}.yaml`)
8
+ expect(missingPaths(model, REPO_ROOT)).toEqual([])
9
+ }
10
+ })
11
+
12
+ it('reports a node path that does not exist', () => {
13
+ const broken = { id: 'x', title: 'x', doc: 'architecture/x.md', boundaries: [], edges: [], nodes: [{ id: 'x', label: 'x', path: 'src/nowhere/file.ts' }] }
14
+ expect(missingPaths(broken, REPO_ROOT)).toEqual(['src/nowhere/file.ts'])
15
+ })
16
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseCompositionModel } from '../../composition/model.js'
3
+
4
+ const valid = `
5
+ id: demo
6
+ title: Demo flow
7
+ doc: docs/demo.md
8
+ boundaries:
9
+ - { id: api, label: API }
10
+ nodes:
11
+ - { id: a, label: Start, path: package.json, boundary: api }
12
+ - { id: b, label: End }
13
+ edges:
14
+ - { from: a, to: b, kind: sequence, label: next }
15
+ `
16
+
17
+ describe('parseCompositionModel', () => {
18
+ it('reads a valid model', () => {
19
+ const model = parseCompositionModel(valid, 'demo.yaml')
20
+ expect(model.id).toBe('demo')
21
+ expect(model.nodes.map(node => node.id)).toEqual(['a', 'b'])
22
+ expect(model.edges[0]).toEqual({ from: 'a', to: 'b', kind: 'sequence', label: 'next' })
23
+ })
24
+
25
+ it('rejects an edge that points at an unknown node', () => {
26
+ expect(() => parseCompositionModel(valid.replace('to: b', 'to: ghost'), 'demo.yaml'))
27
+ .toThrow('edges[0] refers to unknown node "ghost"')
28
+ })
29
+
30
+ it('rejects an edge kind outside the vocabulary', () => {
31
+ expect(() => parseCompositionModel(valid.replace('kind: sequence', 'kind: maybe'), 'demo.yaml'))
32
+ .toThrow('kind "maybe" is not one of')
33
+ })
34
+
35
+ it('rejects a node in an undeclared boundary and an id Mermaid cannot render', () => {
36
+ expect(() => parseCompositionModel(valid.replace('boundary: api', 'boundary: web'), 'demo.yaml'))
37
+ .toThrow('unknown boundary "web"')
38
+ expect(() => parseCompositionModel(valid.replace('id: a,', 'id: 1a,'), 'demo.yaml'))
39
+ .toThrow('must match')
40
+ })
41
+ })
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseCompositionModel } from '../../composition/model.js'
3
+ import { closingMarker, embed, extractEmbedded, openingMarker, renderEmbedded, renderMermaid } from '../../composition/render.js'
4
+
5
+ const model = parseCompositionModel(`
6
+ id: demo
7
+ title: Demo flow
8
+ description: Two steps.
9
+ doc: docs/demo.md
10
+ boundaries:
11
+ - { id: api, label: "API \\"edge\\"" }
12
+ nodes:
13
+ - { id: a, label: Start, boundary: api }
14
+ - { id: b, label: End }
15
+ - { id: c, label: Side }
16
+ edges:
17
+ - { from: a, to: b, kind: sequence }
18
+ - { from: a, to: c, kind: fan-out }
19
+ - { from: c, to: b, kind: wires, label: provides }
20
+ `, 'demo.yaml')
21
+
22
+ describe('renderMermaid', () => {
23
+ it('groups nodes by boundary, escapes quotes and styles edges by kind', () => {
24
+ expect(renderMermaid(model)).toBe([
25
+ 'flowchart LR',
26
+ ' subgraph b_api["API #quot;edge#quot;"]',
27
+ ' a["Start"]',
28
+ ' end',
29
+ ' b["End"]',
30
+ ' c["Side"]',
31
+ ' a --> b',
32
+ ' a -->|"fan-out"| c',
33
+ ' c -.->|"provides"| b',
34
+ '',
35
+ ].join('\n'))
36
+ })
37
+ })
38
+
39
+ describe('embed and extractEmbedded', () => {
40
+ const doc = `# Doc\n\n${openingMarker('demo')}\nstale\n${closingMarker('demo')}\n\nTail\n`
41
+
42
+ it('replaces only the marked block and reads it back unchanged', () => {
43
+ const next = embed(doc, model, 'docs/demo.md')
44
+ expect(next.startsWith('# Doc\n\n')).toBe(true)
45
+ expect(next.endsWith('\n\nTail\n')).toBe(true)
46
+ expect(next).toContain('Two steps.\n\n```mermaid\nflowchart LR')
47
+ expect(extractEmbedded(next, model, 'docs/demo.md')).toBe(renderEmbedded(model))
48
+ expect(extractEmbedded(doc, model, 'docs/demo.md')).not.toBe(renderEmbedded(model))
49
+ })
50
+
51
+ it('refuses a document without the markers', () => {
52
+ expect(() => embed('# Doc\n', model, 'docs/demo.md')).toThrow('missing the <!-- composition:demo -->')
53
+ })
54
+ })
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "resolveJsonModule": true,
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true
13
+ }
14
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts", "vitest.config.ts"],
4
+ "exclude": ["**/node_modules/**", "**/dist/**"]
5
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.ts', 'scripts/tests/**/*.test.ts'],
6
+ environment: 'node',
7
+ },
8
+ })
@@ -0,0 +1,100 @@
1
+ import antfu from '@antfu/eslint-config'
2
+
3
+ const WORKSPACE_PACKAGES = {{workspacePackages}}
4
+
5
+ const ALLOWED_WORKSPACE_IMPORTS = {{allowedWorkspaceImports}}
6
+
7
+ const PROCESS_ENV = 'MemberExpression[object.name="process"][property.name="env"]'
8
+ const PROCESS_MEMBER = 'MemberExpression[object.name="process"]'
9
+ const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
10
+ const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
11
+ const SHARED_IMPORT = ':matches(ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration)[source.value=/^{{scope}}\\/shared(\\/|$)/]'
12
+
13
+ function dependencyBoundary([directory, allowed]) {
14
+ const forbidden = WORKSPACE_PACKAGES.filter(name => !allowed.includes(name))
15
+ return {
16
+ files: [`${directory}/**`],
17
+ rules: {
18
+ 'no-restricted-imports': ['error', {
19
+ patterns: [{
20
+ group: forbidden.flatMap(name => [name, `${name}/*`]),
21
+ message: allowed.length === 0
22
+ ? `${directory} imports no other workspace package`
23
+ : `${directory} may import only ${allowed.join(', ')}`,
24
+ }],
25
+ }],
26
+ },
27
+ }
28
+ }
29
+
30
+ const dependencyBoundaries = Object.entries(ALLOWED_WORKSPACE_IMPORTS)
31
+ .filter(([, allowed]) => allowed.length < WORKSPACE_PACKAGES.length)
32
+ .map(dependencyBoundary)
33
+
34
+ const NO_PROCESS_OUTSIDE_CONFIG = {
35
+ selector: PROCESS_MEMBER,
36
+ message: 'An app touches process only in config.ts: read configuration through readConfig() and pass the value on',
37
+ }
38
+ const NO_PROCESS_ENV_IN_PACKAGE = {
39
+ selector: PROCESS_ENV,
40
+ message: 'A package reads the environment only in its *.config.ts',
41
+ }
42
+ const NO_RAW_REQUEST_DATA = {
43
+ selector: RAW_REQUEST_DATA,
44
+ message: 'External input is validated at the HTTP boundary: read req.body, req.query and req.params in a controller or middleware and pass values on',
45
+ }
46
+ const NO_RAW_SQL = {
47
+ selector: RAW_SQL,
48
+ message: 'SQL is never built from raw strings: interpolate tables and columns into the sql template instead of sql.raw',
49
+ }
50
+ const NO_SHARED_OUTSIDE_CONTRACTS = {
51
+ selector: SHARED_IMPORT,
52
+ message: 'Contract types enter an app only through src/contracts/types.ts: import them from there',
53
+ }
54
+
55
+ function restrictSyntax(files, restrictions) {
56
+ return { files, rules: { 'no-restricted-syntax': ['error', ...restrictions] } }
57
+ }
58
+
59
+ const APP_ENVIRONMENT_READERS = ['apps/*/src/config.ts', 'apps/*/src/server.ts', 'apps/*/src/scripts/**']
60
+ const HTTP_BOUNDARY = ['apps/*/src/**/*.controller.ts', 'apps/*/src/**/*.middleware.ts', 'apps/*/src/http/**']
61
+ const CONTRACT_TYPES_GATEWAY = ['apps/*/src/contracts/**']
62
+ const PACKAGE_ENVIRONMENT_READERS = ['packages/*/src/*.config.ts']
63
+
64
+ const restrictedSyntax = [
65
+ restrictSyntax(['apps/*/src/**'], [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG, NO_RAW_REQUEST_DATA, NO_SHARED_OUTSIDE_CONTRACTS]),
66
+ restrictSyntax(CONTRACT_TYPES_GATEWAY, [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG, NO_RAW_REQUEST_DATA]),
67
+ restrictSyntax(HTTP_BOUNDARY, [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG, NO_SHARED_OUTSIDE_CONTRACTS]),
68
+ restrictSyntax(APP_ENVIRONMENT_READERS, [NO_RAW_SQL, NO_SHARED_OUTSIDE_CONTRACTS]),
69
+ restrictSyntax(['packages/*/src/**'], [NO_RAW_SQL, NO_PROCESS_ENV_IN_PACKAGE]),
70
+ restrictSyntax(PACKAGE_ENVIRONMENT_READERS, [NO_RAW_SQL]),
71
+ ]
72
+
73
+ export default antfu(
74
+ {
75
+ isInEditor: false,
76
+ typescript: {
77
+ tsconfigPath: './tsconfig.json',
78
+ },
79
+ },
80
+ {
81
+ ignores: ['**/dist/**', '**/node_modules/**', 'pnpm-lock.yaml', 'scripts/construct/*.workflow.mjs', '{{contractTypesOutput}}'],
82
+ },
83
+ ...dependencyBoundaries,
84
+ ...restrictedSyntax,
85
+ {
86
+ files: ['apps/*/src/server.ts', 'apps/*/src/scripts/**'],
87
+ rules: {
88
+ 'no-console': 'off',
89
+ },
90
+ },
91
+ {
92
+ files: ['**/*.test.ts'],
93
+ rules: {
94
+ 'ts/no-unsafe-assignment': 'off',
95
+ 'ts/no-unsafe-call': 'off',
96
+ 'ts/no-unsafe-member-access': 'off',
97
+ 'ts/no-floating-promises': 'off',
98
+ },
99
+ },
100
+ )
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "scripts": {
4
+ "dev": "pnpm --filter {{scope}}/api dev",
5
+ "build": "pnpm -r build"
6
+ },
7
+ "devDependencies": {
8
+ "@antfu/eslint-config": "catalog:",
9
+ "@redocly/cli": "catalog:",
10
+ "@types/node": "catalog:",
11
+ "eslint": "catalog:",
12
+ "openapi-typescript": "catalog:",
13
+ "tsx": "catalog:",
14
+ "typescript": "catalog:",
15
+ "vitest": "catalog:",
16
+ "yaml": "catalog:"
17
+ }
18
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{scope}}/shared",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "types": "./src/index.ts",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "catalog:"
21
+ }
22
+ }