mikoshi-construct 0.1.3 → 0.2.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/README.md +16 -4
- package/dist/cli.js +981 -184
- package/package.json +3 -2
- package/templates/ai/claude/_claude/agents/architect.md +7 -11
- package/templates/ai/claude/_claude/agents/harness.md +7 -11
- package/templates/ai/claude/_claude/agents/implementer.md +6 -10
- package/templates/ai/claude/_claude/skills/implement/SKILL.md +28 -7
- package/templates/ai/claude/scripts/construct/implement.workflow.mjs +53 -9
- package/templates/ai/cursor/_cursor/rules/construct.mdc +1 -1
- package/templates/ai/shared/_claude/commands/construct-discover.md +25 -4
- package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +22 -6
- package/templates/presets/monorepo/baseline/scripts/tests/lint/syntax-policy.test.ts.eta +110 -0
- package/templates/presets/node-backend/baseline/eslint.config.mjs +11 -3
- package/templates/presets/node-backend/baseline/scripts/tests/lint/syntax-policy.test.ts +86 -0
- package/templates/presets/node-frontend/baseline/eslint.config.mjs +12 -3
- package/templates/presets/node-frontend/baseline/scripts/tests/lint/syntax-policy.test.ts +78 -0
- package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +0 -36
- package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +0 -31
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikoshi-construct",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "Bootstrap for AI-native software projects. Start with a proven engineering workflow instead of an empty repository.",
|
|
6
6
|
"author": "Eli Tabrisov",
|
|
7
7
|
"license": "MIT",
|
|
@@ -60,9 +60,10 @@
|
|
|
60
60
|
"typecheck": "tsc --noEmit",
|
|
61
61
|
"test": "vitest run",
|
|
62
62
|
"test:watch": "vitest",
|
|
63
|
-
"quality": "pnpm composition:check && pnpm lint && pnpm typecheck && pnpm test",
|
|
63
|
+
"quality": "pnpm composition:check && pnpm privacy:check && pnpm lint && pnpm typecheck && pnpm test",
|
|
64
64
|
"composition:render": "tsx scripts/composition/sync-docs.ts",
|
|
65
65
|
"composition:check": "tsx scripts/composition/check.ts",
|
|
66
|
+
"privacy:check": "tsx scripts/privacy/check.ts",
|
|
66
67
|
"ci": "pnpm run quality",
|
|
67
68
|
"changeset": "changeset",
|
|
68
69
|
"version-packages": "changeset version",
|
|
@@ -24,15 +24,11 @@ Work through, in this order, and write nothing down until you have:
|
|
|
24
24
|
5. The explicit constraints the implementer must respect, and the acceptance criteria that make
|
|
25
25
|
the task statable.
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Return these fields; the runtime validates the shape against the schema it gives you.
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"acceptance": ["one verifiable criterion per entry"],
|
|
36
|
-
"files": ["files to create or change"]
|
|
37
|
-
}
|
|
38
|
-
```
|
|
29
|
+
- `decision` — the design in a few sentences, including what stays unchanged and why.
|
|
30
|
+
- `contractChanges` — operations or schemas to add or change in the API contract, empty when none.
|
|
31
|
+
- `compositionChanges` — composition model nodes or edges to change, empty when none.
|
|
32
|
+
- `constraints` — one constraint per entry, each one the implementer must respect.
|
|
33
|
+
- `acceptance` — one verifiable criterion per entry.
|
|
34
|
+
- `files` — the files to create or change.
|
|
@@ -26,15 +26,11 @@ When git is usable, also inspect `git diff` (staged and unstaged) for:
|
|
|
26
26
|
Report the security leg separately from the rest when a failure comes from a security lint rule,
|
|
27
27
|
a contract security test or the secret scan, so the reader sees the invariant, not just the tool.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Return these fields; the runtime validates the shape against the schema it gives you.
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"testsWeakened": false,
|
|
38
|
-
"contractChanged": false
|
|
39
|
-
}
|
|
40
|
-
```
|
|
31
|
+
- `passed` — whether every harness command succeeded.
|
|
32
|
+
- `failureExcerpt` — the failing command and its last relevant lines, empty when passed.
|
|
33
|
+
- `securityFinding` — the invariant that failed, empty when none.
|
|
34
|
+
- `diffStat` — the output of `git diff --stat`, or why git could not be used.
|
|
35
|
+
- `testsWeakened` — whether a test was deleted, renamed away, skipped or narrowed.
|
|
36
|
+
- `contractChanged` — whether a file under `contracts/` changed.
|
|
@@ -27,14 +27,10 @@ Constraints at this effort level:
|
|
|
27
27
|
If the task is ambiguous about a contract, a boundary or which of two designs is meant, stop and
|
|
28
28
|
report `blocked` with one precise question. Do not pick one.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Return these fields; the runtime validates the shape against the schema it gives you.
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"harnessTail": "last lines of the harness output, empty when blocked",
|
|
38
|
-
"question": "the single question when blocked, otherwise empty"
|
|
39
|
-
}
|
|
40
|
-
```
|
|
32
|
+
- `status` — `done`, `failed` or `blocked`.
|
|
33
|
+
- `summary` — one or two sentences on what changed.
|
|
34
|
+
- `files` — the paths you changed.
|
|
35
|
+
- `harnessTail` — the last lines of the harness output, empty when blocked.
|
|
36
|
+
- `question` — the single question when blocked, otherwise empty.
|
|
@@ -23,14 +23,35 @@ repository's CLAUDE.md and `construct.json`.
|
|
|
23
23
|
a JSON object:
|
|
24
24
|
`{ "task": ..., "acceptance": [...], "effort": "low|medium|high", "harness": { "command": ..., "extra": [...] } }`
|
|
25
25
|
where `harness.command` comes from `construct.json` and `harness.extra` lists any area-specific
|
|
26
|
-
commands CLAUDE.md names for the files the task touches (usually empty).
|
|
27
|
-
|
|
26
|
+
commands CLAUDE.md names for the files the task touches (usually empty). `retryLimit` is optional
|
|
27
|
+
and defaults to `0`: a rejected response is not re-asked, and the run stops with the validator's
|
|
28
|
+
error so a person reads it. Each retry is a whole new agent call that repeats the agent's
|
|
29
|
+
exploration from scratch — measured at roughly three million billable tokens for an architect —
|
|
30
|
+
and it cannot fix a contradiction in the task, because the agent may not change the task. Raise it
|
|
31
|
+
only when a rejected response is expected to be a transient shape error rather than a bad brief.
|
|
32
|
+
The user's `/implement` invocation is the opt-in the tool requires. Note the run identifier the
|
|
33
|
+
Workflow tool reports when it launches the run and again when it completes; step 4 records it.
|
|
28
34
|
4. Record the run: append one JSON line to `.construct/runs.jsonl` (create the directory if needed)
|
|
29
|
-
with
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
with exactly these fields and no others:
|
|
36
|
+
- `run` — the Workflow run identifier from step 3. It is the key `construct cost` joins the entry
|
|
37
|
+
to the runtime's session data on. Never invent one: an entry without it is reported as
|
|
38
|
+
unjoinable, which is the truth about it.
|
|
39
|
+
- `at` — the ISO timestamp.
|
|
40
|
+
- `task` — the task text, first 120 characters.
|
|
41
|
+
- `effort` — the class you chose in step 1.
|
|
42
|
+
- `status` — `done`, `failed` or `blocked`, from the result.
|
|
43
|
+
- `rung` — the effort of the rung that finished: `effort` from the result when it is `done`,
|
|
44
|
+
otherwise the `effort` of the last entry in `attempts`.
|
|
45
|
+
- `attempts` — the result's `attempts` array verbatim; each entry carries its `rung`, `effort`,
|
|
46
|
+
`outcome` and the `reason` that separates an invalid response shape from a red harness from a
|
|
47
|
+
blocked report.
|
|
48
|
+
- `agents`, `tokens`, `toolUses`, `seconds` — the Workflow tool's own accounting for the run,
|
|
49
|
+
exactly as it reported it. Write `"unknown"` for a token figure it did not report, never `0`.
|
|
50
|
+
The ledger carries counts and reasons only — never a prompt, a response or any other message
|
|
51
|
+
content. This log is what tunes the ladder later; workflow scripts have no filesystem access, so
|
|
52
|
+
it is written here, not by the script. Nothing enforces this step: the ledger is L0, and
|
|
53
|
+
`construct cost` reconciles it against the runtime instead of trusting it. `.construct/` is
|
|
54
|
+
gitignored.
|
|
34
55
|
5. Relay the result: status, the effort rung that succeeded and how many attempts it took, the
|
|
35
56
|
files changed, and the harness tail. When the status is `blocked`, put the architect's or
|
|
36
57
|
implementer's question to the user verbatim. When `failed`, give the last failure excerpt.
|
|
@@ -52,10 +52,46 @@ const SPEC = {
|
|
|
52
52
|
},
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const DEFAULT_RETRY_LIMIT = 0
|
|
56
|
+
|
|
55
57
|
const task = args.task
|
|
56
58
|
const acceptance = args.acceptance ?? []
|
|
57
59
|
const harness = { command: 'pnpm run quality', extra: [], ...(args.harness ?? {}) }
|
|
58
60
|
const rungs = LADDERS[args.effort] ?? LADDERS.low
|
|
61
|
+
const retryLimit = Number.isInteger(args.retryLimit) && args.retryLimit >= 0 ? args.retryLimit : DEFAULT_RETRY_LIMIT
|
|
62
|
+
|
|
63
|
+
let lastValidationError = null
|
|
64
|
+
|
|
65
|
+
function retryPrompt(prompt, validationError) {
|
|
66
|
+
return `${prompt}\n\nThe previous response did not match the shape the runtime validates. The validator reported:\n${validationError}\n\nReturn the same fields again with that corrected.`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function askOnce(prompt, options) {
|
|
70
|
+
try {
|
|
71
|
+
const value = await agent(prompt, options)
|
|
72
|
+
return value == null
|
|
73
|
+
? { value: null, validationError: 'the agent returned no object the schema could validate' }
|
|
74
|
+
: { value, validationError: null }
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return { value: null, validationError: String(error?.message ?? error) }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ask(prompt, options) {
|
|
82
|
+
let validationError = null
|
|
83
|
+
for (let attempt = 0; attempt <= retryLimit; attempt++) {
|
|
84
|
+
const answer = await askOnce(validationError == null ? prompt : retryPrompt(prompt, validationError), options)
|
|
85
|
+
if (answer.value != null) {
|
|
86
|
+
lastValidationError = null
|
|
87
|
+
return answer.value
|
|
88
|
+
}
|
|
89
|
+
validationError = answer.validationError
|
|
90
|
+
log(`${options.label}: response rejected by the schema — ${validationError}`)
|
|
91
|
+
}
|
|
92
|
+
lastValidationError = validationError
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
59
95
|
|
|
60
96
|
function harnessPrompt() {
|
|
61
97
|
return [
|
|
@@ -94,7 +130,7 @@ const attempts = []
|
|
|
94
130
|
|
|
95
131
|
if (args.effort === 'high') {
|
|
96
132
|
phase('Design')
|
|
97
|
-
spec = await
|
|
133
|
+
spec = await ask(architectPrompt('The task is classified as high effort; design it before any implementation.'), {
|
|
98
134
|
agentType: 'architect',
|
|
99
135
|
effort: 'xhigh',
|
|
100
136
|
phase: 'Design',
|
|
@@ -107,7 +143,7 @@ for (const [index, effort] of rungs.entries()) {
|
|
|
107
143
|
const rung = index + 1
|
|
108
144
|
phase('Implement')
|
|
109
145
|
log(`rung ${rung}/${rungs.length} @ ${effort}: implementing`)
|
|
110
|
-
const report = await
|
|
146
|
+
const report = await ask(implementerPrompt(spec, feedback), {
|
|
111
147
|
agentType: 'implementer',
|
|
112
148
|
effort,
|
|
113
149
|
phase: 'Implement',
|
|
@@ -115,15 +151,15 @@ for (const [index, effort] of rungs.entries()) {
|
|
|
115
151
|
schema: REPORT,
|
|
116
152
|
})
|
|
117
153
|
if (report == null) {
|
|
118
|
-
attempts.push({ rung, effort, outcome: '
|
|
154
|
+
attempts.push({ rung, effort, outcome: 'schema invalid', reason: lastValidationError })
|
|
119
155
|
continue
|
|
120
156
|
}
|
|
121
157
|
|
|
122
158
|
if (report.status === 'blocked') {
|
|
123
|
-
attempts.push({ rung, effort, outcome: 'blocked', question: report.question })
|
|
159
|
+
attempts.push({ rung, effort, outcome: 'blocked', reason: report.question, question: report.question })
|
|
124
160
|
if (rung === rungs.length)
|
|
125
161
|
return { status: 'blocked', question: report.question, attempts }
|
|
126
|
-
spec = await
|
|
162
|
+
spec = await ask(architectPrompt(`The implementer stopped on this question:\n${report.question}`), {
|
|
127
163
|
agentType: 'architect',
|
|
128
164
|
effort: 'xhigh',
|
|
129
165
|
phase: 'Design',
|
|
@@ -137,7 +173,7 @@ for (const [index, effort] of rungs.entries()) {
|
|
|
137
173
|
|
|
138
174
|
phase('Verify')
|
|
139
175
|
log(`rung ${rung}/${rungs.length} @ ${effort}: running ${harness.command}`)
|
|
140
|
-
const verdict = await
|
|
176
|
+
const verdict = await ask(harnessPrompt(), {
|
|
141
177
|
agentType: 'harness',
|
|
142
178
|
effort: 'low',
|
|
143
179
|
phase: 'Verify',
|
|
@@ -145,7 +181,15 @@ for (const [index, effort] of rungs.entries()) {
|
|
|
145
181
|
schema: VERDICT,
|
|
146
182
|
})
|
|
147
183
|
const passed = verdict?.passed === true && verdict.testsWeakened === false
|
|
148
|
-
attempts.push(
|
|
184
|
+
attempts.push(verdict == null
|
|
185
|
+
? { rung, effort, outcome: 'schema invalid', reason: lastValidationError, securityFinding: '' }
|
|
186
|
+
: {
|
|
187
|
+
rung,
|
|
188
|
+
effort,
|
|
189
|
+
outcome: passed ? 'passed' : 'harness failed',
|
|
190
|
+
reason: passed ? '' : (verdict.testsWeakened ? 'a test was deleted, skipped or narrowed' : verdict.failureExcerpt),
|
|
191
|
+
securityFinding: verdict.securityFinding ?? '',
|
|
192
|
+
})
|
|
149
193
|
log(`rung ${rung} @ ${effort}: ${passed ? 'harness passed' : 'harness failed'}`)
|
|
150
194
|
|
|
151
195
|
if (passed) {
|
|
@@ -162,7 +206,7 @@ for (const [index, effort] of rungs.entries()) {
|
|
|
162
206
|
}
|
|
163
207
|
|
|
164
208
|
feedback = verdict == null
|
|
165
|
-
?
|
|
209
|
+
? `The harness produced no verdict the schema could validate: ${lastValidationError}`
|
|
166
210
|
: verdict.testsWeakened
|
|
167
211
|
? `A test was deleted, skipped or narrowed. Restore it and make the implementation pass it.\n${verdict.failureExcerpt}`
|
|
168
212
|
: verdict.failureExcerpt
|
|
@@ -172,7 +216,7 @@ for (const [index, effort] of rungs.entries()) {
|
|
|
172
216
|
if (rung === rungs.length - 1) {
|
|
173
217
|
phase('Design')
|
|
174
218
|
log(`rung ${rung}/${rungs.length} failed twice: architect redesigns before the last rung`)
|
|
175
|
-
spec = await
|
|
219
|
+
spec = await ask(architectPrompt(`Two rungs have failed the harness. Latest failure:\n${feedback}\n\nDecide whether the approach, the contract or the boundary is wrong before the last attempt.`), {
|
|
176
220
|
agentType: 'architect',
|
|
177
221
|
effort: 'xhigh',
|
|
178
222
|
phase: 'Design',
|
|
@@ -11,7 +11,7 @@ areas, defects vs accepted variance), `architecture/security-invariants.md`.
|
|
|
11
11
|
The harness command is in `construct.json` (`harness.command`). A change is done only when it passes.
|
|
12
12
|
API changes start from the contract file named in `construct.json` (`contracts.path`); regenerate the
|
|
13
13
|
types with `pnpm contracts:types` before implementing. Composition models in the directory
|
|
14
|
-
`construct.json` names as `discovery.composition` are the source of the diagrams; edit the model and run
|
|
14
|
+
`construct.json` names as `discovery.markers.composition.file` are the source of the diagrams; edit the model and run
|
|
15
15
|
`pnpm composition:render`, never the rendered block.
|
|
16
16
|
|
|
17
17
|
Discovery: when asked to "run construct discovery", follow the `construct-discover` rule in this
|
|
@@ -9,8 +9,8 @@ not verify by reading code; where the codebase is inconsistent, record an open q
|
|
|
9
9
|
inventing a rule.
|
|
10
10
|
|
|
11
11
|
Scope: `$ARGUMENTS` (empty means every marker). The markers, and the file each one lives in, are
|
|
12
|
-
listed under `discovery` in `construct.json` (the text markers in `AGENTS.md`, the composition
|
|
13
|
-
in the directory `discovery.composition` names). Every marker is a block between
|
|
12
|
+
listed under `discovery.markers` in `construct.json` (the text markers in `AGENTS.md`, the composition
|
|
13
|
+
models in the directory `discovery.markers.composition.file` names). Every marker is a block between
|
|
14
14
|
`<!-- construct:discover:<name> -->` and `<!-- /construct:discover:<name> -->`; replace the placeholder
|
|
15
15
|
line inside the block and nothing outside it. `construct doctor` reports any marker still holding the
|
|
16
16
|
placeholder.
|
|
@@ -37,7 +37,9 @@ Work in this order:
|
|
|
37
37
|
2. **Inventory.** Read `construct.json`, `package.json`, the directory tree two levels deep, the entry
|
|
38
38
|
points (servers, app factories, `main.ts`, CLI scripts, workers), the API contract if there is
|
|
39
39
|
one, and every `*.config.ts` / `config.ts`. Note the package manager, runtime, database and clients, CI, deployment
|
|
40
|
-
and existing conventions. Do not write yet.
|
|
40
|
+
and existing conventions. Do not write yet. Record the commit this run starts from: set
|
|
41
|
+
`discovery.baseSha` in `construct.json` to the output of `git rev-parse HEAD`, or `null` where the
|
|
42
|
+
repository has no commit yet.
|
|
41
43
|
3. **`product`** (AGENTS.md): what the system does, in one paragraph, and the one flow where a
|
|
42
44
|
defect costs the most (money, identity, data). If the repository is empty apart from the baseline,
|
|
43
45
|
say so in one line.
|
|
@@ -54,7 +56,7 @@ Work in this order:
|
|
|
54
56
|
8. **`high-effort-areas`** (AGENTS.md): the paths where a wrong low-effort guess is expensive —
|
|
55
57
|
attribution, authentication, money, schema, anything a shipped client depends on. This list is what
|
|
56
58
|
`/implement` uses to classify a task as `high`.
|
|
57
|
-
9. **`composition`** (`<discovery.composition>/*.yaml` from `construct.json`): one model per real flow the code has today
|
|
59
|
+
9. **`composition`** (`<discovery.markers.composition.file>/*.yaml` from `construct.json`): one model per real flow the code has today
|
|
58
60
|
(the HTTP app, a worker, a sync, a CLI, the browser bootstrap) — small, one per flow, every `path`
|
|
59
61
|
must exist. A baseline model, when the construct shipped one, is updated, not duplicated; a
|
|
60
62
|
repository that had code before the construct starts with no model and needs at least one for its
|
|
@@ -71,5 +73,24 @@ Work in this order:
|
|
|
71
73
|
`npx mikoshi-construct doctor` when the CLI is not installed; it names every marker that still
|
|
72
74
|
holds the placeholder.
|
|
73
75
|
|
|
76
|
+
13. **Record what you wrote, in the manifest and nowhere else.** Provenance belongs in
|
|
77
|
+
`construct.json`; never add a byline, an authorship note or a hash to a marker body. Set
|
|
78
|
+
`discovery.filledAt` to the time you finished, and for each marker you filled set
|
|
79
|
+
`discovery.markers.<name>` to `{"file": "<the file it lives in>", "authoredBy": "construct",
|
|
80
|
+
"sha": "<sha256 of the body you wrote>"}`. A marker you did not fill keeps the entry it had.
|
|
81
|
+
The body is the text between `<!-- construct:discover:<name> -->` and
|
|
82
|
+
`<!-- /construct:discover:<name> -->` with leading and trailing whitespace stripped; for
|
|
83
|
+
`composition` it is every `*.yaml` in the directory, sorted by name, each as its filename, a
|
|
84
|
+
newline and its contents, joined by newlines. Compute it, never estimate it:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
node -e 'const{createHash}=require("node:crypto"),{readFileSync}=require("node:fs");const[f,m]=process.argv.slice(1);const d=readFileSync(f,"utf8"),o=`<!-- construct:discover:${m} -->`,c=`<!-- /construct:discover:${m} -->`;console.log(createHash("sha256").update(d.slice(d.indexOf(o)+o.length,d.indexOf(c)).trim()).digest("hex"))' AGENTS.md product
|
|
88
|
+
node -e 'const{createHash}=require("node:crypto"),{readFileSync,readdirSync}=require("node:fs"),p=require("node:path");const d=process.argv[1],b=readdirSync(d).filter(n=>n.endsWith(".yaml")).sort().map(n=>`${n}\n${readFileSync(p.join(d,n),"utf8")}`).join("\n");console.log(createHash("sha256").update(b).digest("hex"))' architecture/composition
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This is what keeps a statement the tool wrote from later reading as one the repository stands
|
|
92
|
+
behind. The moment the owner edits a marker its body stops matching the recorded sha and
|
|
93
|
+
`construct doctor` reads it as theirs — the edit is the evidence, and there is no command to run.
|
|
94
|
+
|
|
74
95
|
Report: which markers you filled, which you left as open questions and why, and the harness result.
|
|
75
96
|
Do not commit.
|
|
@@ -4,11 +4,27 @@ const WORKSPACE_PACKAGES = {{workspacePackages}}
|
|
|
4
4
|
|
|
5
5
|
const ALLOWED_WORKSPACE_IMPORTS = {{allowedWorkspaceImports}}
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const PROCESS_ENV_MEMBER = 'MemberExpression[object.name="process"][property.name="env"]'
|
|
8
|
+
const PROCESS_ENV_VIA_GLOBAL_THIS = 'MemberExpression[object.object.name="globalThis"][object.property.name="process"][property.name="env"]'
|
|
9
|
+
const PROCESS_ENV_DESTRUCTURED = 'VariableDeclarator[init.name="process"] > ObjectPattern > Property[key.name="env"]'
|
|
10
|
+
const PROCESS_ENV = `:matches(${PROCESS_ENV_MEMBER}, ${PROCESS_ENV_VIA_GLOBAL_THIS}, ${PROCESS_ENV_DESTRUCTURED})`
|
|
11
|
+
|
|
8
12
|
const PROCESS_MEMBER = 'MemberExpression[object.name="process"]'
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
13
|
+
const PROCESS_VIA_GLOBAL_THIS = 'MemberExpression[object.name="globalThis"]:matches([property.name="process"], [property.value="process"])'
|
|
14
|
+
const PROCESS_BINDING = 'VariableDeclarator[init.name="process"]'
|
|
15
|
+
const PROCESS = `:matches(${PROCESS_MEMBER}, ${PROCESS_VIA_GLOBAL_THIS}, ${PROCESS_BINDING})`
|
|
16
|
+
|
|
17
|
+
const RAW_REQUEST_MEMBER = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
|
|
18
|
+
const RAW_REQUEST_DESTRUCTURED = 'VariableDeclarator[init.name="req"] > ObjectPattern > Property[key.name=/^(body|query|params)$/]'
|
|
19
|
+
const RAW_REQUEST_ALIASED = 'VariableDeclarator[id.type="Identifier"][init.name="req"]'
|
|
20
|
+
const RAW_REQUEST_DATA = `:matches(${RAW_REQUEST_MEMBER}, ${RAW_REQUEST_DESTRUCTURED}, ${RAW_REQUEST_ALIASED})`
|
|
21
|
+
|
|
22
|
+
const RAW_SQL = 'MemberExpression[object.name="sql"]:matches([property.name="raw"], [property.value="raw"])'
|
|
23
|
+
|
|
24
|
+
const SHARED_SPECIFIER = '/^{{scope}}\\/shared(\\/|$)/'
|
|
25
|
+
const SHARED_IMPORT = `:matches(ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, ImportExpression)[source.value=${SHARED_SPECIFIER}]`
|
|
26
|
+
const SHARED_REQUIRE = `CallExpression[callee.name="require"][arguments.0.value=${SHARED_SPECIFIER}]`
|
|
27
|
+
const SHARED_MODULE = `:matches(${SHARED_IMPORT}, ${SHARED_REQUIRE})`
|
|
12
28
|
|
|
13
29
|
function dependencyBoundary([directory, allowed]) {
|
|
14
30
|
const forbidden = WORKSPACE_PACKAGES.filter(name => !allowed.includes(name))
|
|
@@ -32,7 +48,7 @@ const dependencyBoundaries = Object.entries(ALLOWED_WORKSPACE_IMPORTS)
|
|
|
32
48
|
.map(dependencyBoundary)
|
|
33
49
|
|
|
34
50
|
const NO_PROCESS_OUTSIDE_CONFIG = {
|
|
35
|
-
selector:
|
|
51
|
+
selector: PROCESS,
|
|
36
52
|
message: 'An app touches process only in config.ts: read configuration through readConfig() and pass the value on',
|
|
37
53
|
}
|
|
38
54
|
const NO_PROCESS_ENV_IN_PACKAGE = {
|
|
@@ -48,7 +64,7 @@ const NO_RAW_SQL = {
|
|
|
48
64
|
message: 'SQL is never built from raw strings: interpolate tables and columns into the sql template instead of sql.raw',
|
|
49
65
|
}
|
|
50
66
|
const NO_SHARED_OUTSIDE_CONTRACTS = {
|
|
51
|
-
selector:
|
|
67
|
+
selector: SHARED_MODULE,
|
|
52
68
|
message: 'Contract types enter an app only through src/contracts/types.ts: import them from there',
|
|
53
69
|
}
|
|
54
70
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ESLint } from 'eslint'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { REPO_ROOT } from '../../composition/files.js'
|
|
4
|
+
|
|
5
|
+
const SAMPLES = {
|
|
6
|
+
plainModule: 'export function label(name: string): string {\n return name.trim()\n}\n',
|
|
7
|
+
processMember: 'export const level = process.env.LOG_LEVEL\n',
|
|
8
|
+
processDestructured: 'const { env } = process\n\nexport const level = env.LOG_LEVEL\n',
|
|
9
|
+
processViaGlobalThis: 'export const level = globalThis.process.env.LOG_LEVEL\n',
|
|
10
|
+
processAliased: 'const runtime = process\n\nexport const level = runtime.env.LOG_LEVEL\n',
|
|
11
|
+
processArgv: 'export const args = process.argv.slice(2)\n',
|
|
12
|
+
rawRequestMember: 'export function name(req: { body: { name: string } }): string {\n return req.body.name\n}\n',
|
|
13
|
+
rawRequestDestructured: 'export function name(req: { body: { name: string } }): string {\n const { body } = req\n return body.name\n}\n',
|
|
14
|
+
rawRequestAliased: 'export function name(req: { body: { name: string } }): string {\n const request = req\n return request.body.name\n}\n',
|
|
15
|
+
rawSqlCall: 'export const rows = sql.raw(\'select 1\')\n',
|
|
16
|
+
rawSqlTagged: 'export const rows = sql.raw`select 1`\n',
|
|
17
|
+
rawSqlComputed: 'export const rows = sql[\'raw\'](\'select 1\')\n',
|
|
18
|
+
sharedStaticImport: 'import type { components } from \'{{scope}}/shared\'\n\nexport type Contract = components\n',
|
|
19
|
+
sharedDynamicImport: 'export async function load(): Promise<unknown> {\n return import(\'{{scope}}/shared\')\n}\n',
|
|
20
|
+
sharedRequire: 'export const shared: unknown = require(\'{{scope}}/shared\')\n',
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
type SampleName = keyof typeof SAMPLES
|
|
24
|
+
|
|
25
|
+
const PROCESS_FORMS: SampleName[] = ['processMember', 'processDestructured', 'processViaGlobalThis', 'processAliased', 'processArgv']
|
|
26
|
+
const PROCESS_ENV_FORMS: SampleName[] = ['processMember', 'processDestructured', 'processViaGlobalThis']
|
|
27
|
+
const RAW_REQUEST_FORMS: SampleName[] = ['rawRequestMember', 'rawRequestDestructured', 'rawRequestAliased']
|
|
28
|
+
const RAW_SQL_FORMS: SampleName[] = ['rawSqlCall', 'rawSqlTagged', 'rawSqlComputed']
|
|
29
|
+
const SHARED_FORMS: SampleName[] = ['sharedStaticImport', 'sharedDynamicImport', 'sharedRequire']
|
|
30
|
+
|
|
31
|
+
const ROLES: Array<{ role: string, file: string, reported: SampleName[], exempt: SampleName[] }> = [
|
|
32
|
+
{
|
|
33
|
+
role: 'a service',
|
|
34
|
+
file: 'apps/api/src/things/things.service.ts',
|
|
35
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS, ...RAW_REQUEST_FORMS, ...SHARED_FORMS],
|
|
36
|
+
exempt: [],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
role: 'a controller',
|
|
40
|
+
file: 'apps/api/src/things/things.controller.ts',
|
|
41
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS, ...SHARED_FORMS],
|
|
42
|
+
exempt: RAW_REQUEST_FORMS,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
role: 'a middleware',
|
|
46
|
+
file: 'apps/api/src/http/error-handler.middleware.ts',
|
|
47
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS, ...SHARED_FORMS],
|
|
48
|
+
exempt: RAW_REQUEST_FORMS,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
role: 'the contract types gateway',
|
|
52
|
+
file: 'apps/api/src/contracts/types.ts',
|
|
53
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS, ...RAW_REQUEST_FORMS],
|
|
54
|
+
exempt: SHARED_FORMS,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
role: 'the app config',
|
|
58
|
+
file: 'apps/api/src/config.ts',
|
|
59
|
+
reported: [...RAW_SQL_FORMS, ...SHARED_FORMS],
|
|
60
|
+
exempt: [...PROCESS_FORMS, ...RAW_REQUEST_FORMS],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
role: 'the process entry',
|
|
64
|
+
file: 'apps/api/src/server.ts',
|
|
65
|
+
reported: [...RAW_SQL_FORMS, ...SHARED_FORMS],
|
|
66
|
+
exempt: [...PROCESS_FORMS, ...RAW_REQUEST_FORMS],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
role: 'a package module',
|
|
70
|
+
file: 'packages/shared/src/index.ts',
|
|
71
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_ENV_FORMS],
|
|
72
|
+
exempt: ['processArgv', ...RAW_REQUEST_FORMS, ...SHARED_FORMS],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
role: 'a package config',
|
|
76
|
+
file: 'packages/shared/src/shared.config.ts',
|
|
77
|
+
reported: RAW_SQL_FORMS,
|
|
78
|
+
exempt: [...PROCESS_FORMS, ...RAW_REQUEST_FORMS, ...SHARED_FORMS],
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const eslint = new ESLint({
|
|
83
|
+
cwd: REPO_ROOT,
|
|
84
|
+
ruleFilter: ({ ruleId }) => ruleId === 'no-restricted-syntax',
|
|
85
|
+
overrideConfig: { languageOptions: { parserOptions: { projectService: false } } },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
async function restrictionReports(file: string, sample: SampleName): Promise<string[]> {
|
|
89
|
+
const [result] = await eslint.lintText(SAMPLES[sample], { filePath: file })
|
|
90
|
+
const fatal = result.messages.find(message => message.fatal)
|
|
91
|
+
if (fatal)
|
|
92
|
+
throw new Error(`${file} (${sample}): ${fatal.message}`)
|
|
93
|
+
return result.messages
|
|
94
|
+
.filter(message => message.ruleId === 'no-restricted-syntax')
|
|
95
|
+
.map(message => message.message)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('syntax policy by file role', () => {
|
|
99
|
+
for (const { role, file, reported, exempt } of ROLES) {
|
|
100
|
+
it(`reports every restricted form in ${role}`, async () => {
|
|
101
|
+
for (const sample of reported)
|
|
102
|
+
expect(await restrictionReports(file, sample), sample).not.toEqual([])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it(`reports nothing ${role} may legitimately write`, async () => {
|
|
106
|
+
for (const sample of ['plainModule' as SampleName, ...exempt])
|
|
107
|
+
expect(await restrictionReports(file, sample), sample).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
})
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import antfu from '@antfu/eslint-config'
|
|
2
2
|
|
|
3
3
|
const PROCESS_MEMBER = 'MemberExpression[object.name="process"]'
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const PROCESS_VIA_GLOBAL_THIS = 'MemberExpression[object.name="globalThis"]:matches([property.name="process"], [property.value="process"])'
|
|
5
|
+
const PROCESS_BINDING = 'VariableDeclarator[init.name="process"]'
|
|
6
|
+
const PROCESS = `:matches(${PROCESS_MEMBER}, ${PROCESS_VIA_GLOBAL_THIS}, ${PROCESS_BINDING})`
|
|
7
|
+
|
|
8
|
+
const RAW_REQUEST_MEMBER = 'MemberExpression[object.name="req"][property.name=/^(body|query|params)$/]'
|
|
9
|
+
const RAW_REQUEST_DESTRUCTURED = 'VariableDeclarator[init.name="req"] > ObjectPattern > Property[key.name=/^(body|query|params)$/]'
|
|
10
|
+
const RAW_REQUEST_ALIASED = 'VariableDeclarator[id.type="Identifier"][init.name="req"]'
|
|
11
|
+
const RAW_REQUEST_DATA = `:matches(${RAW_REQUEST_MEMBER}, ${RAW_REQUEST_DESTRUCTURED}, ${RAW_REQUEST_ALIASED})`
|
|
12
|
+
|
|
13
|
+
const RAW_SQL = 'MemberExpression[object.name="sql"]:matches([property.name="raw"], [property.value="raw"])'
|
|
6
14
|
|
|
7
15
|
const NO_PROCESS_OUTSIDE_CONFIG = {
|
|
8
|
-
selector:
|
|
16
|
+
selector: PROCESS,
|
|
9
17
|
message: 'src touches process only in config.ts: read configuration through readConfig() and pass the value on',
|
|
10
18
|
}
|
|
11
19
|
const NO_RAW_REQUEST_DATA = {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ESLint } from 'eslint'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { REPO_ROOT } from '../../composition/files.js'
|
|
4
|
+
|
|
5
|
+
const SAMPLES = {
|
|
6
|
+
plainModule: 'export function label(name: string): string {\n return name.trim()\n}\n',
|
|
7
|
+
processMember: 'export const level = process.env.LOG_LEVEL\n',
|
|
8
|
+
processDestructured: 'const { env } = process\n\nexport const level = env.LOG_LEVEL\n',
|
|
9
|
+
processViaGlobalThis: 'export const level = globalThis.process.env.LOG_LEVEL\n',
|
|
10
|
+
processAliased: 'const runtime = process\n\nexport const level = runtime.env.LOG_LEVEL\n',
|
|
11
|
+
rawRequestMember: 'export function name(req: { body: { name: string } }): string {\n return req.body.name\n}\n',
|
|
12
|
+
rawRequestDestructured: 'export function name(req: { body: { name: string } }): string {\n const { body } = req\n return body.name\n}\n',
|
|
13
|
+
rawRequestAliased: 'export function name(req: { body: { name: string } }): string {\n const request = req\n return request.body.name\n}\n',
|
|
14
|
+
rawSqlCall: 'export const rows = sql.raw(\'select 1\')\n',
|
|
15
|
+
rawSqlTagged: 'export const rows = sql.raw`select 1`\n',
|
|
16
|
+
rawSqlComputed: 'export const rows = sql[\'raw\'](\'select 1\')\n',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
type SampleName = keyof typeof SAMPLES
|
|
20
|
+
|
|
21
|
+
const PROCESS_FORMS: SampleName[] = ['processMember', 'processDestructured', 'processViaGlobalThis', 'processAliased']
|
|
22
|
+
const RAW_REQUEST_FORMS: SampleName[] = ['rawRequestMember', 'rawRequestDestructured', 'rawRequestAliased']
|
|
23
|
+
const RAW_SQL_FORMS: SampleName[] = ['rawSqlCall', 'rawSqlTagged', 'rawSqlComputed']
|
|
24
|
+
|
|
25
|
+
const ROLES: Array<{ role: string, file: string, reported: SampleName[], exempt: SampleName[] }> = [
|
|
26
|
+
{
|
|
27
|
+
role: 'a service',
|
|
28
|
+
file: 'src/things/things.service.ts',
|
|
29
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS, ...RAW_REQUEST_FORMS],
|
|
30
|
+
exempt: [],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
role: 'a controller',
|
|
34
|
+
file: 'src/things/things.controller.ts',
|
|
35
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS],
|
|
36
|
+
exempt: RAW_REQUEST_FORMS,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
role: 'a middleware',
|
|
40
|
+
file: 'src/http/error-handler.middleware.ts',
|
|
41
|
+
reported: [...RAW_SQL_FORMS, ...PROCESS_FORMS],
|
|
42
|
+
exempt: RAW_REQUEST_FORMS,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
role: 'the app config',
|
|
46
|
+
file: 'src/config.ts',
|
|
47
|
+
reported: RAW_SQL_FORMS,
|
|
48
|
+
exempt: [...PROCESS_FORMS, ...RAW_REQUEST_FORMS],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
role: 'the process entry',
|
|
52
|
+
file: 'src/server.ts',
|
|
53
|
+
reported: RAW_SQL_FORMS,
|
|
54
|
+
exempt: [...PROCESS_FORMS, ...RAW_REQUEST_FORMS],
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const eslint = new ESLint({
|
|
59
|
+
cwd: REPO_ROOT,
|
|
60
|
+
ruleFilter: ({ ruleId }) => ruleId === 'no-restricted-syntax',
|
|
61
|
+
overrideConfig: { languageOptions: { parserOptions: { projectService: false } } },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
async function restrictionReports(file: string, sample: SampleName): Promise<string[]> {
|
|
65
|
+
const [result] = await eslint.lintText(SAMPLES[sample], { filePath: file })
|
|
66
|
+
const fatal = result.messages.find(message => message.fatal)
|
|
67
|
+
if (fatal)
|
|
68
|
+
throw new Error(`${file} (${sample}): ${fatal.message}`)
|
|
69
|
+
return result.messages
|
|
70
|
+
.filter(message => message.ruleId === 'no-restricted-syntax')
|
|
71
|
+
.map(message => message.message)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('syntax policy by file role', () => {
|
|
75
|
+
for (const { role, file, reported, exempt } of ROLES) {
|
|
76
|
+
it(`reports every restricted form in ${role}`, async () => {
|
|
77
|
+
for (const sample of reported)
|
|
78
|
+
expect(await restrictionReports(file, sample), sample).not.toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it(`reports nothing ${role} may legitimately write`, async () => {
|
|
82
|
+
for (const sample of ['plainModule' as SampleName, ...exempt])
|
|
83
|
+
expect(await restrictionReports(file, sample), sample).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
})
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import antfu from '@antfu/eslint-config'
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
3
|
+
const CLASS_LIST_ACCESS = ':matches([callee.object.property.name="classList"], [callee.object.property.value="classList"])'
|
|
4
|
+
const CLASS_LIST_METHOD = ':matches([callee.property.name=/^(add|remove|toggle|replace)$/], [callee.property.value=/^(add|remove|toggle|replace)$/])'
|
|
5
|
+
const CLASS_LIST_CALL = `CallExpression${CLASS_LIST_ACCESS}${CLASS_LIST_METHOD}`
|
|
6
|
+
const CLASS_LIST_BINDING = 'VariableDeclarator:matches([init.property.name="classList"], [init.property.value="classList"])'
|
|
7
|
+
const CLASS_LIST_MUTATION = `:matches(${CLASS_LIST_CALL}, ${CLASS_LIST_BINDING})`
|
|
8
|
+
|
|
9
|
+
const STYLE_ACCESS = ':matches([left.object.property.name="style"], [left.object.property.value="style"])'
|
|
10
|
+
const INLINE_STYLE_WRITE = `AssignmentExpression${STYLE_ACCESS}`
|
|
11
|
+
const STYLE_METHOD = ':matches([callee.property.name=/^(setProperty|removeProperty)$/], [callee.property.value=/^(setProperty|removeProperty)$/])'
|
|
12
|
+
const INLINE_STYLE_CALL = `CallExpression:matches([callee.object.property.name="style"], [callee.object.property.value="style"])${STYLE_METHOD}`
|
|
13
|
+
const STYLE_BINDING = 'VariableDeclarator:matches([init.property.name="style"], [init.property.value="style"])'
|
|
14
|
+
const INLINE_STYLE_PROPERTY = `:matches(${INLINE_STYLE_CALL}, ${STYLE_BINDING})`
|
|
6
15
|
|
|
7
16
|
const NO_STATE_CLASSES = {
|
|
8
17
|
selector: CLASS_LIST_MUTATION,
|