@agjs/tsforge 0.4.0 → 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.
- package/package.json +1 -1
- package/scripts/boot-check.ts +106 -0
- package/scripts/build-rule-docs.ts +5 -2
- package/scripts/test-coverage-check.ts +138 -0
- package/src/detect-gate.ts +32 -0
- package/src/loop/feedback/meta-rule-docs.ts +6 -0
- package/src/loop/feedback/rule-docs.ts +44 -1
- package/src/loop/rule-docs.generated.json +242 -222
- package/src/meta-rules/registry.ts +6 -0
- package/src/meta-rules/rules/structure/no-circular-imports.ts +195 -0
- package/src/meta-rules/rules/supply-chain/no-undeclared-dependencies.ts +180 -0
- package/strict.type-aware.eslint.config.mjs +36 -3
package/package.json
CHANGED
|
@@ -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:
|
|
104
|
-
good:
|
|
106
|
+
bad: "",
|
|
107
|
+
good: "",
|
|
105
108
|
};
|
|
106
109
|
|
|
107
110
|
packRulesAdded += 1;
|
|
@@ -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/detect-gate.ts
CHANGED
|
@@ -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 /
|
|
@@ -677,9 +684,34 @@ export async function buildGate(
|
|
|
677
684
|
}
|
|
678
685
|
}
|
|
679
686
|
|
|
687
|
+
appendOptInOracles(parts, labels, process.env);
|
|
688
|
+
|
|
680
689
|
return { command: parts.join(" && "), label: labels.join(" + ") };
|
|
681
690
|
}
|
|
682
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
|
+
|
|
683
715
|
/** The npm-init placeholder test script — running it always fails, so it must
|
|
684
716
|
* NOT count as "the project has tests". */
|
|
685
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
|
|
|
@@ -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
|
-
|
|
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");
|