@agjs/tsforge 0.4.0 → 0.5.1
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/agent/agent.constants.ts +14 -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/loop/tools/execute-tool.ts +1 -0
- package/src/loop/tools/lsp-ops.ts +48 -1
- package/src/lsp/service.ts +69 -1
- 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
|
@@ -30,6 +30,7 @@ const HANDLERS: Record<ToolName, ToolHandler> = {
|
|
|
30
30
|
[TOOL_NAME.typeAt]: (a, c) => doLsp(TOOL_NAME.typeAt, a, c),
|
|
31
31
|
[TOOL_NAME.diagnostics]: (a, c) => doLsp(TOOL_NAME.diagnostics, a, c),
|
|
32
32
|
[TOOL_NAME.renameSymbol]: (a, c) => doLsp(TOOL_NAME.renameSymbol, a, c),
|
|
33
|
+
[TOOL_NAME.moveFile]: (a, c) => doLsp(TOOL_NAME.moveFile, a, c),
|
|
33
34
|
[TOOL_NAME.organizeImports]: (a, c) => doLsp(TOOL_NAME.organizeImports, a, c),
|
|
34
35
|
[TOOL_NAME.scaffoldUi]: doScaffoldUi,
|
|
35
36
|
[TOOL_NAME.scaffoldRoutes]: doScaffoldRoutes,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { fileArg, TOOL_NAME, type ToolName } from "../../agent";
|
|
3
3
|
import { runArgvCommand } from "../../lib/fs";
|
|
4
|
-
import { writable } from "../../lib/scope";
|
|
4
|
+
import { writable, isVendored } from "../../lib/scope";
|
|
5
5
|
import { LOOP_LIMITS } from "../loop.constants";
|
|
6
6
|
import { str, reject, type IToolContext } from "./tool-context";
|
|
7
7
|
|
|
@@ -137,10 +137,57 @@ export function doLsp(
|
|
|
137
137
|
return `organize_imports: ${n} change(s) in ${file}`;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// move_file takes {from, to} (not {file, symbol}) — handle before doSymbolLsp's
|
|
141
|
+
// symbol guard. Scope-enforced: a move rewrites importers across the project; it
|
|
142
|
+
// must NOT touch read-only/out-of-scope/vendored files.
|
|
143
|
+
if (name === TOOL_NAME.moveFile) {
|
|
144
|
+
return doMoveFile(svc, args, ctx, rel);
|
|
145
|
+
}
|
|
146
|
+
|
|
140
147
|
// The remaining tools address a symbol by name within a file.
|
|
141
148
|
return doSymbolLsp(name, svc, file, args, ctx, rel);
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
/** move_file: relocate a file and rewrite every importer's specifier. */
|
|
152
|
+
function doMoveFile(
|
|
153
|
+
svc: LspService,
|
|
154
|
+
args: Record<string, unknown>,
|
|
155
|
+
ctx: IToolContext,
|
|
156
|
+
rel: (abs: string) => string
|
|
157
|
+
): string {
|
|
158
|
+
const from = str(args, "from");
|
|
159
|
+
const to = str(args, "to");
|
|
160
|
+
|
|
161
|
+
if (from.length === 0 || to.length === 0) {
|
|
162
|
+
return "move_file: need {from, to}";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const targets = svc.moveTargets(from, to).map(rel);
|
|
166
|
+
const blocked = targets.filter(
|
|
167
|
+
(t) => !writable(t, ctx.files) || isVendored(t, ctx.vendored ?? [])
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (blocked.length > 0) {
|
|
171
|
+
return reject(
|
|
172
|
+
ctx,
|
|
173
|
+
"move_file",
|
|
174
|
+
`move '${from}' → '${to}' REJECTED: would touch out-of-scope/read-only file(s): ${blocked.join(", ")}. Move files only when the source, destination, and every importer are in your editable scope.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const changed = svc.moveFile(from, to);
|
|
179
|
+
|
|
180
|
+
ctx.report({
|
|
181
|
+
kind: "tool",
|
|
182
|
+
task: ctx.task,
|
|
183
|
+
message: `move_file ${from}→${to} (${changed ?? 0})`,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return changed === null
|
|
187
|
+
? `move_file: source '${from}' not found`
|
|
188
|
+
: `moved '${from}' → '${to}', updated imports across ${changed} file(s)`;
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
type LspService = NonNullable<IToolContext["tsService"]>;
|
|
145
192
|
|
|
146
193
|
/** The LSP tools that resolve a symbol position first (type_at, find_references,
|
package/src/lsp/service.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { join, isAbsolute } from "node:path";
|
|
1
|
+
import { join, isAbsolute, dirname } from "node:path";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
2
3
|
import ts from "typescript";
|
|
3
4
|
import type {
|
|
4
5
|
ITsDiagnostic,
|
|
@@ -451,6 +452,73 @@ export class TsService {
|
|
|
451
452
|
return [...new Set((locs ?? []).map((l) => l.fileName))];
|
|
452
453
|
}
|
|
453
454
|
|
|
455
|
+
/** Edits a file move would produce (rewriting importers + the file's own
|
|
456
|
+
* relative imports). Absolute paths. */
|
|
457
|
+
private moveEdits(
|
|
458
|
+
fromAbs: string,
|
|
459
|
+
toAbs: string
|
|
460
|
+
): readonly ts.FileTextChanges[] {
|
|
461
|
+
return this.service.getEditsForFileRename(
|
|
462
|
+
fromAbs,
|
|
463
|
+
toAbs,
|
|
464
|
+
ts.getDefaultFormatCodeSettings("\n"),
|
|
465
|
+
{}
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Which files a move would touch — every importer plus the source and
|
|
470
|
+
* destination — for scope-checking BEFORE applying. Absolute paths. */
|
|
471
|
+
moveTargets(fromRel: string, toRel: string): string[] {
|
|
472
|
+
const fromAbs = this.toAbs(fromRel);
|
|
473
|
+
const toAbs = this.toAbs(toRel);
|
|
474
|
+
const touched = new Set(
|
|
475
|
+
this.moveEdits(fromAbs, toAbs).map((e) => e.fileName)
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
touched.add(fromAbs);
|
|
479
|
+
touched.add(toAbs);
|
|
480
|
+
|
|
481
|
+
return [...touched];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Move a file and rewrite every import that points at it (and its own relative
|
|
486
|
+
* imports), compiler-accurately via getEditsForFileRename. Returns the number
|
|
487
|
+
* of files changed (incl. the moved file), or null if the source can't be read.
|
|
488
|
+
* Callers MUST enforce scope first (a move can touch read-only files).
|
|
489
|
+
*/
|
|
490
|
+
moveFile(fromRel: string, toRel: string): number | null {
|
|
491
|
+
const fromAbs = this.toAbs(fromRel);
|
|
492
|
+
const toAbs = this.toAbs(toRel);
|
|
493
|
+
const original = ts.sys.readFile(fromAbs);
|
|
494
|
+
|
|
495
|
+
if (original === undefined) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const edits = this.moveEdits(fromAbs, toAbs);
|
|
500
|
+
|
|
501
|
+
// Apply the import rewrites (importers + the moved file's own imports) while
|
|
502
|
+
// the file still lives at its old path, THEN relocate the edited content.
|
|
503
|
+
this.applyChanges(edits);
|
|
504
|
+
|
|
505
|
+
const moved = ts.sys.readFile(fromAbs) ?? original;
|
|
506
|
+
|
|
507
|
+
mkdirSync(dirname(toAbs), { recursive: true });
|
|
508
|
+
ts.sys.writeFile(toAbs, moved);
|
|
509
|
+
rmSync(fromAbs, { force: true });
|
|
510
|
+
|
|
511
|
+
const stale = this.files.indexOf(fromAbs);
|
|
512
|
+
|
|
513
|
+
if (stale >= 0) {
|
|
514
|
+
this.files.splice(stale, 1);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.refresh(toRel);
|
|
518
|
+
|
|
519
|
+
return new Set([...edits.map((e) => e.fileName), fromAbs]).size;
|
|
520
|
+
}
|
|
521
|
+
|
|
454
522
|
/** Organize imports (dedupe/sort/drop unused) for one file. Returns edits made. */
|
|
455
523
|
organizeImports(file: string): number {
|
|
456
524
|
const changes = this.service.organizeImports(
|
|
@@ -28,6 +28,8 @@ import { noGithubContextInShellRule } from "./rules/ci/no-github-context-in-shel
|
|
|
28
28
|
import { dockerfileBaseImagePinnedRule } from "./rules/docker/dockerfile-base-image-pinned";
|
|
29
29
|
import { dockerfileNonRootUserRule } from "./rules/docker/dockerfile-non-root-user";
|
|
30
30
|
import { dockerfileNoSecretsInEnvArgRule } from "./rules/docker/dockerfile-no-secrets-in-env-arg";
|
|
31
|
+
import { noUndeclaredDependenciesRule } from "./rules/supply-chain/no-undeclared-dependencies";
|
|
32
|
+
import { noCircularImportsRule } from "./rules/structure/no-circular-imports";
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* All available meta-rules, ordered by category for readability.
|
|
@@ -45,6 +47,7 @@ export const META_RULES: readonly IMetaRule[] = [
|
|
|
45
47
|
dependencyOverridesRequireCommentRule,
|
|
46
48
|
productionMustNotUseDrizzlePushRule,
|
|
47
49
|
migrationsMustBeCheckedInRule,
|
|
50
|
+
noUndeclaredDependenciesRule,
|
|
48
51
|
|
|
49
52
|
// Source text
|
|
50
53
|
noEslintDisableCommentsRule,
|
|
@@ -74,4 +77,7 @@ export const META_RULES: readonly IMetaRule[] = [
|
|
|
74
77
|
dockerfileBaseImagePinnedRule,
|
|
75
78
|
dockerfileNonRootUserRule,
|
|
76
79
|
dockerfileNoSecretsInEnvArgRule,
|
|
80
|
+
|
|
81
|
+
// Structure (cross-file)
|
|
82
|
+
noCircularImportsRule,
|
|
77
83
|
];
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { dirname, join, normalize } from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
IMetaRule,
|
|
4
|
+
IMetaRuleContext,
|
|
5
|
+
IMetaRuleViolation,
|
|
6
|
+
} from "../../meta-rules.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Circular imports (A → B → A) are a cross-file smell that per-file ESLint cannot
|
|
10
|
+
* see: they cause partially-initialized modules (TDZ/`undefined` at import time),
|
|
11
|
+
* defeat tree-shaking, and make refactors brittle. We build the module graph from
|
|
12
|
+
* the project's own relative imports and report each cyclic group once.
|
|
13
|
+
*
|
|
14
|
+
* Only RELATIVE imports (`./`, `../`) are followed — alias/bare specifiers need a
|
|
15
|
+
* resolver and would risk false positives. That keeps this high-precision: every
|
|
16
|
+
* reported cycle is real, in-project, and the model's to break.
|
|
17
|
+
*/
|
|
18
|
+
const IMPORT_FROM = /(?:import|export)[^'"]*?from\s*['"](?<spec>[^'"]+)['"]/gu;
|
|
19
|
+
const DYNAMIC_IMPORT = /\bimport\s*\(\s*['"](?<spec>[^'"]+)['"]\s*\)/gu;
|
|
20
|
+
|
|
21
|
+
/** Relative import specifiers (`./x`, `../y`) found in one file's text. */
|
|
22
|
+
function relativeSpecifiers(text: string): string[] {
|
|
23
|
+
const out: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const re of [IMPORT_FROM, DYNAMIC_IMPORT]) {
|
|
26
|
+
re.lastIndex = 0;
|
|
27
|
+
|
|
28
|
+
for (const m of text.matchAll(re)) {
|
|
29
|
+
const spec = m.groups?.spec;
|
|
30
|
+
|
|
31
|
+
if (spec?.startsWith(".") === true) {
|
|
32
|
+
out.push(spec);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve a relative specifier to a known source file, or null. */
|
|
41
|
+
function resolveToSourceFile(
|
|
42
|
+
fromFile: string,
|
|
43
|
+
spec: string,
|
|
44
|
+
fileSet: ReadonlySet<string>
|
|
45
|
+
): string | null {
|
|
46
|
+
const base = normalize(join(dirname(fromFile), spec))
|
|
47
|
+
.split("\\")
|
|
48
|
+
.join("/");
|
|
49
|
+
const candidates =
|
|
50
|
+
base.endsWith(".ts") || base.endsWith(".tsx")
|
|
51
|
+
? [base]
|
|
52
|
+
: [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`];
|
|
53
|
+
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (fileSet.has(candidate)) {
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Adjacency list of in-project module edges. */
|
|
64
|
+
function buildGraph(ctx: IMetaRuleContext): Map<string, string[]> {
|
|
65
|
+
const fileSet = new Set(ctx.sourceFiles);
|
|
66
|
+
const graph = new Map<string, string[]>();
|
|
67
|
+
|
|
68
|
+
for (const file of ctx.sourceFiles) {
|
|
69
|
+
const text = ctx.readFile(file);
|
|
70
|
+
const edges: string[] = [];
|
|
71
|
+
|
|
72
|
+
if (text !== null) {
|
|
73
|
+
for (const spec of relativeSpecifiers(text)) {
|
|
74
|
+
const target = resolveToSourceFile(file, spec, fileSet);
|
|
75
|
+
|
|
76
|
+
if (target !== null && target !== file) {
|
|
77
|
+
edges.push(target);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
graph.set(file, edges);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return graph;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ITarjanState {
|
|
89
|
+
readonly index: Map<string, number>;
|
|
90
|
+
readonly low: Map<string, number>;
|
|
91
|
+
readonly onStack: Set<string>;
|
|
92
|
+
readonly stack: string[];
|
|
93
|
+
readonly components: string[][];
|
|
94
|
+
counter: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tarjan's strongly-connected-components — each SCC with >1 node (or a self-loop)
|
|
98
|
+
* is an import cycle. Iterative-free recursion is fine for project-sized graphs. */
|
|
99
|
+
function strongConnect(
|
|
100
|
+
node: string,
|
|
101
|
+
graph: Map<string, string[]>,
|
|
102
|
+
state: ITarjanState
|
|
103
|
+
): void {
|
|
104
|
+
state.index.set(node, state.counter);
|
|
105
|
+
state.low.set(node, state.counter);
|
|
106
|
+
state.counter += 1;
|
|
107
|
+
state.stack.push(node);
|
|
108
|
+
state.onStack.add(node);
|
|
109
|
+
|
|
110
|
+
for (const next of graph.get(node) ?? []) {
|
|
111
|
+
if (!state.index.has(next)) {
|
|
112
|
+
strongConnect(next, graph, state);
|
|
113
|
+
state.low.set(
|
|
114
|
+
node,
|
|
115
|
+
Math.min(state.low.get(node) ?? 0, state.low.get(next) ?? 0)
|
|
116
|
+
);
|
|
117
|
+
} else if (state.onStack.has(next)) {
|
|
118
|
+
state.low.set(
|
|
119
|
+
node,
|
|
120
|
+
Math.min(state.low.get(node) ?? 0, state.index.get(next) ?? 0)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (state.low.get(node) === state.index.get(node)) {
|
|
126
|
+
const component: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (;;) {
|
|
129
|
+
const popped = state.stack.pop();
|
|
130
|
+
|
|
131
|
+
if (popped === undefined) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
state.onStack.delete(popped);
|
|
136
|
+
component.push(popped);
|
|
137
|
+
|
|
138
|
+
if (popped === node) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
state.components.push(component);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** All cyclic groups: SCCs of size > 1, plus single nodes that import themselves. */
|
|
148
|
+
function findCycles(graph: Map<string, string[]>): string[][] {
|
|
149
|
+
const state: ITarjanState = {
|
|
150
|
+
index: new Map(),
|
|
151
|
+
low: new Map(),
|
|
152
|
+
onStack: new Set(),
|
|
153
|
+
stack: [],
|
|
154
|
+
components: [],
|
|
155
|
+
counter: 0,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const node of graph.keys()) {
|
|
159
|
+
if (!state.index.has(node)) {
|
|
160
|
+
strongConnect(node, graph, state);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return state.components.filter((c) => {
|
|
165
|
+
if (c.length > 1) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const only = c[0];
|
|
170
|
+
|
|
171
|
+
return only !== undefined && (graph.get(only) ?? []).includes(only);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const noCircularImportsRule: IMetaRule = {
|
|
176
|
+
id: "no-circular-imports",
|
|
177
|
+
category: "stack-layout",
|
|
178
|
+
description:
|
|
179
|
+
"Project modules must not form import cycles (A → B → A) — they cause partial-initialization bugs and defeat tree-shaking.",
|
|
180
|
+
severity: "error",
|
|
181
|
+
run(ctx) {
|
|
182
|
+
const cycles = findCycles(buildGraph(ctx));
|
|
183
|
+
|
|
184
|
+
return cycles.map((cycle): IMetaRuleViolation => {
|
|
185
|
+
const ordered = [...cycle].sort();
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
file: ordered[0] ?? "src",
|
|
189
|
+
ruleId: "no-circular-imports",
|
|
190
|
+
severity: "error",
|
|
191
|
+
message: `Import cycle between ${ordered.length} modules: ${ordered.join(" ↔ ")}. Break it by extracting the shared piece into a third module both can import.`,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { builtinModules } from "node:module";
|
|
2
|
+
import type {
|
|
3
|
+
IMetaRule,
|
|
4
|
+
IMetaRuleContext,
|
|
5
|
+
IMetaRuleViolation,
|
|
6
|
+
} from "../../meta-rules.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every bare `import` must resolve to a DECLARED dependency. A classic AI mistake
|
|
10
|
+
* is importing a package it never added to package.json — it works locally via a
|
|
11
|
+
* hoisted/transitive copy, then breaks on a clean install in CI or for a teammate.
|
|
12
|
+
* We compare each imported package against package.json's declared deps (+ Node
|
|
13
|
+
* builtins, `bun:` specifiers, tsconfig path aliases, and the project's own name).
|
|
14
|
+
*/
|
|
15
|
+
const IMPORT_FROM = /(?:import|export)[^'"]*?from\s*['"](?<spec>[^'"]+)['"]/gu;
|
|
16
|
+
const DYNAMIC_IMPORT = /\bimport\s*\(\s*['"](?<spec>[^'"]+)['"]\s*\)/gu;
|
|
17
|
+
const NODE_BUILTINS = new Set([
|
|
18
|
+
...builtinModules,
|
|
19
|
+
...builtinModules.map((m) => `node:${m}`),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Bare specifiers (packages), skipping relative/absolute paths. */
|
|
23
|
+
function bareSpecifiers(text: string): string[] {
|
|
24
|
+
const out: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const re of [IMPORT_FROM, DYNAMIC_IMPORT]) {
|
|
27
|
+
re.lastIndex = 0;
|
|
28
|
+
|
|
29
|
+
for (const m of text.matchAll(re)) {
|
|
30
|
+
const spec = m.groups?.spec;
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
spec !== undefined &&
|
|
34
|
+
!spec.startsWith(".") &&
|
|
35
|
+
!spec.startsWith("/")
|
|
36
|
+
) {
|
|
37
|
+
out.push(spec);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The package name a specifier belongs to (`@scope/pkg/sub` → `@scope/pkg`). */
|
|
46
|
+
function packageName(spec: string): string {
|
|
47
|
+
const parts = spec.split("/");
|
|
48
|
+
|
|
49
|
+
if (spec.startsWith("@")) {
|
|
50
|
+
return parts.slice(0, 2).join("/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return parts[0] ?? spec;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Collect declared dependency names from every dependency field. */
|
|
57
|
+
function declaredDeps(pkg: Record<string, unknown> | null): Set<string> {
|
|
58
|
+
const names = new Set<string>();
|
|
59
|
+
const fields = [
|
|
60
|
+
"dependencies",
|
|
61
|
+
"devDependencies",
|
|
62
|
+
"peerDependencies",
|
|
63
|
+
"optionalDependencies",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const field of fields) {
|
|
67
|
+
const map = pkg?.[field];
|
|
68
|
+
|
|
69
|
+
if (typeof map === "object" && map !== null) {
|
|
70
|
+
for (const name of Object.keys(map)) {
|
|
71
|
+
names.add(name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return names;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read a string-keyed property as `unknown` without surfacing `any`. */
|
|
80
|
+
function prop(value: unknown, key: string): unknown {
|
|
81
|
+
if (typeof value !== "object" || value === null || !(key in value)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const record: Record<string, unknown> = { ...value };
|
|
86
|
+
|
|
87
|
+
return record[key];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** tsconfig `compilerOptions.paths` alias prefixes (e.g. `@/*` → `@/`). */
|
|
91
|
+
function aliasPrefixes(ctx: IMetaRuleContext): string[] {
|
|
92
|
+
const raw = ctx.readFile("tsconfig.json");
|
|
93
|
+
|
|
94
|
+
if (raw === null) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const parsed: unknown = JSON.parse(raw);
|
|
100
|
+
const paths = prop(prop(parsed, "compilerOptions"), "paths");
|
|
101
|
+
|
|
102
|
+
if (typeof paths !== "object" || paths === null) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Object.keys(paths).map((k) => k.replace(/\*$/u, ""));
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True when an import is satisfied without a runtime dep declaration. */
|
|
113
|
+
function isAllowed(
|
|
114
|
+
pkg: string,
|
|
115
|
+
spec: string,
|
|
116
|
+
declared: ReadonlySet<string>,
|
|
117
|
+
aliases: readonly string[],
|
|
118
|
+
ownName: string
|
|
119
|
+
): boolean {
|
|
120
|
+
if (spec.startsWith("bun:") || pkg === "bun") {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (NODE_BUILTINS.has(pkg) || NODE_BUILTINS.has(spec)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (pkg === ownName || declared.has(pkg) || declared.has(`@types/${pkg}`)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return aliases.some((prefix) => prefix.length > 0 && spec.startsWith(prefix));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const noUndeclaredDependenciesRule: IMetaRule = {
|
|
136
|
+
id: "no-undeclared-dependencies",
|
|
137
|
+
category: "supply-chain",
|
|
138
|
+
description:
|
|
139
|
+
"Every imported package must be declared in package.json — an undeclared import works via hoisting locally but breaks on a clean install.",
|
|
140
|
+
severity: "error",
|
|
141
|
+
run(ctx) {
|
|
142
|
+
if (ctx.packageJson === null) {
|
|
143
|
+
return []; // no manifest to check against
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const declared = declaredDeps(ctx.packageJson);
|
|
147
|
+
const aliases = aliasPrefixes(ctx);
|
|
148
|
+
const ownNameRaw = prop(ctx.packageJson, "name");
|
|
149
|
+
const ownName = typeof ownNameRaw === "string" ? ownNameRaw : "";
|
|
150
|
+
const violations: IMetaRuleViolation[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
|
|
153
|
+
for (const file of ctx.sourceFiles) {
|
|
154
|
+
const text = ctx.readFile(file);
|
|
155
|
+
|
|
156
|
+
if (text === null) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const spec of bareSpecifiers(text)) {
|
|
161
|
+
const pkg = packageName(spec);
|
|
162
|
+
const key = `${file}::${pkg}`;
|
|
163
|
+
|
|
164
|
+
if (isAllowed(pkg, spec, declared, aliases, ownName) || seen.has(key)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
seen.add(key);
|
|
169
|
+
violations.push({
|
|
170
|
+
file,
|
|
171
|
+
ruleId: "no-undeclared-dependencies",
|
|
172
|
+
severity: "error",
|
|
173
|
+
message: `Imports \`${pkg}\` but it is not in package.json — add it to dependencies (or devDependencies) so a clean install resolves it.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return violations;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
// Optional type-aware ESLint overlay — enabled only when the target has a
|
|
2
|
-
// compiling tsconfig (see detect-gate.ts).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// compiling tsconfig (see detect-gate.ts). These rules need full type info
|
|
3
|
+
// (parserOptions.project) and are kept separate from strict.eslint.config.mjs so
|
|
4
|
+
// the syntactic gate still runs on any .ts file without type info.
|
|
5
|
+
//
|
|
6
|
+
// Two jobs:
|
|
7
|
+
// 1. Async correctness — floating/misused promises (a dropped `await` is silent
|
|
8
|
+
// data loss / unhandled rejection).
|
|
9
|
+
// 2. IMPLICIT-`any` containment — the `no-unsafe-*` family. `no-explicit-any`
|
|
10
|
+
// (in the syntactic config) bans the literal `any` token, but it CANNOT see
|
|
11
|
+
// `any` that leaks in from an untyped boundary: `JSON.parse(s)`, `await
|
|
12
|
+
// res.json()`, an untyped dependency. `tsc --strict` propagates that `any`
|
|
13
|
+
// silently. These rules are the only thing that catches it — they make the
|
|
14
|
+
// generated-code gate enforce what tsforge already enforces on its OWN source
|
|
15
|
+
// via strictTypeChecked. Curated to the HIGH-SIGNAL, low-thrash subset;
|
|
16
|
+
// narrative rules (strict-boolean-expressions, no-unnecessary-condition,
|
|
17
|
+
// restrict-template-expressions) are deliberately left off until a sweep
|
|
18
|
+
// shows they don't thrash the local model.
|
|
5
19
|
import tseslint from "typescript-eslint";
|
|
6
20
|
|
|
7
21
|
export default tseslint.config(
|
|
@@ -19,6 +33,7 @@ export default tseslint.config(
|
|
|
19
33
|
"@typescript-eslint": tseslint.plugin,
|
|
20
34
|
},
|
|
21
35
|
rules: {
|
|
36
|
+
// Async correctness.
|
|
22
37
|
"@typescript-eslint/no-floating-promises": "error",
|
|
23
38
|
"@typescript-eslint/no-misused-promises": [
|
|
24
39
|
"error",
|
|
@@ -28,6 +43,24 @@ export default tseslint.config(
|
|
|
28
43
|
},
|
|
29
44
|
},
|
|
30
45
|
],
|
|
46
|
+
"@typescript-eslint/await-thenable": "error",
|
|
47
|
+
|
|
48
|
+
// Implicit-`any` containment: stop untyped boundary data from flowing
|
|
49
|
+
// through the program unchecked.
|
|
50
|
+
"@typescript-eslint/no-unsafe-assignment": "error",
|
|
51
|
+
"@typescript-eslint/no-unsafe-member-access": "error",
|
|
52
|
+
"@typescript-eslint/no-unsafe-call": "error",
|
|
53
|
+
"@typescript-eslint/no-unsafe-return": "error",
|
|
54
|
+
"@typescript-eslint/no-unsafe-argument": "error",
|
|
55
|
+
|
|
56
|
+
// Cheap, high-signal correctness rules that need type info.
|
|
57
|
+
"@typescript-eslint/no-for-in-array": "error",
|
|
58
|
+
"@typescript-eslint/no-base-to-string": "error",
|
|
59
|
+
"@typescript-eslint/restrict-plus-operands": "error",
|
|
60
|
+
"@typescript-eslint/switch-exhaustiveness-check": [
|
|
61
|
+
"error",
|
|
62
|
+
{ considerDefaultExhaustiveForUnions: true },
|
|
63
|
+
],
|
|
31
64
|
},
|
|
32
65
|
}
|
|
33
66
|
);
|