@agjs/tsforge 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-check.ts +106 -0
  3. package/scripts/build-rule-docs.ts +5 -2
  4. package/scripts/build-rules-md.ts +5 -2
  5. package/scripts/test-coverage-check.ts +138 -0
  6. package/src/cli.ts +2 -2
  7. package/src/detect-gate.ts +48 -2
  8. package/src/loop/feedback/meta-rule-docs.ts +16 -0
  9. package/src/loop/feedback/rule-docs.ts +44 -1
  10. package/src/loop/prompt/prompt.ts +1 -0
  11. package/src/loop/rule-docs.generated.json +242 -222
  12. package/src/meta-rules/context.ts +50 -0
  13. package/src/meta-rules/meta-rules.types.ts +3 -1
  14. package/src/meta-rules/registry.ts +14 -0
  15. package/src/meta-rules/rules/docker/dockerfile-base-image-pinned.ts +73 -0
  16. package/src/meta-rules/rules/docker/dockerfile-no-secrets-in-env-arg.ts +67 -0
  17. package/src/meta-rules/rules/docker/dockerfile-non-root-user.ts +58 -0
  18. package/src/meta-rules/rules/docker/utils.ts +58 -0
  19. package/src/meta-rules/rules/structure/no-circular-imports.ts +195 -0
  20. package/src/meta-rules/rules/supply-chain/no-undeclared-dependencies.ts +180 -0
  21. package/src/rule-packs/ai-sdk/index.ts +28 -0
  22. package/src/rule-packs/ai-sdk/rules/no-api-key-in-client.ts +92 -0
  23. package/src/rule-packs/ai-sdk/rules/no-user-input-in-system-prompt.ts +91 -0
  24. package/src/rule-packs/ai-sdk/rules/require-completion-token-limit.ts +112 -0
  25. package/src/rule-packs/index.ts +2 -0
  26. package/src/stack-detection/packs.ts +19 -0
  27. package/strict.eslint.config.mjs +12 -0
  28. package/strict.type-aware.eslint.config.mjs +36 -3
  29. package/strict.web.eslint.config.mjs +9 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.3.4",
4
+ "version": "0.5.0",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "eslint-plugin-react": "^7.37.5",
39
39
  "eslint-plugin-react-hooks": "^7.1.1",
40
40
  "eslint-plugin-jsx-a11y": "^6.10.2",
41
+ "eslint-plugin-sonarjs": "4.0.3",
41
42
  "eslint": "10.4.0",
42
43
  "prettier": "3.8.3",
43
44
  "typescript": "6.0.3",
@@ -0,0 +1,106 @@
1
+ // Gate-runnable BOOT oracle: actually start the built server and confirm it comes
2
+ // up and answers a request without a 5xx. This is "does it RUN", a class of
3
+ // failure tsc/eslint/unit-tests never catch — a server that throws on boot
4
+ // (bad env wiring, a port clash, a top-level await that rejects) still type-checks
5
+ // and lints clean. Mirrors the web browser smoke, for backends.
6
+ //
7
+ // OPT-IN: wired into the gate only when TSFORGE_BOOT is set to the start command
8
+ // (e.g. `TSFORGE_BOOT="bun run start"`). TSFORGE_BOOT_URL (default
9
+ // http://localhost:3000/) and TSFORGE_BOOT_TIMEOUT (ms, default 15000) tune it.
10
+ //
11
+ // TSFORGE_BOOT="bun run start" TSFORGE_BOOT_URL=http://localhost:3000/health bun boot-check.ts
12
+
13
+ export interface IBootConfig {
14
+ readonly command: string;
15
+ readonly url: string;
16
+ readonly timeoutMs: number;
17
+ }
18
+
19
+ /** Read the boot config from env; null when TSFORGE_BOOT is not set. */
20
+ export function bootConfig(
21
+ env: Record<string, string | undefined>
22
+ ): IBootConfig | null {
23
+ const command = env.TSFORGE_BOOT;
24
+
25
+ if (command === undefined || command.trim().length === 0) {
26
+ return null;
27
+ }
28
+
29
+ const timeoutRaw = Number(env.TSFORGE_BOOT_TIMEOUT);
30
+
31
+ return {
32
+ command,
33
+ url: env.TSFORGE_BOOT_URL ?? "http://localhost:3000/",
34
+ timeoutMs:
35
+ Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 15000,
36
+ };
37
+ }
38
+
39
+ /** Poll `url` until it answers with status < 500, or the deadline passes.
40
+ * Returns the status code on success, or null on timeout. */
41
+ export async function pollUntilReady(
42
+ url: string,
43
+ timeoutMs: number,
44
+ now: () => number = () => performance.now(),
45
+ sleep: (ms: number) => Promise<void> = (ms) =>
46
+ new Promise((r) => setTimeout(r, ms))
47
+ ): Promise<number | null> {
48
+ const deadline = now() + timeoutMs;
49
+
50
+ while (now() < deadline) {
51
+ try {
52
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
53
+
54
+ if (res.status < 500) {
55
+ return res.status;
56
+ }
57
+ } catch {
58
+ // not up yet
59
+ }
60
+
61
+ await sleep(250);
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ async function main(): Promise<number> {
68
+ const cfg = bootConfig(process.env);
69
+
70
+ if (cfg === null) {
71
+ return 0; // not configured — nothing to do
72
+ }
73
+
74
+ const child = Bun.spawn(["sh", "-c", cfg.command], {
75
+ cwd: process.cwd(),
76
+ stdout: "pipe",
77
+ stderr: "pipe",
78
+ env: process.env,
79
+ });
80
+
81
+ try {
82
+ const status = await pollUntilReady(cfg.url, cfg.timeoutMs);
83
+
84
+ if (status === null) {
85
+ const err = await new Response(child.stderr).text();
86
+
87
+ process.stderr.write(
88
+ `boot-check: server did not answer ${cfg.url} within ${cfg.timeoutMs}ms (or only returned 5xx). It must boot and serve a non-5xx response.\n${err.slice(0, 800)}\n`
89
+ );
90
+
91
+ return 1;
92
+ }
93
+
94
+ process.stdout.write(
95
+ `boot-check: server answered ${cfg.url} with ${status}. OK\n`
96
+ );
97
+
98
+ return 0;
99
+ } finally {
100
+ child.kill();
101
+ }
102
+ }
103
+
104
+ if (import.meta.main) {
105
+ process.exit(await main());
106
+ }
@@ -98,10 +98,13 @@ for (const pack of Object.values(RULE_PACKS)) {
98
98
  const ruleId = `tsforge/${ruleName}`;
99
99
  const description = getRuleDescription(ruleModule) ?? ruleName;
100
100
 
101
+ // No fake placeholders: leave bad/good empty so the feedback renderer shows
102
+ // just the description. Real worked examples come from curated RULE_DOCS,
103
+ // which take precedence over this generated cache.
101
104
  out[ruleId] = {
102
105
  what: description,
103
- bad: `// Example that violates the rule`,
104
- good: `// Corrected version`,
106
+ bad: "",
107
+ good: "",
105
108
  };
106
109
 
107
110
  packRulesAdded += 1;
@@ -131,6 +131,7 @@ const categoryOrder = [
131
131
  "testing",
132
132
  "stack-layout",
133
133
  "ci",
134
+ "container",
134
135
  ] as const;
135
136
 
136
137
  const rulesByCategory = new Map<string, (typeof META_RULES)[number][]>();
@@ -172,9 +173,11 @@ out.push(
172
173
  "- GraphQL/WebSocket/OpenAPI contract rules (until OpenAPI dep + parser)"
173
174
  );
174
175
  out.push(
175
- "- Container/Kubernetes YAML hardening (future meta-rules when Dockerfile/k8s detected)"
176
+ "- Kubernetes / Compose YAML hardening (Dockerfile hardening now ships as container meta-rules)"
177
+ );
178
+ out.push(
179
+ "- MCP-server security pack (the AI-SDK pack now covers `ai`/`openai`/Anthropic clients)"
176
180
  );
177
- out.push("- LLM/MCP security packs (opt-in when AI SDK deps detected)");
178
181
  out.push("- FSD layer DAG / full authorization taint tracking");
179
182
  out.push("- Lighthouse / bundle-analyzer CI gates");
180
183
  out.push("- Violation ratcheting / baseline snapshots (Phase 5)");
@@ -0,0 +1,138 @@
1
+ // Gate-runnable TEST-COVERAGE oracle: run the project's tests with lcov coverage
2
+ // and fail if line coverage falls below a floor. Proves the tests don't just
3
+ // *pass* but actually *exercise* the code — closing the "added code, added no
4
+ // test" gap (a test suite can be green and assert almost nothing).
5
+ //
6
+ // OPT-IN: only wired into the gate when TSFORGE_COVERAGE is set (a percent, e.g.
7
+ // `TSFORGE_COVERAGE=80`). Skips cleanly (exit 0) when the project has no tests or
8
+ // the coverage report can't be produced — it never blocks a project that simply
9
+ // has nothing to measure yet.
10
+ //
11
+ // TSFORGE_COVERAGE=80 bun test-coverage-check.ts
12
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ export interface ICoverage {
17
+ readonly lineFound: number; // LF
18
+ readonly lineHit: number; // LH
19
+ readonly funcFound: number; // FNF
20
+ readonly funcHit: number; // FNH
21
+ readonly linePct: number; // 0..100
22
+ readonly funcPct: number; // 0..100
23
+ /** The weaker of line/function coverage — the floor is checked against this so
24
+ * an uncovered FUNCTION can't hide behind line counts (bun marks a one-line
25
+ * arrow's declaration line "hit" even when the function is never called). */
26
+ readonly pct: number;
27
+ }
28
+
29
+ function sumPrefixed(text: string, prefix: string): number {
30
+ let total = 0;
31
+
32
+ for (const line of text.split("\n")) {
33
+ if (line.startsWith(prefix)) {
34
+ const n = Number(line.slice(prefix.length).trim());
35
+
36
+ total += Number.isNaN(n) ? 0 : n;
37
+ }
38
+ }
39
+
40
+ return total;
41
+ }
42
+
43
+ /** Sum line (LF/LH) and function (FNF/FNH) coverage across an lcov.info report. */
44
+ export function parseLcovCoverage(text: string): ICoverage {
45
+ const lineFound = sumPrefixed(text, "LF:");
46
+ const lineHit = sumPrefixed(text, "LH:");
47
+ const funcFound = sumPrefixed(text, "FNF:");
48
+ const funcHit = sumPrefixed(text, "FNH:");
49
+ const linePct = lineFound === 0 ? 100 : (lineHit / lineFound) * 100;
50
+ const funcPct = funcFound === 0 ? 100 : (funcHit / funcFound) * 100;
51
+
52
+ return {
53
+ lineFound,
54
+ lineHit,
55
+ funcFound,
56
+ funcHit,
57
+ linePct,
58
+ funcPct,
59
+ pct: Math.min(linePct, funcPct),
60
+ };
61
+ }
62
+
63
+ /** The configured floor: TSFORGE_COVERAGE as a percent, default 80 when set
64
+ * truthy-but-not-a-useful-number (e.g. `=1`). */
65
+ export function coverageFloor(raw: string | undefined): number {
66
+ if (raw === undefined) {
67
+ return 80;
68
+ }
69
+
70
+ const n = Number(raw);
71
+
72
+ return Number.isFinite(n) && n > 1 && n <= 100 ? n : 80;
73
+ }
74
+
75
+ async function main(): Promise<number> {
76
+ const floor = coverageFloor(process.env.TSFORGE_COVERAGE);
77
+ const covDir = mkdtempSync(join(tmpdir(), "tsforge-cov-"));
78
+
79
+ try {
80
+ const proc = Bun.spawn(
81
+ [
82
+ "bun",
83
+ "test",
84
+ "--coverage",
85
+ "--coverage-reporter=lcov",
86
+ `--coverage-dir=${covDir}`,
87
+ ],
88
+ { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" }
89
+ );
90
+
91
+ await proc.exited;
92
+
93
+ const lcovPath = join(covDir, "lcov.info");
94
+
95
+ if (!existsSync(lcovPath)) {
96
+ process.stdout.write(
97
+ "test-coverage-check: no lcov report produced (no tests?) — skipping.\n"
98
+ );
99
+
100
+ return 0;
101
+ }
102
+
103
+ const cov = parseLcovCoverage(readFileSync(lcovPath, "utf8"));
104
+
105
+ if (cov.lineFound === 0 && cov.funcFound === 0) {
106
+ process.stdout.write(
107
+ "test-coverage-check: nothing instrumented — skipping.\n"
108
+ );
109
+
110
+ return 0;
111
+ }
112
+
113
+ const pct = cov.pct.toFixed(1);
114
+
115
+ if (cov.pct + 1e-9 < floor) {
116
+ process.stderr.write(
117
+ `test-coverage-check: coverage ${pct}% is below the ${floor}% floor ` +
118
+ `(lines ${cov.lineHit}/${cov.lineFound}, functions ${cov.funcHit}/${cov.funcFound}). ` +
119
+ `Add tests that actually call the uncovered code.\n`
120
+ );
121
+
122
+ return 1;
123
+ }
124
+
125
+ process.stdout.write(
126
+ `test-coverage-check: ${pct}% >= ${floor}% floor ` +
127
+ `(lines ${cov.lineHit}/${cov.lineFound}, functions ${cov.funcHit}/${cov.funcFound}). OK\n`
128
+ );
129
+
130
+ return 0;
131
+ } finally {
132
+ rmSync(covDir, { recursive: true, force: true });
133
+ }
134
+ }
135
+
136
+ if (import.meta.main) {
137
+ process.exit(await main());
138
+ }
package/src/cli.ts CHANGED
@@ -774,7 +774,7 @@ async function baseGate(
774
774
  }
775
775
 
776
776
  if (args.web) {
777
- const web = buildWebGate("react");
777
+ const web = buildWebGate("react", undefined, args.dir);
778
778
 
779
779
  return { accept: web.command, gateLabel: web.label };
780
780
  }
@@ -980,7 +980,7 @@ async function repl(args: ICliArgs): Promise<number> {
980
980
  `\n ↳ scaffolding a ${frameworkLabel(framework)} project\n`
981
981
  );
982
982
  await setUpWebProject(args.dir, framework);
983
- session.setGate(buildWebGate(framework).command);
983
+ session.setGate(buildWebGate(framework, undefined, args.dir).command);
984
984
  session.setFix(buildWebFix(framework));
985
985
  session.setIncrementalCheck(buildWebTscCheck());
986
986
  session.guide(webGuidance(framework));
@@ -77,6 +77,13 @@ const BROWSER_CHECK = join(
77
77
  );
78
78
 
79
79
  const STUB_CHECK = join(import.meta.dir, "..", "scripts", "stub-check.ts");
80
+ const TEST_COVERAGE_CHECK = join(
81
+ import.meta.dir,
82
+ "..",
83
+ "scripts",
84
+ "test-coverage-check.ts"
85
+ );
86
+ const BOOT_CHECK = join(import.meta.dir, "..", "scripts", "boot-check.ts");
80
87
 
81
88
  // The strict tsconfig tsforge brings to a greenfield project — strict + the
82
89
  // index-safety the local model is weakest at, with DOM + JSX libs so browser /
@@ -442,7 +449,8 @@ function packEnvPrefix(
442
449
 
443
450
  export function buildWebGate(
444
451
  framework: WebFramework,
445
- packs: readonly string[] = WEB_PACKS
452
+ packs: readonly string[] = WEB_PACKS,
453
+ cwd: string = process.cwd()
446
454
  ): IGate {
447
455
  const template = WEB_TEMPLATES[framework];
448
456
  const ignores = template.eslintIgnore
@@ -478,8 +486,21 @@ export function buildWebGate(
478
486
  // fails fast.
479
487
  const stubs = `bun "${STUB_CHECK}" .`;
480
488
 
489
+ // Type-aware async correctness (no-floating-promises / no-misused-promises) —
490
+ // the CORE gate already runs this via typeAwareLintPart(), but the web gate
491
+ // historically did not, so a dropped `await` in a handler/effect/mutation passed.
492
+ // Splice it in after the syntactic lint when the scaffold has a tsconfig (it
493
+ // always does), reusing the SHIPPED strict.type-aware config verbatim.
494
+ const typeAware = existsSync(join(cwd, "tsconfig.json"))
495
+ ? `bun "${ESLINT_BIN}" --no-config-lookup -c "${TYPE_AWARE_CONFIG}" ${ignores} --format json .`.replace(
496
+ /\s+/g,
497
+ " "
498
+ )
499
+ : null;
500
+ const lintChain = typeAware === null ? lint : `${lint} && ${typeAware}`;
501
+
481
502
  return {
482
- command: `${build} && ${tsc} && ${lint} && ${stubs} && ${format} && ${render}`,
503
+ command: `${build} && ${tsc} && ${lintChain} && ${stubs} && ${format} && ${render}`,
483
504
  label: `${template.label} (build + behaviour smoke)`,
484
505
  };
485
506
  }
@@ -663,9 +684,34 @@ export async function buildGate(
663
684
  }
664
685
  }
665
686
 
687
+ appendOptInOracles(parts, labels, process.env);
688
+
666
689
  return { command: parts.join(" && "), label: labels.join(" + ") };
667
690
  }
668
691
 
692
+ /**
693
+ * Opt-in quality oracles (default OFF, mirroring the web a11y/screenshot flags).
694
+ * They run AFTER tests and read their own config from env, so the gate command
695
+ * stays free of shell-quoting:
696
+ * - TSFORGE_COVERAGE=<pct> — fail if line coverage is below the floor.
697
+ * - TSFORGE_BOOT="<start cmd>" — boot the server and require a non-5xx response.
698
+ */
699
+ function appendOptInOracles(
700
+ parts: string[],
701
+ labels: string[],
702
+ env: Record<string, string | undefined>
703
+ ): void {
704
+ if (env.TSFORGE_COVERAGE !== undefined && env.TSFORGE_COVERAGE.length > 0) {
705
+ parts.push(`bun "${TEST_COVERAGE_CHECK}"`);
706
+ labels.push("test coverage");
707
+ }
708
+
709
+ if (env.TSFORGE_BOOT !== undefined && env.TSFORGE_BOOT.trim().length > 0) {
710
+ parts.push(`bun "${BOOT_CHECK}"`);
711
+ labels.push("boot smoke");
712
+ }
713
+ }
714
+
669
715
  /** The npm-init placeholder test script — running it always fails, so it must
670
716
  * NOT count as "the project has tests". */
671
717
  const PLACEHOLDER_TEST = /no test specified/i;
@@ -32,6 +32,12 @@ export const META_RULE_DOCS: Record<string, string> = {
32
32
  "production-must-not-use-drizzle-push":
33
33
  "Replace `drizzle-kit push` in scripts and CI with checked-in SQL migrations and `drizzle-kit migrate`.",
34
34
 
35
+ "no-undeclared-dependencies":
36
+ "Add the imported package to package.json (dependencies or devDependencies) — relying on a hoisted/transitive copy breaks on a clean install.",
37
+
38
+ "no-circular-imports":
39
+ "Break the import cycle by extracting the shared code both modules need into a third module they each import (one-way edges only).",
40
+
35
41
  "migrations-must-be-checked-in":
36
42
  "Add a drizzle/ or migrations/ folder with generated SQL migration files when using Drizzle ORM.",
37
43
 
@@ -86,4 +92,14 @@ export const META_RULE_DOCS: Record<string, string> = {
86
92
 
87
93
  "no-github-context-in-shell":
88
94
  "Pass github.event values through env: instead of interpolating them directly in run: shell scripts.",
95
+
96
+ // Container
97
+ "dockerfile-base-image-pinned":
98
+ "Pin every Dockerfile FROM to an explicit non-latest tag (e.g. node:24.3.0-bookworm) or a @sha256: digest; build-stage references and scratch are exempt.",
99
+
100
+ "dockerfile-non-root-user":
101
+ "Add a non-root USER instruction (after the install steps) so the container process does not run as root.",
102
+
103
+ "dockerfile-no-secrets-in-env-arg":
104
+ "Do not assign secret-looking ENV/ARG literals (names ending in _KEY/_TOKEN/_SECRET/_PASSWORD) — they bake into image layers; inject them at runtime via --env-file, a secret manager, or a BuildKit --secret mount.",
89
105
  };
@@ -25,6 +25,39 @@ const GENERATED: Record<string, IRuleDoc> = generatedJson;
25
25
  * failure beats making the model re-derive the fix from scratch.
26
26
  */
27
27
  const RULE_DOCS: Record<string, IRuleDoc> = {
28
+ // --- Type-aware: implicit-`any` containment (no `any` token to see) ---
29
+ // (no-unsafe-assignment / -member-access / -return are curated further below)
30
+ "@typescript-eslint/no-unsafe-call": {
31
+ what: "Calling an `any`-typed value. Type the callee so the call is checked.",
32
+ bad: "const fn = lib.run; fn(); // lib is any",
33
+ good: "const fn: () => void = lib.run; fn();",
34
+ },
35
+ "@typescript-eslint/no-unsafe-argument": {
36
+ what: "Passing an `any` into a typed parameter. Validate/narrow before the call.",
37
+ bad: "save(JSON.parse(body));",
38
+ good: "save(UserSchema.parse(JSON.parse(body)));",
39
+ },
40
+ "sonarjs/cognitive-complexity": {
41
+ what: "Function is too tangled (cognitive complexity > 20). Extract named helper functions for the inner branches/loops — don't suppress.",
42
+ bad: "function handle(x) { /* many nested if/for/switch in one body */ }",
43
+ good: "function handle(x) { return isA(x) ? doA(x) : doB(x); } // branches extracted",
44
+ },
45
+ // --- AI-SDK pack ---
46
+ "tsforge/no-api-key-in-client": {
47
+ what: "An AI provider client constructed in a `'use client'` file ships the API key to the browser. Call the model from a server route/action.",
48
+ bad: "'use client';\nconst client = new OpenAI({ apiKey: process.env.KEY });",
49
+ good: "// server action / route handler:\nconst client = new OpenAI({ apiKey: process.env.KEY });",
50
+ },
51
+ "tsforge/require-completion-token-limit": {
52
+ what: "AI completion calls must bound output tokens to cap cost/latency.",
53
+ bad: "await generateText({ model, prompt });",
54
+ good: "await generateText({ model, prompt, maxTokens: 512 });",
55
+ },
56
+ "tsforge/no-user-input-in-system-prompt": {
57
+ what: "Don't splice request/user data into the system prompt (injection). Keep the system prompt constant; pass user input as a user message.",
58
+ bad: "generateText({ system: `You are ${role}`, prompt });",
59
+ good: "generateText({ system: SYSTEM_PROMPT, messages: [{ role: 'user', content: userInput }] });",
60
+ },
28
61
  TS2532: {
29
62
  what: "Indexed access is `T | undefined` (noUncheckedIndexedAccess). Bind and guard before use; never `!`.",
30
63
  bad: "total += arr[i];",
@@ -416,7 +449,17 @@ export function ruleHelp(errors: ErrorSet): string {
416
449
  }
417
450
 
418
451
  seen.add(e.rule);
419
- blocks.push(`${e.rule}: ${doc.what}\n ✗ ${doc.bad}\n ✓ ${doc.good}`);
452
+
453
+ // Only show the ✗/✓ pair when there is a REAL worked example. Generated-only
454
+ // entries (pack rules without a curated example) carry just `what` — a fake
455
+ // "// Example that violates the rule" placeholder is worse than nothing.
456
+ const hasExample = doc.bad.length > 0 && doc.good.length > 0;
457
+
458
+ blocks.push(
459
+ hasExample
460
+ ? `${e.rule}: ${doc.what}\n ✗ ${doc.bad}\n ✓ ${doc.good}`
461
+ : `${e.rule}: ${doc.what}`
462
+ );
420
463
  }
421
464
 
422
465
  return blocks.join("\n");
@@ -15,6 +15,7 @@ export const SYSTEM = [
15
15
  "The harness also AUTO-FIXES mechanical formatting on every file you write — blank lines, braces, quotes, semicolons, import order, `prefer-template`. NEVER hand-fix or chase those, and do NOT run `tsc`/`eslint`/the gate yourself to look for them. Fix only what the gate explicitly hands back (`as`/`any`/`!`, `I`-prefix, real type errors), then stop.",
16
16
  "Test hypotheses by RUNNING them, never by reasoning them out. Unsure about an edge case, rounding, or ordering (`Math.floor(100/3)`, largest-remainder ties)? `run` a quick `bun -e '…console.log(…)'`, or write a throwaway `scratch/check.ts` importing your impl and `run` it. `scratch/` is yours — the gate ignores it.",
17
17
  "The gate is `tsc` strict + eslint with every rule an error, so write TypeScript that satisfies it: interfaces are `I`-prefixed; `===`; no `var`; never the non-null `!` — guard index access (`const x = arr[i]; if (x === undefined) {...}`); no `any` and no `as` — type every parameter (e.g. `.reduce((acc: number, r: number) => …, 0)`); explicit boolean conditions. When the gate flags errors in read-only files (tests/types), they come from your editable file being missing or wrong-shaped and vanish once it's correct — don't edit them.",
18
+ "Keep functions small: the gate caps cognitive complexity at 20 and nesting depth at 4. If a function grows long or deeply nested, extract named helpers instead of one sprawling block. Always `await` promises (or `void` them deliberately) — a floating promise is a gate error.",
18
19
  ].join("\n");
19
20
 
20
21
  /** Appended to SYSTEM for from-scratch, NON-web utility builds when the simplicity