@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.
@@ -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). Adds async correctness rules that
3
- // require parserOptions.project; kept separate from strict.eslint.config.mjs
4
- // so the syntactic gate still runs on any .ts file without type info.
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
  );