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,81 @@
|
|
|
1
|
+
export interface paths {
|
|
2
|
+
"/health": {
|
|
3
|
+
parameters: {
|
|
4
|
+
query?: never;
|
|
5
|
+
header?: never;
|
|
6
|
+
path?: never;
|
|
7
|
+
cookie?: never;
|
|
8
|
+
};
|
|
9
|
+
/** Liveness */
|
|
10
|
+
get: operations["getHealth"];
|
|
11
|
+
put?: never;
|
|
12
|
+
post?: never;
|
|
13
|
+
delete?: never;
|
|
14
|
+
options?: never;
|
|
15
|
+
head?: never;
|
|
16
|
+
patch?: never;
|
|
17
|
+
trace?: never;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export type webhooks = Record<string, never>;
|
|
21
|
+
export interface components {
|
|
22
|
+
schemas: {
|
|
23
|
+
HealthResponse: {
|
|
24
|
+
/** @enum {string} */
|
|
25
|
+
status: "ok" | "error";
|
|
26
|
+
service: string;
|
|
27
|
+
/** Format: date-time */
|
|
28
|
+
time: string;
|
|
29
|
+
};
|
|
30
|
+
ErrorResponse: {
|
|
31
|
+
error: string;
|
|
32
|
+
message: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
responses: never;
|
|
36
|
+
parameters: never;
|
|
37
|
+
requestBodies: never;
|
|
38
|
+
headers: never;
|
|
39
|
+
pathItems: never;
|
|
40
|
+
}
|
|
41
|
+
export type $defs = Record<string, never>;
|
|
42
|
+
export interface operations {
|
|
43
|
+
getHealth: {
|
|
44
|
+
parameters: {
|
|
45
|
+
query?: never;
|
|
46
|
+
header?: never;
|
|
47
|
+
path?: never;
|
|
48
|
+
cookie?: never;
|
|
49
|
+
};
|
|
50
|
+
requestBody?: never;
|
|
51
|
+
responses: {
|
|
52
|
+
/** @description The service is up. */
|
|
53
|
+
200: {
|
|
54
|
+
headers: {
|
|
55
|
+
[name: string]: unknown;
|
|
56
|
+
};
|
|
57
|
+
content: {
|
|
58
|
+
"application/json": components["schemas"]["HealthResponse"];
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/** @description A dependency did not answer. */
|
|
62
|
+
503: {
|
|
63
|
+
headers: {
|
|
64
|
+
[name: string]: unknown;
|
|
65
|
+
};
|
|
66
|
+
content: {
|
|
67
|
+
"application/json": components["schemas"]["HealthResponse"];
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/** @description Unexpected error. */
|
|
71
|
+
default: {
|
|
72
|
+
headers: {
|
|
73
|
+
[name: string]: unknown;
|
|
74
|
+
};
|
|
75
|
+
content: {
|
|
76
|
+
"application/json": components["schemas"]["ErrorResponse"];
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type * from './api/openapi.js'
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
packages:
|
|
11
|
+
- apps/*
|
|
12
|
+
- packages/*
|
|
13
|
+
|
|
14
|
+
allowBuilds:
|
|
15
|
+
esbuild: true
|
|
16
|
+
|
|
17
|
+
catalog:
|
|
18
|
+
'@antfu/eslint-config': ^9.5.1
|
|
19
|
+
'@redocly/cli': ^2.52.0
|
|
20
|
+
'@types/express': ^4.17.25
|
|
21
|
+
'@types/node': ^{{nodeMajor}}.0.0
|
|
22
|
+
'@types/supertest': ^7.2.1
|
|
23
|
+
eslint: ^10.10.0
|
|
24
|
+
express: ^4.21.2
|
|
25
|
+
express-openapi-validator: ^5.6.2
|
|
26
|
+
openapi-typescript: ^7.13.0
|
|
27
|
+
supertest: ^7.2.2
|
|
28
|
+
tsx: ^4.23.13
|
|
29
|
+
typescript: ^5.9.3
|
|
30
|
+
vitest: ^5.0.0
|
|
31
|
+
yaml: ^2.9.1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"apps/*/src/**/*.ts",
|
|
5
|
+
"apps/*/tests/**/*.ts",
|
|
6
|
+
"packages/*/src/**/*.ts",
|
|
7
|
+
"packages/*/tests/**/*.ts",
|
|
8
|
+
"scripts/**/*.ts",
|
|
9
|
+
"vitest.config.ts"
|
|
10
|
+
],
|
|
11
|
+
"exclude": ["**/node_modules/**", "**/dist/**"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{scope}}/api",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx --watch src/server.ts",
|
|
8
|
+
"start": "node dist/server.js",
|
|
9
|
+
"build": "tsc -p tsconfig.build.json"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"{{scope}}/shared": "workspace:*",
|
|
13
|
+
"express": "catalog:",
|
|
14
|
+
"express-openapi-validator": "catalog:"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/express": "catalog:",
|
|
18
|
+
"@types/supertest": "catalog:",
|
|
19
|
+
"supertest": "catalog:",
|
|
20
|
+
"tsx": "catalog:",
|
|
21
|
+
"typescript": "catalog:"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ESLint } from 'eslint'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { REPO_ROOT } from '../../composition/files.js'
|
|
4
|
+
|
|
5
|
+
const PROCESS = 'MemberExpression[object.name="process"]'
|
|
6
|
+
const PROCESS_ENV = 'MemberExpression[object.name="process"][property.name="env"]'
|
|
7
|
+
const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
|
|
8
|
+
const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
|
|
9
|
+
const SHARED_IMPORT = ':matches(ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration)[source.value=/^{{scope}}\\/shared(\\/|$)/]'
|
|
10
|
+
|
|
11
|
+
const eslint = new ESLint({ cwd: REPO_ROOT })
|
|
12
|
+
|
|
13
|
+
async function restrictedSelectors(file: string): Promise<string[]> {
|
|
14
|
+
const config = await eslint.calculateConfigForFile(file)
|
|
15
|
+
const [, ...restrictions] = config.rules['no-restricted-syntax'] as [unknown, ...Array<{ selector: string }>]
|
|
16
|
+
return restrictions.map(restriction => restriction.selector).sort()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ROLES: Array<{ role: string, file: string, selectors: string[] }> = [
|
|
20
|
+
{ role: 'a service', file: 'apps/api/src/things/things.service.ts', selectors: [PROCESS, RAW_REQUEST_DATA, RAW_SQL, SHARED_IMPORT] },
|
|
21
|
+
{ role: 'a controller', file: 'apps/api/src/things/things.controller.ts', selectors: [PROCESS, RAW_SQL, SHARED_IMPORT] },
|
|
22
|
+
{ role: 'a middleware', file: 'apps/api/src/http/error-handler.middleware.ts', selectors: [PROCESS, RAW_SQL, SHARED_IMPORT] },
|
|
23
|
+
{ role: 'the contract types gateway', file: 'apps/api/src/contracts/types.ts', selectors: [PROCESS, RAW_REQUEST_DATA, RAW_SQL] },
|
|
24
|
+
{ role: 'the app config', file: 'apps/api/src/config.ts', selectors: [RAW_SQL, SHARED_IMPORT] },
|
|
25
|
+
{ role: 'the process entry', file: 'apps/api/src/server.ts', selectors: [RAW_SQL, SHARED_IMPORT] },
|
|
26
|
+
{ role: 'a package module', file: 'packages/shared/src/index.ts', selectors: [PROCESS_ENV, RAW_SQL] },
|
|
27
|
+
{ role: 'a package config', file: 'packages/shared/src/shared.config.ts', selectors: [RAW_SQL] },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
describe('syntax policy by file role', () => {
|
|
31
|
+
for (const { role, file, selectors } of ROLES) {
|
|
32
|
+
it(`keeps every restriction that applies to ${role}`, async () => {
|
|
33
|
+
expect(await restrictedSelectors(file)).toEqual([...selectors].sort())
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import antfu from '@antfu/eslint-config'
|
|
2
|
+
|
|
3
|
+
const PROCESS_MEMBER = 'MemberExpression[object.name="process"]'
|
|
4
|
+
const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
|
|
5
|
+
const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
|
|
6
|
+
|
|
7
|
+
const NO_PROCESS_OUTSIDE_CONFIG = {
|
|
8
|
+
selector: PROCESS_MEMBER,
|
|
9
|
+
message: 'src touches process only in config.ts: read configuration through readConfig() and pass the value on',
|
|
10
|
+
}
|
|
11
|
+
const NO_RAW_REQUEST_DATA = {
|
|
12
|
+
selector: RAW_REQUEST_DATA,
|
|
13
|
+
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',
|
|
14
|
+
}
|
|
15
|
+
const NO_RAW_SQL = {
|
|
16
|
+
selector: RAW_SQL,
|
|
17
|
+
message: 'SQL is never built from raw strings: interpolate tables and columns into the sql template instead of sql.raw',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function restrictSyntax(files, restrictions) {
|
|
21
|
+
return { files, rules: { 'no-restricted-syntax': ['error', ...restrictions] } }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ENVIRONMENT_READERS = ['src/config.ts', 'src/server.ts', 'src/scripts/**']
|
|
25
|
+
const HTTP_BOUNDARY = ['src/**/*.controller.ts', 'src/**/*.middleware.ts', 'src/http/**']
|
|
26
|
+
|
|
27
|
+
const dependencyPolicy = [
|
|
28
|
+
restrictSyntax(['src/**'], [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG, NO_RAW_REQUEST_DATA]),
|
|
29
|
+
restrictSyntax(HTTP_BOUNDARY, [NO_RAW_SQL, NO_PROCESS_OUTSIDE_CONFIG]),
|
|
30
|
+
restrictSyntax(ENVIRONMENT_READERS, [NO_RAW_SQL]),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export default antfu(
|
|
34
|
+
{
|
|
35
|
+
isInEditor: false,
|
|
36
|
+
typescript: {
|
|
37
|
+
tsconfigPath: './tsconfig.json',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
ignores: ['**/dist/**', '**/node_modules/**', 'pnpm-lock.yaml', 'scripts/construct/*.workflow.mjs', 'src/contracts/openapi.ts'],
|
|
42
|
+
},
|
|
43
|
+
...dependencyPolicy,
|
|
44
|
+
{
|
|
45
|
+
files: ['src/server.ts', 'src/scripts/**'],
|
|
46
|
+
rules: {
|
|
47
|
+
'no-console': 'off',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
files: ['**/*.test.ts'],
|
|
52
|
+
rules: {
|
|
53
|
+
'ts/no-unsafe-assignment': 'off',
|
|
54
|
+
'ts/no-unsafe-call': 'off',
|
|
55
|
+
'ts/no-unsafe-member-access': 'off',
|
|
56
|
+
'ts/no-floating-promises': 'off',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx --watch src/server.ts",
|
|
8
|
+
"start": "node dist/server.js",
|
|
9
|
+
"build": "tsc -p tsconfig.build.json"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"express": "^4.21.2",
|
|
13
|
+
"express-openapi-validator": "^5.6.2"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/express": "^4.17.25",
|
|
17
|
+
"@types/supertest": "^7.2.1",
|
|
18
|
+
"supertest": "^7.2.2"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface paths {
|
|
2
|
+
"/health": {
|
|
3
|
+
parameters: {
|
|
4
|
+
query?: never;
|
|
5
|
+
header?: never;
|
|
6
|
+
path?: never;
|
|
7
|
+
cookie?: never;
|
|
8
|
+
};
|
|
9
|
+
/** Liveness */
|
|
10
|
+
get: operations["getHealth"];
|
|
11
|
+
put?: never;
|
|
12
|
+
post?: never;
|
|
13
|
+
delete?: never;
|
|
14
|
+
options?: never;
|
|
15
|
+
head?: never;
|
|
16
|
+
patch?: never;
|
|
17
|
+
trace?: never;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export type webhooks = Record<string, never>;
|
|
21
|
+
export interface components {
|
|
22
|
+
schemas: {
|
|
23
|
+
HealthResponse: {
|
|
24
|
+
/** @enum {string} */
|
|
25
|
+
status: "ok" | "error";
|
|
26
|
+
service: string;
|
|
27
|
+
/** Format: date-time */
|
|
28
|
+
time: string;
|
|
29
|
+
};
|
|
30
|
+
ErrorResponse: {
|
|
31
|
+
error: string;
|
|
32
|
+
message: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
responses: never;
|
|
36
|
+
parameters: never;
|
|
37
|
+
requestBodies: never;
|
|
38
|
+
headers: never;
|
|
39
|
+
pathItems: never;
|
|
40
|
+
}
|
|
41
|
+
export type $defs = Record<string, never>;
|
|
42
|
+
export interface operations {
|
|
43
|
+
getHealth: {
|
|
44
|
+
parameters: {
|
|
45
|
+
query?: never;
|
|
46
|
+
header?: never;
|
|
47
|
+
path?: never;
|
|
48
|
+
cookie?: never;
|
|
49
|
+
};
|
|
50
|
+
requestBody?: never;
|
|
51
|
+
responses: {
|
|
52
|
+
/** @description The service is up. */
|
|
53
|
+
200: {
|
|
54
|
+
headers: {
|
|
55
|
+
[name: string]: unknown;
|
|
56
|
+
};
|
|
57
|
+
content: {
|
|
58
|
+
"application/json": components["schemas"]["HealthResponse"];
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/** @description A dependency did not answer. */
|
|
62
|
+
503: {
|
|
63
|
+
headers: {
|
|
64
|
+
[name: string]: unknown;
|
|
65
|
+
};
|
|
66
|
+
content: {
|
|
67
|
+
"application/json": components["schemas"]["HealthResponse"];
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/** @description Unexpected error. */
|
|
71
|
+
default: {
|
|
72
|
+
headers: {
|
|
73
|
+
[name: string]: unknown;
|
|
74
|
+
};
|
|
75
|
+
content: {
|
|
76
|
+
"application/json": components["schemas"]["ErrorResponse"];
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ESLint } from 'eslint'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { REPO_ROOT } from '../../composition/files.js'
|
|
4
|
+
|
|
5
|
+
const PROCESS = 'MemberExpression[object.name="process"]'
|
|
6
|
+
const RAW_REQUEST_DATA = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
|
|
7
|
+
const RAW_SQL = 'CallExpression[callee.object.name="sql"][callee.property.name="raw"]'
|
|
8
|
+
|
|
9
|
+
const eslint = new ESLint({ cwd: REPO_ROOT })
|
|
10
|
+
|
|
11
|
+
async function restrictedSelectors(file: string): Promise<string[]> {
|
|
12
|
+
const config = await eslint.calculateConfigForFile(file)
|
|
13
|
+
const [, ...restrictions] = config.rules['no-restricted-syntax'] as [unknown, ...Array<{ selector: string }>]
|
|
14
|
+
return restrictions.map(restriction => restriction.selector).sort()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ROLES: Array<{ role: string, file: string, selectors: string[] }> = [
|
|
18
|
+
{ role: 'a service', file: 'src/things/things.service.ts', selectors: [PROCESS, RAW_REQUEST_DATA, RAW_SQL] },
|
|
19
|
+
{ role: 'a controller', file: 'src/things/things.controller.ts', selectors: [PROCESS, RAW_SQL] },
|
|
20
|
+
{ role: 'a middleware', file: 'src/http/error-handler.middleware.ts', selectors: [PROCESS, RAW_SQL] },
|
|
21
|
+
{ role: 'the app config', file: 'src/config.ts', selectors: [RAW_SQL] },
|
|
22
|
+
{ role: 'the process entry', file: 'src/server.ts', selectors: [RAW_SQL] },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
describe('syntax policy by file role', () => {
|
|
26
|
+
for (const { role, file, selectors } of ROLES) {
|
|
27
|
+
it(`keeps every restriction that applies to ${role}`, async () => {
|
|
28
|
+
expect(await restrictedSelectors(file)).toEqual([...selectors].sort())
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.css"
|
|
4
|
+
- "**/*.vue"
|
|
5
|
+
- "**/*.astro"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# CSS
|
|
9
|
+
|
|
10
|
+
Modern, platform-native CSS. No preprocessor — nesting, custom properties, colour functions and
|
|
11
|
+
container queries are all in the language now.
|
|
12
|
+
|
|
13
|
+
## State as attributes, variants as custom properties
|
|
14
|
+
|
|
15
|
+
- **Everything that crosses from JS into CSS crosses as an attribute.** Templates and scripts toggle
|
|
16
|
+
`data-*`, or an ARIA attribute where one already carries the meaning (`aria-expanded`,
|
|
17
|
+
`aria-pressed`, `aria-checked`, `[hidden]`). JS does not add or remove styling classes and does not
|
|
18
|
+
write inline styles for state.
|
|
19
|
+
- **An attribute selector re-binds custom properties; it does not restate declarations.** The block
|
|
20
|
+
declares its private `--_*` properties and consumes them once; each variant only changes the values.
|
|
21
|
+
- Classes name *things* — a component, a layout primitive, a utility — and stay where they are genuinely
|
|
22
|
+
useful as hooks. They never encode state or variant: no `--modifier` classes, no new `.is-*`.
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
/* ✅ */
|
|
26
|
+
.pill {
|
|
27
|
+
--_bg: var(--color-surface-container-high);
|
|
28
|
+
--_fg: var(--color-on-surface);
|
|
29
|
+
--_bd: transparent;
|
|
30
|
+
|
|
31
|
+
background: var(--_bg);
|
|
32
|
+
color: var(--_fg);
|
|
33
|
+
border: 1px solid var(--_bd);
|
|
34
|
+
|
|
35
|
+
&[data-tone='info'] { --_bg: var(--color-info-bg); --_fg: var(--color-info); }
|
|
36
|
+
&[data-outlined] { --_bg: transparent; --_bd: var(--color-primary); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ❌ modifier classes toggled from JS, declarations restated per variant */
|
|
40
|
+
.pill--info { background: …; color: …; border: …; }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Nesting
|
|
44
|
+
|
|
45
|
+
Native CSS nesting with `&`. Nest to express relationship — variants, states, parts — not to mirror
|
|
46
|
+
the DOM tree depth-for-depth.
|
|
47
|
+
|
|
48
|
+
## Container queries over media queries
|
|
49
|
+
|
|
50
|
+
- `@media` is for **preferences and devices**: `prefers-reduced-motion`, `pointer: coarse`, print. A
|
|
51
|
+
width-based `@media` is an anti-pattern — a component should react to the space it is in, not to the
|
|
52
|
+
viewport.
|
|
53
|
+
- Use `@container` with range syntax: `@container (inline-size <= 48rem) { … }`. Keep the breakpoints
|
|
54
|
+
as rem tokens (`--bp-cq-md: 48rem`). Name a container (`container: split-card / inline-size`) once
|
|
55
|
+
more than one rule targets it.
|
|
56
|
+
- Use **style container queries** when a parent decides a child's layout mode: the host sets
|
|
57
|
+
`container-type: inline-size` and stamps a custom property; descendants read it.
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
.u-section { container-type: inline-size; --cq-size: lg; }
|
|
61
|
+
@container (inline-size <= 48rem) { .u-section > * { --cq-size: sm; } }
|
|
62
|
+
@container style(--cq-size: lg) { .bento-grid { grid-template-columns: repeat(3, 1fr); } }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Theming is `color-scheme: light dark` plus `light-dark()`, not `@media (prefers-color-scheme)`.
|
|
66
|
+
|
|
67
|
+
## Logical properties
|
|
68
|
+
|
|
69
|
+
`inline-size` / `block-size`, `min-` and `max-inline-size`, `padding-inline` / `padding-block`,
|
|
70
|
+
`margin-inline` / `margin-block`, `inset-block-start` / `inset-inline-start`, `border-inline`,
|
|
71
|
+
`text-align: start`. Physical `width` / `height` / `top` / `left` survive only where the value really is
|
|
72
|
+
physical: SVG geometry attributes, hairlines, `minmax()` track sizes.
|
|
73
|
+
|
|
74
|
+
## Units — relative by default, and the right relative one
|
|
75
|
+
|
|
76
|
+
- `rem` for length and spacing; `em` for what should scale with the element's own font size (icon
|
|
77
|
+
`inline-size: 1em`, letter-spacing); `ch` for measure (`--body-max-inline-size: 65ch`); `lh` for
|
|
78
|
+
vertical rhythm (`padding: 0.25lh 1ch`); `%` / `fr` / `vi` where that *is* the meaning.
|
|
79
|
+
- `px` only for hairlines, shadow offsets and 1px-scale detail — never for spacing or type.
|
|
80
|
+
- Fluid type and space come from `clamp()` on a scale, not from size swaps at breakpoints.
|
|
81
|
+
- This is a default, not a purity test: pick the unit that expresses the intent.
|
|
82
|
+
|
|
83
|
+
## Custom properties and tokens
|
|
84
|
+
|
|
85
|
+
Colour, space, radius, duration and z-index come from tokens — a raw value in a component is a missing
|
|
86
|
+
token. `--_name` marks a component-private property. Derive colours with relative colour syntax
|
|
87
|
+
(`oklch(from var(--color-primary) calc(l + 0.08) c h)`) rather than hand-picking shades.
|
|
88
|
+
|
|
89
|
+
## Smart layouts
|
|
90
|
+
|
|
91
|
+
Layout is a small set of reusable primitives that respond to available space **intrinsically** — no
|
|
92
|
+
breakpoints, no per-component grid code. Compose pages from them; when none fits, add a primitive to
|
|
93
|
+
the shared stylesheet rather than a one-off grid to a component.
|
|
94
|
+
|
|
95
|
+
- **Page shell, named grid lines.** One grid declares `[full-start] … [popout-start] …
|
|
96
|
+
[content-start] … [narrow-start] …` tracks; children default to `grid-column: content` and opt out
|
|
97
|
+
with `.layout-full` / `.layout-popout` / `.layout-narrow`. Full-bleed comes from the shell, never
|
|
98
|
+
from negative margins or `100vw`.
|
|
99
|
+
- **Card grids, `auto-fit` / `auto-fill` + `minmax`.**
|
|
100
|
+
`grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr))`. The inner
|
|
101
|
+
`min(…, 100%)` is what stops it overflowing a narrow container — it is required, not decoration.
|
|
102
|
+
`auto-fit` when the row should stretch to fill, `auto-fill` when empty tracks must be kept.
|
|
103
|
+
- **Parameterised by custom properties and `data-*`, never by variant classes.** A caller retunes a
|
|
104
|
+
primitive by setting `--card-min-width` / `--column-inline-size` / `--grid-gap`, or by stamping an
|
|
105
|
+
attribute the CSS reads directly.
|
|
106
|
+
|
|
107
|
+
```css
|
|
108
|
+
.grid-auto-fit-max-column-count {
|
|
109
|
+
--_max-cols: 3;
|
|
110
|
+
--_max-cols: attr(data-max-cols type(<number>), 3);
|
|
111
|
+
--_min-col: var(--column-inline-size, var(--card-min-width));
|
|
112
|
+
--_col-size: calc((100% - (var(--_max-cols) - 1) * var(--grid-gap)) / var(--_max-cols));
|
|
113
|
+
|
|
114
|
+
display: grid;
|
|
115
|
+
gap: var(--grid-gap);
|
|
116
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--_min-col), var(--_col-size))), 1fr));
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<ul class="grid-auto-fit-max-column-count" data-max-cols="4">
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
(The plain `--_max-cols: 3` line above the typed `attr()` is the fallback for engines without typed
|
|
125
|
+
`attr()`; keep both.)
|
|
126
|
+
|
|
127
|
+
- **Line layouts.** One wrapping flex primitive — `display: flex; flex-wrap: wrap; gap: var(--_gap);
|
|
128
|
+
align-items: center` with `--_gap` overridable — before writing another one-off flex row.
|
|
129
|
+
- **Mode switches.** Where a layout must change *shape* rather than reflow, the host stamps `--cq-size`
|
|
130
|
+
and the shape lives behind `@container style(--cq-size: lg)`.
|
|
131
|
+
- `gap` is the spacing mechanism; margins between siblings are a smell. `subgrid` when a child must
|
|
132
|
+
align to an ancestor's tracks. Respect a project's `@layer` order and put a component's styles in the
|
|
133
|
+
layer its siblings use.
|
|
134
|
+
|
|
135
|
+
## Animations
|
|
136
|
+
|
|
137
|
+
Keyframe animations use **dashed idents** with an `--animation-` prefix.
|
|
138
|
+
|
|
139
|
+
```css
|
|
140
|
+
/* ✅ */
|
|
141
|
+
@keyframes --animation-charities-skeleton-pulse { /* … */ }
|
|
142
|
+
.el {
|
|
143
|
+
animation: --animation-charities-skeleton-pulse 1.4s ease-in-out infinite;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ❌ bare names */
|
|
147
|
+
@keyframes charities-skeleton-pulse { /* … */ }
|
|
148
|
+
.el {
|
|
149
|
+
animation: fade-in 0.3s ease;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- Declare as `@keyframes --animation-<descriptive-name>`, reference via
|
|
154
|
+
`animation: --animation-<descriptive-name> …` or `animation-name: --animation-<descriptive-name>`.
|
|
155
|
+
- `animation: none` is fine.
|
|
156
|
+
- This is only about `@keyframes` / `animation-name`. Do **not** rename transition tokens
|
|
157
|
+
(`--transition-base` and friends).
|
|
158
|
+
- Applies to `.css` files and to `<style>` blocks in `.astro` / `.vue` components alike.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import antfu from '@antfu/eslint-config'
|
|
2
|
+
|
|
3
|
+
const CLASS_LIST_MUTATION = 'CallExpression[callee.object.property.name="classList"][callee.property.name=/^(add|remove|toggle|replace)$/]'
|
|
4
|
+
const INLINE_STYLE_WRITE = 'AssignmentExpression[left.object.property.name="style"]'
|
|
5
|
+
const INLINE_STYLE_PROPERTY = 'CallExpression[callee.object.property.name="style"][callee.property.name=/^(setProperty|removeProperty)$/]'
|
|
6
|
+
|
|
7
|
+
const NO_STATE_CLASSES = {
|
|
8
|
+
selector: CLASS_LIST_MUTATION,
|
|
9
|
+
message: 'State crosses into CSS as a data-* or ARIA attribute; classes name things, never state',
|
|
10
|
+
}
|
|
11
|
+
const NO_INLINE_STYLES = {
|
|
12
|
+
selector: `${INLINE_STYLE_WRITE}, ${INLINE_STYLE_PROPERTY}`,
|
|
13
|
+
message: 'Scripts do not write inline styles: set a data-* attribute and let the stylesheet re-bind custom properties',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function restrictSyntax(files, ignores, restrictions) {
|
|
17
|
+
return { files, ignores, rules: { 'no-restricted-syntax': ['error', ...restrictions] } }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const stylingPolicy = [
|
|
21
|
+
restrictSyntax(['src/**'], [], [NO_STATE_CLASSES, NO_INLINE_STYLES]),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export default antfu(
|
|
25
|
+
{
|
|
26
|
+
isInEditor: false,
|
|
27
|
+
typescript: {
|
|
28
|
+
tsconfigPath: './tsconfig.json',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
ignores: ['**/dist/**', '**/node_modules/**', 'pnpm-lock.yaml', 'scripts/construct/*.workflow.mjs'],
|
|
33
|
+
},
|
|
34
|
+
...stylingPolicy,
|
|
35
|
+
{
|
|
36
|
+
files: ['**/*.test.ts'],
|
|
37
|
+
rules: {
|
|
38
|
+
'ts/no-unsafe-assignment': 'off',
|
|
39
|
+
'ts/no-unsafe-call': 'off',
|
|
40
|
+
'ts/no-unsafe-member-access': 'off',
|
|
41
|
+
'ts/no-floating-promises': 'off',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"happy-dom": "^20.13.0",
|
|
13
|
+
"vite": "^8.2.0"
|
|
14
|
+
}
|
|
15
|
+
}
|