@agjs/tsforge 0.3.3 → 0.4.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 +2 -1
- package/scripts/build-rules-md.ts +5 -2
- package/src/cli/commands.ts +97 -0
- package/src/cli.ts +71 -22
- package/src/detect-gate.ts +16 -2
- package/src/loop/feedback/meta-rule-docs.ts +10 -0
- package/src/loop/prompt/prompt.ts +1 -0
- package/src/meta-rules/context.ts +50 -0
- package/src/meta-rules/meta-rules.types.ts +3 -1
- package/src/meta-rules/registry.ts +8 -0
- package/src/meta-rules/rules/docker/dockerfile-base-image-pinned.ts +73 -0
- package/src/meta-rules/rules/docker/dockerfile-no-secrets-in-env-arg.ts +67 -0
- package/src/meta-rules/rules/docker/dockerfile-non-root-user.ts +58 -0
- package/src/meta-rules/rules/docker/utils.ts +58 -0
- package/src/render/command-menu.ts +174 -0
- package/src/rule-packs/ai-sdk/index.ts +28 -0
- package/src/rule-packs/ai-sdk/rules/no-api-key-in-client.ts +92 -0
- package/src/rule-packs/ai-sdk/rules/no-user-input-in-system-prompt.ts +91 -0
- package/src/rule-packs/ai-sdk/rules/require-completion-token-limit.ts +112 -0
- package/src/rule-packs/index.ts +2 -0
- package/src/stack-detection/packs.ts +19 -0
- package/strict.eslint.config.mjs +12 -0
- 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.
|
|
4
|
+
"version": "0.4.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",
|
|
@@ -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
|
-
"-
|
|
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,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for the REPL's slash commands — drives BOTH the
|
|
3
|
+
* `/help` text and the interactive `/` palette, so they can never drift and the
|
|
4
|
+
* user never has to memorize what exists. The executor stays the `command()`
|
|
5
|
+
* switch in cli.ts; `commandVerbs()` lets a test assert the two stay in sync.
|
|
6
|
+
*/
|
|
7
|
+
export interface ICommandSpec {
|
|
8
|
+
/** Full token incl. leading slash, e.g. "/gate". */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** Argument hint shown after the name (e.g. "<cmd>", "[name]"); omitted if none. */
|
|
11
|
+
readonly arg?: string;
|
|
12
|
+
/** One-line description. */
|
|
13
|
+
readonly summary: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const COMMANDS: readonly ICommandSpec[] = [
|
|
17
|
+
{ name: "/help", summary: "show this help" },
|
|
18
|
+
{
|
|
19
|
+
name: "/compact",
|
|
20
|
+
summary: "summarize the conversation to free up context",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "/clear",
|
|
24
|
+
summary: "reset the conversation (keeps the workspace + gate)",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "/plan",
|
|
28
|
+
summary:
|
|
29
|
+
"toggle plan mode (explore → clarify → plan; 'approve' implements)",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "/gate",
|
|
33
|
+
arg: "<cmd>",
|
|
34
|
+
summary: "set the gate command (empty to clear)",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "/files",
|
|
38
|
+
arg: "<globs>",
|
|
39
|
+
summary: "set the editable scope (comma-separated; empty = all)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "/model",
|
|
43
|
+
arg: "[name]",
|
|
44
|
+
summary: "list configured models (★ active), or switch to <name>",
|
|
45
|
+
},
|
|
46
|
+
{ name: "/sessions", summary: "list saved sessions" },
|
|
47
|
+
{ name: "/cost", summary: "rough conversation size (messages + ~tokens)" },
|
|
48
|
+
{
|
|
49
|
+
name: "/metrics",
|
|
50
|
+
summary: "token totals + generation rate (tok/s) this session",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "/memory",
|
|
54
|
+
arg: "[forget]",
|
|
55
|
+
summary: "show learned failure→fix lessons (forget to clear)",
|
|
56
|
+
},
|
|
57
|
+
{ name: "/exit", summary: "leave the session" },
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** True when the spec takes an argument — the palette prefills `"<name> "` and
|
|
61
|
+
* lets the user type instead of running immediately. */
|
|
62
|
+
export function takesArg(spec: ICommandSpec): boolean {
|
|
63
|
+
return spec.arg !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Command verbs (no slash) that MUST have an executor case, incl. the `/quit`
|
|
67
|
+
* alias that isn't listed in the palette. Used by the registry↔switch parity test. */
|
|
68
|
+
export const COMMAND_VERBS: readonly string[] = [
|
|
69
|
+
...COMMANDS.map((c) => c.name.slice(1)),
|
|
70
|
+
"quit",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** The `/help` body — the command table (from the registry) plus the footer. */
|
|
74
|
+
export function formatHelp(): string {
|
|
75
|
+
const width = Math.max(
|
|
76
|
+
...COMMANDS.map(
|
|
77
|
+
(c) => c.name.length + (c.arg === undefined ? 0 : c.arg.length + 1)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
const rows = COMMANDS.map((c) => {
|
|
81
|
+
const left = c.arg === undefined ? c.name : `${c.name} ${c.arg}`;
|
|
82
|
+
|
|
83
|
+
return ` ${left.padEnd(width)} ${c.summary}`;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
"Commands:",
|
|
88
|
+
...rows,
|
|
89
|
+
" /quit alias for /exit",
|
|
90
|
+
"",
|
|
91
|
+
"Press / for an interactive menu (↑/↓ to select, type to filter, Enter to run).",
|
|
92
|
+
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
93
|
+
'the gate (if set) confirms "done".',
|
|
94
|
+
"While it's working: type a message to STEER the next turn (e.g. 'use Tailwind');",
|
|
95
|
+
"Ctrl-C interrupts the current run.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { join, isAbsolute } from "node:path";
|
|
3
3
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { emitKeypressEvents } from "node:readline";
|
|
6
|
+
import { formatHelp, takesArg } from "./cli/commands";
|
|
7
|
+
import { pickCommand } from "./render/command-menu";
|
|
5
8
|
import { runTask, RUN_STATUS, Session, PLAN_APPROVED_NOTE } from "./loop";
|
|
6
9
|
import {
|
|
7
10
|
PROVIDER_LIMITS,
|
|
@@ -664,26 +667,9 @@ export function isPlanApproval(line: string): boolean {
|
|
|
664
667
|
return /^(approve|approved|go|lgtm|implement)[.!]?$/i.test(line.trim());
|
|
665
668
|
}
|
|
666
669
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
" /compact summarize the conversation to free up context",
|
|
671
|
-
" /clear reset the conversation (keeps the workspace + gate)",
|
|
672
|
-
" /plan toggle plan mode (read-only: explore → clarify → plan; 'approve' implements)",
|
|
673
|
-
" /gate <cmd> set the gate command (empty to clear)",
|
|
674
|
-
" /files <globs> set the editable scope (comma-separated; empty = all)",
|
|
675
|
-
" /model [name] list configured models (★ active), or switch to <name>",
|
|
676
|
-
" /sessions list saved sessions (resume one with: tsforge --resume <id>)",
|
|
677
|
-
" /cost rough conversation size (messages + ~tokens)",
|
|
678
|
-
" /metrics token totals + generation rate (tok/s) this session",
|
|
679
|
-
" /memory show learned failure→fix lessons (/memory forget to clear)",
|
|
680
|
-
" /exit, /quit leave the session",
|
|
681
|
-
"",
|
|
682
|
-
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
683
|
-
'the gate (if set) confirms "done".',
|
|
684
|
-
"While it's working: type a message to STEER the next turn (e.g. 'use Tailwind');",
|
|
685
|
-
"Ctrl-C interrupts the current run.",
|
|
686
|
-
].join("\n");
|
|
670
|
+
// The /help body is generated from the command registry (src/cli/commands.ts) so
|
|
671
|
+
// the help text and the interactive `/` palette can never drift.
|
|
672
|
+
const HELP = formatHelp();
|
|
687
673
|
|
|
688
674
|
/** The session status line — distinguishes off / new / resumed. */
|
|
689
675
|
function sessionLine(id: string, resumed: ISessionRecord | null): string {
|
|
@@ -788,7 +774,7 @@ async function baseGate(
|
|
|
788
774
|
}
|
|
789
775
|
|
|
790
776
|
if (args.web) {
|
|
791
|
-
const web = buildWebGate("react");
|
|
777
|
+
const web = buildWebGate("react", undefined, args.dir);
|
|
792
778
|
|
|
793
779
|
return { accept: web.command, gateLabel: web.label };
|
|
794
780
|
}
|
|
@@ -994,7 +980,7 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
994
980
|
`\n ↳ scaffolding a ${frameworkLabel(framework)} project\n`
|
|
995
981
|
);
|
|
996
982
|
await setUpWebProject(args.dir, framework);
|
|
997
|
-
session.setGate(buildWebGate(framework).command);
|
|
983
|
+
session.setGate(buildWebGate(framework, undefined, args.dir).command);
|
|
998
984
|
session.setFix(buildWebFix(framework));
|
|
999
985
|
session.setIncrementalCheck(buildWebTscCheck());
|
|
1000
986
|
session.guide(webGuidance(framework));
|
|
@@ -1191,6 +1177,7 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1191
1177
|
session.setPlanMode(planMode); // a /clear must not silently drop the mode
|
|
1192
1178
|
planDiscussed = false;
|
|
1193
1179
|
await persist();
|
|
1180
|
+
clearScreen(); // wipe the visible terminal + scrollback, not just the state
|
|
1194
1181
|
process.stdout.write("conversation cleared\n");
|
|
1195
1182
|
break;
|
|
1196
1183
|
|
|
@@ -1356,6 +1343,23 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1356
1343
|
statusBar.teardown();
|
|
1357
1344
|
});
|
|
1358
1345
|
|
|
1346
|
+
// Wipe the visible terminal + scrollback (2J + 3J + home), re-pinning the status
|
|
1347
|
+
// bar around it so its scroll region stays correct. Used by /clear so the screen
|
|
1348
|
+
// is a clean slate, not just the conversation state.
|
|
1349
|
+
const clearScreen = (): void => {
|
|
1350
|
+
const wasActive = statusBar.active;
|
|
1351
|
+
|
|
1352
|
+
if (wasActive) {
|
|
1353
|
+
statusBar.teardown();
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
1357
|
+
|
|
1358
|
+
if (wasActive) {
|
|
1359
|
+
statusBar.install(statusInfo());
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1359
1363
|
// The prompt. With the bar pinned it repaints the bar and shows only the
|
|
1360
1364
|
// input marker; otherwise it prints the inline status line above the marker.
|
|
1361
1365
|
const prompt = (): void => {
|
|
@@ -1374,6 +1378,7 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1374
1378
|
await new Promise<void>((resolveLoop) => {
|
|
1375
1379
|
let busy = false;
|
|
1376
1380
|
let closed = false;
|
|
1381
|
+
let paletteOpen = false;
|
|
1377
1382
|
|
|
1378
1383
|
// Finish the loop only when stdin has closed AND no run is in flight — so a
|
|
1379
1384
|
// stdin EOF (piped input / Ctrl-D) never kills a build mid-turn.
|
|
@@ -1417,6 +1422,50 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1417
1422
|
}
|
|
1418
1423
|
};
|
|
1419
1424
|
|
|
1425
|
+
// Open the interactive `/` command palette: pick a command from a navigable
|
|
1426
|
+
// list, then either run it (no-arg) or prefill the line so the user types the
|
|
1427
|
+
// argument. Cancel ⇒ back to a clean prompt. Only meaningful on a TTY.
|
|
1428
|
+
const openPalette = async (): Promise<void> => {
|
|
1429
|
+
paletteOpen = true;
|
|
1430
|
+
|
|
1431
|
+
try {
|
|
1432
|
+
rl.write(null, { ctrl: true, name: "u" }); // clear the typed "/"
|
|
1433
|
+
|
|
1434
|
+
const picked = await pickCommand(process.stdout.isTTY);
|
|
1435
|
+
|
|
1436
|
+
// The palette ran on the alternate screen; exiting it restored the prompt
|
|
1437
|
+
// verbatim, so don't re-draw it (that left stray `›` lines). On cancel,
|
|
1438
|
+
// nothing to do; for an arg command, prefill so the user types the value.
|
|
1439
|
+
if (picked !== null) {
|
|
1440
|
+
if (takesArg(picked)) {
|
|
1441
|
+
rl.write(`${picked.name} `);
|
|
1442
|
+
} else {
|
|
1443
|
+
void runLine(picked.name);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
} finally {
|
|
1447
|
+
paletteOpen = false;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
// Press `/` on an empty line ⇒ open the palette. setImmediate lets readline
|
|
1452
|
+
// finish inserting the slash first, so `rl.line === "/"` confirms it's a fresh
|
|
1453
|
+
// command start (not a slash mid-text). No-op while busy or already open.
|
|
1454
|
+
if (process.stdin.isTTY) {
|
|
1455
|
+
emitKeypressEvents(process.stdin);
|
|
1456
|
+
process.stdin.on("keypress", (str: string | undefined) => {
|
|
1457
|
+
if (busy || paletteOpen || str !== "/") {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
setImmediate(() => {
|
|
1462
|
+
if (!busy && !paletteOpen && rl.line === "/") {
|
|
1463
|
+
void openPalette();
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1420
1469
|
// Event-driven (not for-await) so stdin is read DURING a run: a line typed
|
|
1421
1470
|
// mid-run is queued to steer the next turn (or, if "/exit", aborts). This is
|
|
1422
1471
|
// what makes it feel like a real harness — you can redirect without waiting.
|
package/src/detect-gate.ts
CHANGED
|
@@ -442,7 +442,8 @@ function packEnvPrefix(
|
|
|
442
442
|
|
|
443
443
|
export function buildWebGate(
|
|
444
444
|
framework: WebFramework,
|
|
445
|
-
packs: readonly string[] = WEB_PACKS
|
|
445
|
+
packs: readonly string[] = WEB_PACKS,
|
|
446
|
+
cwd: string = process.cwd()
|
|
446
447
|
): IGate {
|
|
447
448
|
const template = WEB_TEMPLATES[framework];
|
|
448
449
|
const ignores = template.eslintIgnore
|
|
@@ -478,8 +479,21 @@ export function buildWebGate(
|
|
|
478
479
|
// fails fast.
|
|
479
480
|
const stubs = `bun "${STUB_CHECK}" .`;
|
|
480
481
|
|
|
482
|
+
// Type-aware async correctness (no-floating-promises / no-misused-promises) —
|
|
483
|
+
// the CORE gate already runs this via typeAwareLintPart(), but the web gate
|
|
484
|
+
// historically did not, so a dropped `await` in a handler/effect/mutation passed.
|
|
485
|
+
// Splice it in after the syntactic lint when the scaffold has a tsconfig (it
|
|
486
|
+
// always does), reusing the SHIPPED strict.type-aware config verbatim.
|
|
487
|
+
const typeAware = existsSync(join(cwd, "tsconfig.json"))
|
|
488
|
+
? `bun "${ESLINT_BIN}" --no-config-lookup -c "${TYPE_AWARE_CONFIG}" ${ignores} --format json .`.replace(
|
|
489
|
+
/\s+/g,
|
|
490
|
+
" "
|
|
491
|
+
)
|
|
492
|
+
: null;
|
|
493
|
+
const lintChain = typeAware === null ? lint : `${lint} && ${typeAware}`;
|
|
494
|
+
|
|
481
495
|
return {
|
|
482
|
-
command: `${build} && ${tsc} && ${
|
|
496
|
+
command: `${build} && ${tsc} && ${lintChain} && ${stubs} && ${format} && ${render}`,
|
|
483
497
|
label: `${template.label} (build + behaviour smoke)`,
|
|
484
498
|
};
|
|
485
499
|
}
|
|
@@ -86,4 +86,14 @@ export const META_RULE_DOCS: Record<string, string> = {
|
|
|
86
86
|
|
|
87
87
|
"no-github-context-in-shell":
|
|
88
88
|
"Pass github.event values through env: instead of interpolating them directly in run: shell scripts.",
|
|
89
|
+
|
|
90
|
+
// Container
|
|
91
|
+
"dockerfile-base-image-pinned":
|
|
92
|
+
"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.",
|
|
93
|
+
|
|
94
|
+
"dockerfile-non-root-user":
|
|
95
|
+
"Add a non-root USER instruction (after the install steps) so the container process does not run as root.",
|
|
96
|
+
|
|
97
|
+
"dockerfile-no-secrets-in-env-arg":
|
|
98
|
+
"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
99
|
};
|
|
@@ -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
|
|
@@ -133,6 +133,55 @@ function collectWorkflowFiles(root: string): string[] {
|
|
|
133
133
|
return out.sort();
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** True for a Dockerfile-shaped name: `Dockerfile`, `Dockerfile.<x>`, `<x>.Dockerfile`. */
|
|
137
|
+
function isDockerfileName(entry: string): boolean {
|
|
138
|
+
return (
|
|
139
|
+
entry === "Dockerfile" ||
|
|
140
|
+
entry.startsWith("Dockerfile.") ||
|
|
141
|
+
entry.endsWith(".Dockerfile")
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Dockerfiles at the root and one directory level down (e.g. docker/, apps/*). */
|
|
146
|
+
function collectDockerfiles(root: string): string[] {
|
|
147
|
+
const out: string[] = [];
|
|
148
|
+
|
|
149
|
+
const scanDir = (dir: string, relBase: string): void => {
|
|
150
|
+
let entries: string[];
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
entries = readdirSync(dir);
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (IGNORE_SEGMENTS.has(entry)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const full = join(dir, entry);
|
|
164
|
+
const rel = relBase === "" ? entry : join(relBase, entry);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const stat = statSync(full);
|
|
168
|
+
|
|
169
|
+
if (stat.isFile() && isDockerfileName(entry)) {
|
|
170
|
+
out.push(rel);
|
|
171
|
+
} else if (stat.isDirectory() && relBase === "") {
|
|
172
|
+
scanDir(full, entry); // one level only
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Skip unreadable entries
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
scanDir(root, "");
|
|
181
|
+
|
|
182
|
+
return out.sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
136
185
|
/** Parse package.json, returning null on error. */
|
|
137
186
|
function parsePackageJson(root: string): Record<string, unknown> | null {
|
|
138
187
|
const pkgPath = join(root, "package.json");
|
|
@@ -196,6 +245,7 @@ export function buildMetaRuleContext(
|
|
|
196
245
|
sourceFiles: collectSourceFiles(root),
|
|
197
246
|
configFiles: collectConfigFiles(root),
|
|
198
247
|
workflowFiles: collectWorkflowFiles(root),
|
|
248
|
+
dockerfiles: collectDockerfiles(root),
|
|
199
249
|
activePacks,
|
|
200
250
|
readFile,
|
|
201
251
|
};
|
|
@@ -10,7 +10,8 @@ export type MetaRuleCategory =
|
|
|
10
10
|
| "source-text"
|
|
11
11
|
| "testing"
|
|
12
12
|
| "stack-layout"
|
|
13
|
-
| "ci"
|
|
13
|
+
| "ci"
|
|
14
|
+
| "container";
|
|
14
15
|
|
|
15
16
|
/** A single rule violation (file, rule, message). */
|
|
16
17
|
export interface IMetaRuleViolation {
|
|
@@ -30,6 +31,7 @@ export interface IMetaRuleContext {
|
|
|
30
31
|
readonly sourceFiles: readonly string[]; // repo-relative .ts/.tsx
|
|
31
32
|
readonly configFiles: readonly string[]; // tsconfig*, eslint*, package.json, *.config.*
|
|
32
33
|
readonly workflowFiles: readonly string[]; // .github/workflows/*.yml|yaml
|
|
34
|
+
readonly dockerfiles: readonly string[]; // Dockerfile, Dockerfile.*, *.Dockerfile (root + 1 level)
|
|
33
35
|
readonly activePacks: readonly string[]; // pack ids from stack detection
|
|
34
36
|
readonly readFile: (relPath: string) => string | null; // cached, safe
|
|
35
37
|
}
|
|
@@ -25,6 +25,9 @@ import { workflowPermissionsExplicitRule } from "./rules/ci/workflow-permissions
|
|
|
25
25
|
import { workflowPermissionsLeastPrivilegeRule } from "./rules/ci/workflow-permissions-least-privilege";
|
|
26
26
|
import { noPullRequestTargetUntrustedCheckoutRule } from "./rules/ci/no-pull-request-target-untrusted-checkout";
|
|
27
27
|
import { noGithubContextInShellRule } from "./rules/ci/no-github-context-in-shell";
|
|
28
|
+
import { dockerfileBaseImagePinnedRule } from "./rules/docker/dockerfile-base-image-pinned";
|
|
29
|
+
import { dockerfileNonRootUserRule } from "./rules/docker/dockerfile-non-root-user";
|
|
30
|
+
import { dockerfileNoSecretsInEnvArgRule } from "./rules/docker/dockerfile-no-secrets-in-env-arg";
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* All available meta-rules, ordered by category for readability.
|
|
@@ -66,4 +69,9 @@ export const META_RULES: readonly IMetaRule[] = [
|
|
|
66
69
|
workflowPermissionsLeastPrivilegeRule,
|
|
67
70
|
noPullRequestTargetUntrustedCheckoutRule,
|
|
68
71
|
noGithubContextInShellRule,
|
|
72
|
+
|
|
73
|
+
// Container
|
|
74
|
+
dockerfileBaseImagePinnedRule,
|
|
75
|
+
dockerfileNonRootUserRule,
|
|
76
|
+
dockerfileNoSecretsInEnvArgRule,
|
|
69
77
|
];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
import { dockerInstructionLines } from "./utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every `FROM` must pin its base image to an explicit, non-floating tag (or a
|
|
6
|
+
* digest). `latest` (or no tag) changes underneath you between builds with no
|
|
7
|
+
* diff — non-reproducible images and silent base-image drift.
|
|
8
|
+
*/
|
|
9
|
+
const FROM_PATTERN = /^(?<ref>\S+)(?:\s+[Aa][Ss]\s+(?<stage>\S+))?/u;
|
|
10
|
+
|
|
11
|
+
/** The tag of an image ref, or null when untagged. Splits on the LAST `/` so a
|
|
12
|
+
* registry host:port (which also contains `:`) is not mistaken for a tag. */
|
|
13
|
+
function imageTag(ref: string): string | null {
|
|
14
|
+
const lastSlash = ref.lastIndexOf("/");
|
|
15
|
+
const finalSegment = lastSlash === -1 ? ref : ref.slice(lastSlash + 1);
|
|
16
|
+
const colon = finalSegment.indexOf(":");
|
|
17
|
+
|
|
18
|
+
return colon === -1 ? null : finalSegment.slice(colon + 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const dockerfileBaseImagePinnedRule: IMetaRule = {
|
|
22
|
+
id: "dockerfile-base-image-pinned",
|
|
23
|
+
category: "container",
|
|
24
|
+
description:
|
|
25
|
+
"Dockerfile FROM instructions must pin an explicit non-latest tag (or a digest) so image builds are reproducible.",
|
|
26
|
+
severity: "error",
|
|
27
|
+
run(ctx) {
|
|
28
|
+
const violations: IMetaRuleViolation[] = [];
|
|
29
|
+
const stages = new Set<string>();
|
|
30
|
+
|
|
31
|
+
for (const line of dockerInstructionLines(ctx)) {
|
|
32
|
+
if (line.instruction !== "FROM") {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const match = FROM_PATTERN.exec(line.args);
|
|
37
|
+
const ref = match?.groups?.ref;
|
|
38
|
+
const stage = match?.groups?.stage;
|
|
39
|
+
|
|
40
|
+
if (stage !== undefined) {
|
|
41
|
+
stages.add(stage.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (ref === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Skip references to an earlier build stage and the empty `scratch` base.
|
|
49
|
+
if (stages.has(ref.toLowerCase()) || ref.toLowerCase() === "scratch") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pinnedByDigest = ref.includes("@sha256:");
|
|
54
|
+
const tag = imageTag(ref);
|
|
55
|
+
|
|
56
|
+
if (pinnedByDigest || (tag !== null && tag !== "latest")) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const reason =
|
|
61
|
+
tag === "latest" ? "uses the floating `latest` tag" : "has no tag";
|
|
62
|
+
|
|
63
|
+
violations.push({
|
|
64
|
+
file: line.file,
|
|
65
|
+
ruleId: "dockerfile-base-image-pinned",
|
|
66
|
+
severity: "error",
|
|
67
|
+
message: `Line ${line.lineNo}: \`FROM ${ref}\` ${reason} — pin an explicit version (e.g. \`node:24.3.0-bookworm\`) or a \`@sha256:\` digest for reproducible builds.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return violations;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
import { dockerInstructionLines } from "./utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Secrets must not be baked into the image via `ENV`/`ARG` literals — they
|
|
6
|
+
* persist in every image layer and `docker history`, readable by anyone who
|
|
7
|
+
* pulls the image. Inject them at runtime (`--env-file`, a secret manager, or
|
|
8
|
+
* BuildKit `--secret`) instead.
|
|
9
|
+
*/
|
|
10
|
+
const SECRET_NAME =
|
|
11
|
+
/(^|_)(KEY|TOKEN|SECRET|SECRETS|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS)(_|$)/u;
|
|
12
|
+
|
|
13
|
+
/** The `NAME=value` (or `NAME value`) pairs declared on one ENV/ARG line. */
|
|
14
|
+
function declaredName(args: string): string | null {
|
|
15
|
+
const eq = args.indexOf("=");
|
|
16
|
+
const name = eq === -1 ? args.split(/\s+/u)[0] : args.slice(0, eq);
|
|
17
|
+
|
|
18
|
+
return name === undefined || name.length === 0 ? null : name.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True when the line actually assigns a value (vs. a bare build-time `ARG`). */
|
|
22
|
+
function hasAssignedValue(instruction: string, args: string): boolean {
|
|
23
|
+
if (args.includes("=")) {
|
|
24
|
+
const value = args.slice(args.indexOf("=") + 1).trim();
|
|
25
|
+
|
|
26
|
+
return value.length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `ENV NAME value` form assigns; bare `ARG NAME` does not.
|
|
30
|
+
return instruction === "ENV" && /\S+\s+\S/u.test(args);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const dockerfileNoSecretsInEnvArgRule: IMetaRule = {
|
|
34
|
+
id: "dockerfile-no-secrets-in-env-arg",
|
|
35
|
+
category: "container",
|
|
36
|
+
description:
|
|
37
|
+
"Dockerfiles must not assign secret-looking ENV/ARG values (KEY/TOKEN/SECRET/PASSWORD) — they bake into image layers. Inject secrets at runtime.",
|
|
38
|
+
severity: "error",
|
|
39
|
+
run(ctx) {
|
|
40
|
+
const violations: IMetaRuleViolation[] = [];
|
|
41
|
+
|
|
42
|
+
for (const line of dockerInstructionLines(ctx)) {
|
|
43
|
+
if (line.instruction !== "ENV" && line.instruction !== "ARG") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const name = declaredName(line.args);
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
name === null ||
|
|
51
|
+
!SECRET_NAME.test(name.toUpperCase()) ||
|
|
52
|
+
!hasAssignedValue(line.instruction, line.args)
|
|
53
|
+
) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
violations.push({
|
|
58
|
+
file: line.file,
|
|
59
|
+
ruleId: "dockerfile-no-secrets-in-env-arg",
|
|
60
|
+
severity: "error",
|
|
61
|
+
message: `Line ${line.lineNo}: \`${line.instruction} ${name}=…\` bakes a secret into the image layers (visible in \`docker history\`). Inject it at runtime via \`--env-file\`/secret manager or a BuildKit \`--secret\` mount.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return violations;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
import { dockerInstructionLines } from "./utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A Dockerfile must drop privileges with a non-root `USER` instruction. Running
|
|
6
|
+
* the container process as root is the default and a standard container-escape
|
|
7
|
+
* amplifier; a single `USER app` (after install steps) closes it.
|
|
8
|
+
*/
|
|
9
|
+
const ROOT_USERS = new Set(["root", "0", "0:0"]);
|
|
10
|
+
|
|
11
|
+
/** The user name/uid from a `USER` arg (`USER node` / `USER node:node`). */
|
|
12
|
+
function userName(args: string): string {
|
|
13
|
+
return args.trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const dockerfileNonRootUserRule: IMetaRule = {
|
|
17
|
+
id: "dockerfile-non-root-user",
|
|
18
|
+
category: "container",
|
|
19
|
+
description:
|
|
20
|
+
"Dockerfiles must declare a non-root USER so the container process does not run as root.",
|
|
21
|
+
severity: "error",
|
|
22
|
+
run(ctx) {
|
|
23
|
+
const violations: IMetaRuleViolation[] = [];
|
|
24
|
+
const lines = dockerInstructionLines(ctx);
|
|
25
|
+
const byFile = new Map<string, boolean>();
|
|
26
|
+
|
|
27
|
+
// Seed every READABLE Dockerfile as "no non-root USER seen yet" (readFile is
|
|
28
|
+
// cached, so this does not re-hit disk).
|
|
29
|
+
for (const file of ctx.dockerfiles) {
|
|
30
|
+
if (ctx.readFile(file) !== null) {
|
|
31
|
+
byFile.set(file, false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (line.instruction !== "USER") {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!ROOT_USERS.has(userName(line.args))) {
|
|
41
|
+
byFile.set(line.file, true);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [file, hasNonRoot] of byFile) {
|
|
46
|
+
if (!hasNonRoot) {
|
|
47
|
+
violations.push({
|
|
48
|
+
file,
|
|
49
|
+
ruleId: "dockerfile-non-root-user",
|
|
50
|
+
severity: "error",
|
|
51
|
+
message: `${file} never drops to a non-root USER — add \`USER <non-root>\` after the install steps so the container does not run as root.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return violations;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IMetaRuleContext } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
/** One meaningful Dockerfile instruction line (comments + blanks stripped). */
|
|
4
|
+
export interface IDockerLine {
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly lineNo: number; // 1-based
|
|
7
|
+
readonly instruction: string; // upper-cased keyword, e.g. "FROM"
|
|
8
|
+
readonly args: string; // everything after the keyword, trimmed
|
|
9
|
+
readonly raw: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const INSTRUCTION_PATTERN = /^\s*(?<keyword>[A-Za-z]+)\s+(?<args>.*\S)\s*$/u;
|
|
13
|
+
|
|
14
|
+
/** Parse every Dockerfile in the context into instruction lines. Continuation
|
|
15
|
+
* lines (`\` at EOL) and comments are skipped — good enough for the textual
|
|
16
|
+
* hardening checks (base-image pin, USER, secret literals). */
|
|
17
|
+
export function dockerInstructionLines(
|
|
18
|
+
ctx: Pick<IMetaRuleContext, "dockerfiles" | "readFile">
|
|
19
|
+
): IDockerLine[] {
|
|
20
|
+
const out: IDockerLine[] = [];
|
|
21
|
+
|
|
22
|
+
for (const file of ctx.dockerfiles) {
|
|
23
|
+
const text = ctx.readFile(file);
|
|
24
|
+
|
|
25
|
+
if (text === null) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
32
|
+
const raw = lines[i] ?? "";
|
|
33
|
+
const trimmed = raw.trim();
|
|
34
|
+
|
|
35
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const match = INSTRUCTION_PATTERN.exec(raw);
|
|
40
|
+
const keyword = match?.groups?.keyword;
|
|
41
|
+
const args = match?.groups?.args;
|
|
42
|
+
|
|
43
|
+
if (keyword === undefined || args === undefined) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
out.push({
|
|
48
|
+
file,
|
|
49
|
+
lineNo: i + 1,
|
|
50
|
+
instruction: keyword.toUpperCase(),
|
|
51
|
+
args: args.trim(),
|
|
52
|
+
raw,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { emitKeypressEvents } from "node:readline";
|
|
2
|
+
import { STYLE, paint } from "./style";
|
|
3
|
+
import { COMMANDS, type ICommandSpec } from "../cli/commands";
|
|
4
|
+
|
|
5
|
+
const ESC = String.fromCharCode(27);
|
|
6
|
+
// Render the palette on the terminal's ALTERNATE screen buffer: enter on open,
|
|
7
|
+
// clear+home before each frame, exit on close — which restores the previous screen
|
|
8
|
+
// (conversation + status bar) verbatim. This avoids in-place cursor math fighting
|
|
9
|
+
// the status bar's scroll region (which caused frames to stack instead of redraw).
|
|
10
|
+
const ENTER_ALT = `${ESC}[?1049h${ESC}[r`; // alt screen + reset scroll margins
|
|
11
|
+
const EXIT_ALT = `${ESC}[?1049l`;
|
|
12
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
13
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
14
|
+
const CLEAR_HOME = `${ESC}[2J${ESC}[H`;
|
|
15
|
+
|
|
16
|
+
/** Filter commands by a query (the text typed after `/`). Leading slash and case
|
|
17
|
+
* are ignored; matches commands whose name contains the query. Empty ⇒ all. */
|
|
18
|
+
export function filterCommands(
|
|
19
|
+
commands: readonly ICommandSpec[],
|
|
20
|
+
query: string
|
|
21
|
+
): ICommandSpec[] {
|
|
22
|
+
const q = query.replace(/^\//u, "").toLowerCase();
|
|
23
|
+
|
|
24
|
+
if (q.length === 0) {
|
|
25
|
+
return [...commands];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return commands.filter((c) => c.name.slice(1).toLowerCase().includes(q));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Keep `selected` within `[0, count)` (wraps), so ↑/↓ never points off-list. */
|
|
32
|
+
export function clampIndex(selected: number, count: number): number {
|
|
33
|
+
if (count <= 0) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return ((selected % count) + count) % count;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render the palette as a block of lines (no trailing newline). The header echoes
|
|
41
|
+
* the current filter + key hints; the selected row is brand-highlighted. */
|
|
42
|
+
export function renderMenu(
|
|
43
|
+
items: readonly ICommandSpec[],
|
|
44
|
+
selected: number,
|
|
45
|
+
query: string,
|
|
46
|
+
color: boolean
|
|
47
|
+
): string {
|
|
48
|
+
const header =
|
|
49
|
+
paint(`/${query}`, STYLE.brand, color) +
|
|
50
|
+
paint(
|
|
51
|
+
" ↑/↓ select · type to filter · enter run · esc cancel",
|
|
52
|
+
STYLE.dim,
|
|
53
|
+
color
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (items.length === 0) {
|
|
57
|
+
return `${header}\n ${paint("no matching command", STYLE.dim, color)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rows = items.map((c, i) => {
|
|
61
|
+
const active = i === selected;
|
|
62
|
+
const gutter = active ? paint("›", STYLE.brand, color) : " ";
|
|
63
|
+
const label = c.arg === undefined ? c.name : `${c.name} ${c.arg}`;
|
|
64
|
+
const name = paint(label, active ? STYLE.brand : STYLE.bold, color);
|
|
65
|
+
|
|
66
|
+
return `${gutter} ${name} ${paint(c.summary, STYLE.dim, color)}`;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return [header, ...rows].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One keypress, as decoded by readline's `emitKeypressEvents`. */
|
|
73
|
+
interface IKeyInfo {
|
|
74
|
+
readonly name?: string;
|
|
75
|
+
readonly ctrl?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The interactive `/` palette. Owns `keypress` input for its lifetime — it detaches
|
|
80
|
+
* the existing keypress listeners (readline's line editor + the REPL's `/` trigger)
|
|
81
|
+
* so they don't also react, renders a navigable list, and resolves to the chosen
|
|
82
|
+
* command or null (Esc / Ctrl-C / backspace-past-empty). `finish()` ALWAYS restores
|
|
83
|
+
* the saved listeners, so input returns to normal. No-ops to null off a TTY.
|
|
84
|
+
*
|
|
85
|
+
* Note: stdin stays in the raw, flowing mode readline already set — we only swap
|
|
86
|
+
* WHO listens, never toggle raw mode, so the terminal can't be left wedged.
|
|
87
|
+
*/
|
|
88
|
+
export function pickCommand(
|
|
89
|
+
color: boolean,
|
|
90
|
+
out: (s: string) => void = (s) => process.stdout.write(s)
|
|
91
|
+
): Promise<ICommandSpec | null> {
|
|
92
|
+
const stdin = process.stdin;
|
|
93
|
+
|
|
94
|
+
if (!stdin.isTTY) {
|
|
95
|
+
return Promise.resolve(null);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
let query = "";
|
|
100
|
+
let selected = 0;
|
|
101
|
+
|
|
102
|
+
emitKeypressEvents(stdin);
|
|
103
|
+
|
|
104
|
+
// Take over keypress for the palette's lifetime: stash + detach the current
|
|
105
|
+
// listeners (readline's editor + the REPL `/` trigger) so only `onKey` reacts;
|
|
106
|
+
// restored in finish().
|
|
107
|
+
const saved = stdin.rawListeners("keypress");
|
|
108
|
+
|
|
109
|
+
stdin.removeAllListeners("keypress");
|
|
110
|
+
|
|
111
|
+
const draw = (): void => {
|
|
112
|
+
const items = filterCommands(COMMANDS, query);
|
|
113
|
+
|
|
114
|
+
selected = clampIndex(selected, items.length);
|
|
115
|
+
|
|
116
|
+
out(`${CLEAR_HOME}${renderMenu(items, selected, query, color)}`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const finish = (result: ICommandSpec | null): void => {
|
|
120
|
+
stdin.removeListener("keypress", onKey);
|
|
121
|
+
out(`${SHOW_CURSOR}${EXIT_ALT}`); // restore the previous screen verbatim
|
|
122
|
+
|
|
123
|
+
// Restore the listeners we detached (readline's editor + the REPL trigger),
|
|
124
|
+
// forwarding through a thin wrapper so we don't fight the Function[] type.
|
|
125
|
+
for (const l of saved) {
|
|
126
|
+
stdin.on("keypress", (...args: unknown[]) => {
|
|
127
|
+
Reflect.apply(l, stdin, args);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
resolve(result);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onKey = (str: string | undefined, key: IKeyInfo): void => {
|
|
135
|
+
try {
|
|
136
|
+
if ((key.ctrl === true && key.name === "c") || key.name === "escape") {
|
|
137
|
+
finish(null);
|
|
138
|
+
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const items = filterCommands(COMMANDS, query);
|
|
143
|
+
|
|
144
|
+
if (key.name === "return" || key.name === "enter") {
|
|
145
|
+
finish(items[clampIndex(selected, items.length)] ?? null);
|
|
146
|
+
} else if (key.name === "up") {
|
|
147
|
+
selected -= 1;
|
|
148
|
+
draw();
|
|
149
|
+
} else if (key.name === "down") {
|
|
150
|
+
selected += 1;
|
|
151
|
+
draw();
|
|
152
|
+
} else if (key.name === "backspace") {
|
|
153
|
+
if (query.length === 0) {
|
|
154
|
+
finish(null); // backspace past the slash closes the palette
|
|
155
|
+
} else {
|
|
156
|
+
query = query.slice(0, -1);
|
|
157
|
+
selected = 0;
|
|
158
|
+
draw();
|
|
159
|
+
}
|
|
160
|
+
} else if (key.ctrl !== true && str?.length === 1 && str >= " ") {
|
|
161
|
+
query += str;
|
|
162
|
+
selected = 0;
|
|
163
|
+
draw();
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
finish(null); // never let a render error wedge input
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
stdin.on("keypress", onKey);
|
|
171
|
+
out(`${ENTER_ALT}${HIDE_CURSOR}`);
|
|
172
|
+
draw();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { noApiKeyInClientRule } from "./rules/no-api-key-in-client";
|
|
4
|
+
import { requireCompletionTokenLimitRule } from "./rules/require-completion-token-limit";
|
|
5
|
+
import { noUserInputInSystemPromptRule } from "./rules/no-user-input-in-system-prompt";
|
|
6
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
7
|
+
|
|
8
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
9
|
+
"no-api-key-in-client": noApiKeyInClientRule,
|
|
10
|
+
"require-completion-token-limit": requireCompletionTokenLimitRule,
|
|
11
|
+
"no-user-input-in-system-prompt": noUserInputInSystemPromptRule,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const aiSdkPack: IRulePack = {
|
|
15
|
+
id: "ai-sdk",
|
|
16
|
+
description:
|
|
17
|
+
"LLM/AI-SDK security and cost guardrails: no provider key in client bundles, bounded completion tokens, and no request data spliced into the system prompt",
|
|
18
|
+
rules,
|
|
19
|
+
// Structural checks block (error); the injection heuristic warns until proven
|
|
20
|
+
// precise — a false positive on an un-bypassable gate would deadlock the model.
|
|
21
|
+
rulesConfig: {
|
|
22
|
+
"no-api-key-in-client": "error",
|
|
23
|
+
"require-completion-token-limit": "error",
|
|
24
|
+
"no-user-input-in-system-prompt": "warn",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default aiSdkPack;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "no-api-key-in-client";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "clientProvider";
|
|
8
|
+
|
|
9
|
+
// Providers whose constructor / factory takes an API key. Building one in a
|
|
10
|
+
// `"use client"` file ships the key into the browser bundle.
|
|
11
|
+
const PROVIDER_CONSTRUCTORS = new Set([
|
|
12
|
+
"OpenAI",
|
|
13
|
+
"Anthropic",
|
|
14
|
+
"GoogleGenerativeAI",
|
|
15
|
+
]);
|
|
16
|
+
const PROVIDER_FACTORIES = new Set([
|
|
17
|
+
"createOpenAI",
|
|
18
|
+
"createAnthropic",
|
|
19
|
+
"createGoogleGenerativeAI",
|
|
20
|
+
"createAzure",
|
|
21
|
+
"createMistral",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** True when the file opens with a `"use client"` directive (client component). */
|
|
25
|
+
function hasUseClientDirective(
|
|
26
|
+
body: readonly TSESTree.ProgramStatement[]
|
|
27
|
+
): boolean {
|
|
28
|
+
for (const stmt of body) {
|
|
29
|
+
if (stmt.type !== AST_NODE_TYPES.ExpressionStatement) {
|
|
30
|
+
return false; // directives must lead; first non-expression ends the prologue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const expr = stmt.expression;
|
|
34
|
+
|
|
35
|
+
if (expr.type === AST_NODE_TYPES.Literal && expr.value === "use client") {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `new OpenAI(...)` etc. */
|
|
44
|
+
function isProviderConstruction(node: TSESTree.NewExpression): boolean {
|
|
45
|
+
return (
|
|
46
|
+
node.callee.type === AST_NODE_TYPES.Identifier &&
|
|
47
|
+
PROVIDER_CONSTRUCTORS.has(node.callee.name)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `createOpenAI(...)` etc. */
|
|
52
|
+
function isProviderFactory(node: TSESTree.CallExpression): boolean {
|
|
53
|
+
return (
|
|
54
|
+
node.callee.type === AST_NODE_TYPES.Identifier &&
|
|
55
|
+
PROVIDER_FACTORIES.has(node.callee.name)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const noApiKeyInClientRule = createRule<[], MessageIds>({
|
|
60
|
+
name: RULE_NAME,
|
|
61
|
+
meta: {
|
|
62
|
+
type: "problem",
|
|
63
|
+
docs: {
|
|
64
|
+
description:
|
|
65
|
+
"Disallow constructing an AI provider client in a client component — it leaks the API key into the browser bundle. Call the model from a server route/action.",
|
|
66
|
+
},
|
|
67
|
+
schema: [],
|
|
68
|
+
messages: {
|
|
69
|
+
clientProvider:
|
|
70
|
+
"Do not create an AI provider client in a `'use client'` file — the API key would ship to the browser. Move the call to a server route or server action.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
defaultOptions: [],
|
|
74
|
+
create(context) {
|
|
75
|
+
if (!hasUseClientDirective(context.sourceCode.ast.body)) {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
NewExpression(node: TSESTree.NewExpression) {
|
|
81
|
+
if (isProviderConstruction(node)) {
|
|
82
|
+
context.report({ node, messageId: "clientProvider" });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
86
|
+
if (isProviderFactory(node)) {
|
|
87
|
+
context.report({ node, messageId: "clientProvider" });
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "no-user-input-in-system-prompt";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "dynamicSystemPrompt";
|
|
8
|
+
|
|
9
|
+
/** A value built by interpolation/concatenation rather than a constant string —
|
|
10
|
+
* the shape that splices request data into the system prompt (injection). A
|
|
11
|
+
* plain string, identifier, or constant template (no `${}`) is fine. */
|
|
12
|
+
function isDynamicString(node: TSESTree.Node): boolean {
|
|
13
|
+
if (node.type === AST_NODE_TYPES.TemplateLiteral) {
|
|
14
|
+
return node.expressions.length > 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "+";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Find a non-computed string-keyed property on an object literal. */
|
|
21
|
+
function findProperty(
|
|
22
|
+
obj: TSESTree.ObjectExpression,
|
|
23
|
+
name: string
|
|
24
|
+
): TSESTree.Property | null {
|
|
25
|
+
for (const p of obj.properties) {
|
|
26
|
+
if (
|
|
27
|
+
p.type === AST_NODE_TYPES.Property &&
|
|
28
|
+
!p.computed &&
|
|
29
|
+
p.key.type === AST_NODE_TYPES.Identifier &&
|
|
30
|
+
p.key.name === name
|
|
31
|
+
) {
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the object is a chat message with `role: "system"`. */
|
|
40
|
+
function isSystemMessage(obj: TSESTree.ObjectExpression): boolean {
|
|
41
|
+
const role = findProperty(obj, "role");
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
role !== null &&
|
|
45
|
+
role.value.type === AST_NODE_TYPES.Literal &&
|
|
46
|
+
role.value.value === "system"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const noUserInputInSystemPromptRule = createRule<[], MessageIds>({
|
|
51
|
+
name: RULE_NAME,
|
|
52
|
+
meta: {
|
|
53
|
+
type: "suggestion",
|
|
54
|
+
docs: {
|
|
55
|
+
description:
|
|
56
|
+
"Warn when a system prompt is built by string interpolation/concatenation — splicing request data into the system role enables prompt injection. Keep the system prompt constant; pass user input as a user message.",
|
|
57
|
+
},
|
|
58
|
+
schema: [],
|
|
59
|
+
messages: {
|
|
60
|
+
dynamicSystemPrompt:
|
|
61
|
+
"System prompt is built dynamically — do not interpolate request/user data into the system role (prompt injection). Keep it a constant and pass user input as a `user` message.",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
defaultOptions: [],
|
|
65
|
+
create(context) {
|
|
66
|
+
const reportIfDynamic = (value: TSESTree.Node | null): void => {
|
|
67
|
+
if (value !== null && isDynamicString(value)) {
|
|
68
|
+
context.report({ node: value, messageId: "dynamicSystemPrompt" });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
// Vercel AI SDK: `{ system: `...${x}...` }`
|
|
74
|
+
"Property[key.name='system']"(node: TSESTree.Property) {
|
|
75
|
+
if (!node.computed) {
|
|
76
|
+
reportIfDynamic(node.value);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
// Chat messages: `{ role: "system", content: `...${x}...` }`
|
|
80
|
+
ObjectExpression(node: TSESTree.ObjectExpression) {
|
|
81
|
+
if (!isSystemMessage(node)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const content = findProperty(node, "content");
|
|
86
|
+
|
|
87
|
+
reportIfDynamic(content === null ? null : content.value);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "require-completion-token-limit";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "missingLimit";
|
|
8
|
+
|
|
9
|
+
// Vercel AI SDK top-level generators.
|
|
10
|
+
const VERCEL_FNS = new Set([
|
|
11
|
+
"generateText",
|
|
12
|
+
"streamText",
|
|
13
|
+
"generateObject",
|
|
14
|
+
"streamObject",
|
|
15
|
+
]);
|
|
16
|
+
// Provider-SDK members that own a `.create(...)` completion call.
|
|
17
|
+
const CREATE_OWNERS = new Set(["completions", "messages", "responses"]);
|
|
18
|
+
// Any of these keys bounds the output, across SDKs.
|
|
19
|
+
const TOKEN_KEYS = new Set([
|
|
20
|
+
"maxTokens",
|
|
21
|
+
"max_tokens",
|
|
22
|
+
"maxOutputTokens",
|
|
23
|
+
"max_output_tokens",
|
|
24
|
+
"max_completion_tokens",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** The options object literal for a Vercel generator call, or null. */
|
|
28
|
+
function vercelOptionsArg(
|
|
29
|
+
node: TSESTree.CallExpression
|
|
30
|
+
): TSESTree.ObjectExpression | null {
|
|
31
|
+
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!VERCEL_FNS.has(node.callee.name)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const arg = node.arguments[0];
|
|
40
|
+
|
|
41
|
+
return arg?.type === AST_NODE_TYPES.ObjectExpression ? arg : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The options object for an `x.<owner>.create({...})` SDK call, or null. */
|
|
45
|
+
function createCallOptionsArg(
|
|
46
|
+
node: TSESTree.CallExpression
|
|
47
|
+
): TSESTree.ObjectExpression | null {
|
|
48
|
+
const callee = node.callee;
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
52
|
+
callee.computed ||
|
|
53
|
+
callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
54
|
+
callee.property.name !== "create"
|
|
55
|
+
) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const owner = callee.object;
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
owner.type !== AST_NODE_TYPES.MemberExpression ||
|
|
63
|
+
owner.computed ||
|
|
64
|
+
owner.property.type !== AST_NODE_TYPES.Identifier ||
|
|
65
|
+
!CREATE_OWNERS.has(owner.property.name)
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const arg = node.arguments[0];
|
|
71
|
+
|
|
72
|
+
return arg?.type === AST_NODE_TYPES.ObjectExpression ? arg : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when the object literal sets one of the recognized token-limit keys. */
|
|
76
|
+
function hasTokenLimit(obj: TSESTree.ObjectExpression): boolean {
|
|
77
|
+
return obj.properties.some(
|
|
78
|
+
(p) =>
|
|
79
|
+
p.type === AST_NODE_TYPES.Property &&
|
|
80
|
+
!p.computed &&
|
|
81
|
+
p.key.type === AST_NODE_TYPES.Identifier &&
|
|
82
|
+
TOKEN_KEYS.has(p.key.name)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const requireCompletionTokenLimitRule = createRule<[], MessageIds>({
|
|
87
|
+
name: RULE_NAME,
|
|
88
|
+
meta: {
|
|
89
|
+
type: "problem",
|
|
90
|
+
docs: {
|
|
91
|
+
description:
|
|
92
|
+
"Require a token limit (maxTokens / max_tokens) on AI completion calls to bound runaway cost and latency.",
|
|
93
|
+
},
|
|
94
|
+
schema: [],
|
|
95
|
+
messages: {
|
|
96
|
+
missingLimit:
|
|
97
|
+
"AI completion call has no token limit — set `maxTokens` (Vercel AI SDK) or `max_tokens` (OpenAI/Anthropic) to bound cost and latency.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
defaultOptions: [],
|
|
101
|
+
create(context) {
|
|
102
|
+
return {
|
|
103
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
104
|
+
const options = vercelOptionsArg(node) ?? createCallOptionsArg(node);
|
|
105
|
+
|
|
106
|
+
if (options !== null && !hasTokenLimit(options)) {
|
|
107
|
+
context.report({ node, messageId: "missingLimit" });
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
package/src/rule-packs/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
2
|
|
|
3
3
|
import type { IRulePack } from "./rule-packs.types";
|
|
4
|
+
import { aiSdkPack } from "./ai-sdk";
|
|
4
5
|
import { authorizationPack } from "./authorization";
|
|
5
6
|
import { bullmqPack } from "./bullmq";
|
|
6
7
|
import { commentHygienePack } from "./comment-hygiene";
|
|
@@ -25,6 +26,7 @@ import { PACK_REGISTRY } from "../stack-detection";
|
|
|
25
26
|
|
|
26
27
|
/** Registry of all available rule packs, keyed by pack ID. */
|
|
27
28
|
export const RULE_PACKS = {
|
|
29
|
+
"ai-sdk": aiSdkPack,
|
|
28
30
|
authorization: authorizationPack,
|
|
29
31
|
bullmq: bullmqPack,
|
|
30
32
|
"code-flow": codeFlowPack,
|
|
@@ -222,6 +222,25 @@ export const PACK_REGISTRY = {
|
|
|
222
222
|
appliesWhen: { anyDeps: ["i18next", "react-i18next"] },
|
|
223
223
|
guidance: "Keep i18n keys organized and validated.",
|
|
224
224
|
} as const satisfies IRulePackDescriptor,
|
|
225
|
+
|
|
226
|
+
"ai-sdk": {
|
|
227
|
+
id: "ai-sdk",
|
|
228
|
+
label: "AI SDK Security",
|
|
229
|
+
description:
|
|
230
|
+
"LLM/AI-SDK security and cost guardrails: no provider key in client bundles, bounded completion tokens, no request data in the system prompt",
|
|
231
|
+
category: "library",
|
|
232
|
+
appliesWhen: {
|
|
233
|
+
anyDeps: [
|
|
234
|
+
"ai",
|
|
235
|
+
"openai",
|
|
236
|
+
"@anthropic-ai/sdk",
|
|
237
|
+
"@ai-sdk/openai",
|
|
238
|
+
"@ai-sdk/anthropic",
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
guidance:
|
|
242
|
+
"Call models server-side, bound output tokens, and keep the system prompt constant.",
|
|
243
|
+
} as const satisfies IRulePackDescriptor,
|
|
225
244
|
} as const;
|
|
226
245
|
|
|
227
246
|
/** Ordered list of always-on pack IDs (for deterministic ordering). */
|
package/strict.eslint.config.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// map of bare rule names to "error" | "warn" | "off").
|
|
14
14
|
import tseslint from "typescript-eslint";
|
|
15
15
|
import stylistic from "@stylistic/eslint-plugin";
|
|
16
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
16
17
|
|
|
17
18
|
// Load stack-aware packs if TSFORGE_PACKS env var is set
|
|
18
19
|
let packConfig = [];
|
|
@@ -56,8 +57,19 @@ export default tseslint.config(
|
|
|
56
57
|
plugins: {
|
|
57
58
|
"@typescript-eslint": tseslint.plugin,
|
|
58
59
|
"@stylistic": stylistic,
|
|
60
|
+
sonarjs,
|
|
59
61
|
},
|
|
60
62
|
rules: {
|
|
63
|
+
// Concern-mixing / copy-paste ceiling (syntactic — no type info needed).
|
|
64
|
+
// cc <= 20 is the house rule tsforge holds ITSELF to (eslint.config.js); it
|
|
65
|
+
// forces the model to decompose a sprawling function into named helpers
|
|
66
|
+
// instead of one un-reviewable block. Not auto-fixable, so it surfaces as a
|
|
67
|
+
// hand-fix error — the intended "split this up" signal. max-depth/max-params
|
|
68
|
+
// are zero-dep ESLint-core complements.
|
|
69
|
+
"sonarjs/cognitive-complexity": ["error", 20],
|
|
70
|
+
"sonarjs/no-identical-functions": "error",
|
|
71
|
+
"max-depth": ["error", 4],
|
|
72
|
+
"max-params": ["error", 4],
|
|
61
73
|
// The idioms the model habitually violates — all caught WITHOUT type info.
|
|
62
74
|
"@typescript-eslint/consistent-type-assertions": [
|
|
63
75
|
"error",
|
|
@@ -13,6 +13,7 @@ import stylistic from "@stylistic/eslint-plugin";
|
|
|
13
13
|
import pluginReact from "eslint-plugin-react";
|
|
14
14
|
import pluginReactHooks from "eslint-plugin-react-hooks";
|
|
15
15
|
import pluginJsxA11y from "eslint-plugin-jsx-a11y";
|
|
16
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
16
17
|
|
|
17
18
|
// Load stack-aware packs if TSFORGE_PACKS env var is set
|
|
18
19
|
let packConfig = [];
|
|
@@ -112,6 +113,7 @@ export default tseslint.config(
|
|
|
112
113
|
"@stylistic": stylistic,
|
|
113
114
|
react: pluginReact,
|
|
114
115
|
"react-hooks": pluginReactHooks,
|
|
116
|
+
sonarjs,
|
|
115
117
|
boringstack: { rules: { "one-component-per-file": oneComponentPerFile } },
|
|
116
118
|
...packConfig
|
|
117
119
|
.filter(
|
|
@@ -125,6 +127,13 @@ export default tseslint.config(
|
|
|
125
127
|
// literal/tuple data (and it makes a fixed array a tuple, so literal-index
|
|
126
128
|
// access is defined, not `T | undefined`). Instead we ban only the
|
|
127
129
|
// value-changing forms (`x as Foo`, `<Foo>x`) via AST selectors below.
|
|
130
|
+
// Concern-mixing / copy-paste ceiling (syntactic — mirrors the core config).
|
|
131
|
+
// cc <= 20 forces decomposition into named helpers; max-depth/max-params are
|
|
132
|
+
// zero-dep ESLint-core complements.
|
|
133
|
+
"sonarjs/cognitive-complexity": ["error", 20],
|
|
134
|
+
"sonarjs/no-identical-functions": "error",
|
|
135
|
+
"max-depth": ["error", 4],
|
|
136
|
+
"max-params": ["error", 4],
|
|
128
137
|
"@typescript-eslint/no-explicit-any": "error",
|
|
129
138
|
"@typescript-eslint/no-non-null-assertion": "error",
|
|
130
139
|
"@typescript-eslint/no-inferrable-types": "error",
|