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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mikoshi-construct",
3
3
  "type": "module",
4
- "version": "0.1.3",
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
- Your final message is data, not prose. Return exactly one JSON object:
27
+ Return these fields; the runtime validates the shape against the schema it gives you.
28
28
 
29
- ```json
30
- {
31
- "decision": "the design in a few sentences, including what stays unchanged and why",
32
- "contractChanges": "operations or schemas to add or change in the API contract, or empty",
33
- "compositionChanges": "composition model nodes or edges to change, or empty",
34
- "constraints": ["one constraint per entry"],
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
- Your final message is data, not prose. Return exactly one JSON object:
29
+ Return these fields; the runtime validates the shape against the schema it gives you.
30
30
 
31
- ```json
32
- {
33
- "passed": true,
34
- "failureExcerpt": "the failing command and its last relevant lines, empty when passed",
35
- "securityFinding": "the invariant that failed, empty when none",
36
- "diffStat": "output of git diff --stat",
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
- Your final message is data, not prose. Return exactly one JSON object:
30
+ Return these fields; the runtime validates the shape against the schema it gives you.
31
31
 
32
- ```json
33
- {
34
- "status": "done | failed | blocked",
35
- "summary": "one or two sentences on what changed",
36
- "files": ["paths you changed"],
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
- The user's `/implement` invocation is the opt-in the tool requires.
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 `at` (ISO time), `task` (first 120 characters), `effort` (the class you chose), `status`,
30
- the rung that finished (`effort` from the result), `attempts` from the result, and the Workflow
31
- tool's own accounting for the run `agents`, `tokens`, `toolUses`, `seconds` exactly as it
32
- reported them. This log is what tunes the ladder later; workflow scripts have no filesystem
33
- access, so it is written here, not by the script. `.construct/` is gitignored.
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 agent(architectPrompt('The task is classified as high effort; design it before any implementation.'), {
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 agent(implementerPrompt(spec, feedback), {
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: 'no report' })
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 agent(architectPrompt(`The implementer stopped on this question:\n${report.question}`), {
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 agent(harnessPrompt(), {
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({ rung, effort, outcome: passed ? 'passed' : 'failed', securityFinding: verdict?.securityFinding ?? '' })
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
- ? 'The harness produced no verdict.'
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 agent(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.`), {
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 models
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 PROCESS_ENV = 'MemberExpression[object.name="process"][property.name="env"]'
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 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(\\/|$)/]'
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: PROCESS_MEMBER,
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: SHARED_IMPORT,
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 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"]'
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: PROCESS_MEMBER,
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 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)$/]'
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,