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.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/cli.js +1492 -0
- package/package.json +73 -0
- package/templates/ai/claude/CLAUDE.md.eta +18 -0
- package/templates/ai/claude/CLAUDE.md.existing.eta +15 -0
- package/templates/ai/claude/_claude/agents/architect.md +38 -0
- package/templates/ai/claude/_claude/agents/harness.md +40 -0
- package/templates/ai/claude/_claude/agents/implementer.md +40 -0
- package/templates/ai/claude/_claude/commands/plan.md +24 -0
- package/templates/ai/claude/_claude/skills/implement/SKILL.md +42 -0
- package/templates/ai/claude/scripts/construct/implement.workflow.mjs +185 -0
- package/templates/ai/cursor/_cursor/rules/construct.mdc +19 -0
- package/templates/ai/review/_github/workflows/claude-review.yml.eta +51 -0
- package/templates/ai/shared/AGENTS.md.eta +123 -0
- package/templates/ai/shared/AGENTS.md.existing.eta +50 -0
- package/templates/ai/shared/_claude/commands/construct-discover.md +75 -0
- package/templates/ai/shared/_claude/rules/conventions.md +63 -0
- package/templates/ai/shared/_claude/rules/secrets.md +8 -0
- package/templates/ai/shared/_claude/rules/tests.md +6 -0
- package/templates/base/_editorconfig +12 -0
- package/templates/base/_github/workflows/security.yml +41 -0
- package/templates/base/_gitignore +11 -0
- package/templates/base/_gitleaks.toml +2 -0
- package/templates/base/_nvmrc.eta +1 -0
- package/templates/base/_vscode/settings.json +22 -0
- package/templates/base/architecture/checklists.md +42 -0
- package/templates/base/architecture/principles.md +113 -0
- package/templates/base/architecture/security-invariants.md +17 -0
- package/templates/harness/_github/workflows/ci.yml.eta +31 -0
- package/templates/harness/eslint.config.mjs +13 -0
- package/templates/harness/package.json.eta +29 -0
- package/templates/harness/pnpm-workspace.yaml +11 -0
- package/templates/harness/scripts/composition/check.ts +19 -0
- package/templates/harness/scripts/composition/files.ts +34 -0
- package/templates/harness/scripts/composition/model.ts +137 -0
- package/templates/harness/scripts/composition/render.ts +72 -0
- package/templates/harness/scripts/composition/sync-docs.ts +11 -0
- package/templates/harness/scripts/tests/composition/files.test.ts +16 -0
- package/templates/harness/scripts/tests/composition/model.test.ts +41 -0
- package/templates/harness/scripts/tests/composition/render.test.ts +54 -0
- package/templates/harness/tsconfig.base.json +14 -0
- package/templates/harness/tsconfig.json +5 -0
- package/templates/harness/vitest.config.ts +8 -0
- package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +100 -0
- package/templates/presets/monorepo/baseline/package.json.eta +18 -0
- package/templates/presets/monorepo/baseline/packages/shared/package.json.eta +22 -0
- package/templates/presets/monorepo/baseline/packages/shared/src/api/openapi.ts +81 -0
- package/templates/presets/monorepo/baseline/packages/shared/src/index.ts +1 -0
- package/templates/presets/monorepo/baseline/packages/shared/tsconfig.build.json +10 -0
- package/templates/presets/monorepo/baseline/packages/shared/tsconfig.json +4 -0
- package/templates/presets/monorepo/baseline/pnpm-workspace.yaml.eta +31 -0
- package/templates/presets/monorepo/baseline/tsconfig.json +12 -0
- package/templates/presets/monorepo/baseline/vitest.config.ts +8 -0
- package/templates/presets/monorepo/sample/apps/api/package.json.eta +23 -0
- package/templates/presets/monorepo/sample/apps/api/tsconfig.build.json +10 -0
- package/templates/presets/monorepo/sample/apps/api/tsconfig.json +4 -0
- package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +36 -0
- package/templates/presets/node-backend/baseline/eslint.config.mjs +59 -0
- package/templates/presets/node-backend/baseline/package.json.eta +20 -0
- package/templates/presets/node-backend/baseline/src/contracts/openapi.ts +81 -0
- package/templates/presets/node-backend/baseline/tsconfig.build.json +10 -0
- package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +31 -0
- package/templates/presets/node-frontend/baseline/_claude/rules/css.md +158 -0
- package/templates/presets/node-frontend/baseline/eslint.config.mjs +44 -0
- package/templates/presets/node-frontend/baseline/package.json.eta +15 -0
- package/templates/presets/node-frontend/baseline/tsconfig.json +11 -0
- package/templates/presets/node-frontend/baseline/vitest.config.ts +10 -0
- package/templates/presets/node-frontend/sample/architecture/app.md +37 -0
- package/templates/presets/node-frontend/sample/architecture/composition/app.yaml +41 -0
- package/templates/presets/node-frontend/sample/index.html.eta +12 -0
- package/templates/presets/node-frontend/sample/src/app.ts +32 -0
- package/templates/presets/node-frontend/sample/src/main.ts +9 -0
- package/templates/presets/node-frontend/sample/src/styles/app.css +43 -0
- package/templates/presets/node-frontend/sample/src/styles/tokens.css +16 -0
- package/templates/presets/node-frontend/sample/tests/app.test.ts +21 -0
- package/templates/stacks/express-api/app/src/app.ts +27 -0
- package/templates/stacks/express-api/app/src/config.ts.eta +30 -0
- package/templates/stacks/express-api/app/src/contracts/types.ts.eta +1 -0
- package/templates/stacks/express-api/app/src/health/health.controller.ts +12 -0
- package/templates/stacks/express-api/app/src/health/health.service.ts +23 -0
- package/templates/stacks/express-api/app/src/http/api-contract.middleware.ts +13 -0
- package/templates/stacks/express-api/app/src/http/app-error.ts +14 -0
- package/templates/stacks/express-api/app/src/http/async-handler.middleware.ts +7 -0
- package/templates/stacks/express-api/app/src/http/error-handler.middleware.ts +50 -0
- package/templates/stacks/express-api/app/src/server.ts +9 -0
- package/templates/stacks/express-api/app/tests/health.test.ts +28 -0
- package/templates/stacks/express-api/app/tests/http/error-handler.middleware.test.ts +48 -0
- package/templates/stacks/express-api/repo/architecture/composition/http.yaml.eta +45 -0
- package/templates/stacks/express-api/repo/architecture/http.md +39 -0
- package/templates/stacks/http-contract/_github/workflows/api-contract.yml +36 -0
- package/templates/stacks/http-contract/contracts/api/openapi.yaml.eta +62 -0
- package/templates/stacks/http-contract/package.json.eta +13 -0
- package/templates/stacks/http-contract/redocly.yaml.eta +8 -0
- package/templates/stacks/http-contract/scripts/contracts/types.mjs.eta +20 -0
- 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,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,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
|
+
}
|