@agjs/tsforge 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/boot-check.ts +106 -0
- package/scripts/build-rule-docs.ts +5 -2
- package/scripts/build-rules-md.ts +5 -2
- package/scripts/test-coverage-check.ts +138 -0
- package/src/cli.ts +2 -2
- package/src/detect-gate.ts +48 -2
- package/src/loop/feedback/meta-rule-docs.ts +16 -0
- package/src/loop/feedback/rule-docs.ts +44 -1
- package/src/loop/prompt/prompt.ts +1 -0
- package/src/loop/rule-docs.generated.json +242 -222
- package/src/meta-rules/context.ts +50 -0
- package/src/meta-rules/meta-rules.types.ts +3 -1
- package/src/meta-rules/registry.ts +14 -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/meta-rules/rules/structure/no-circular-imports.ts +195 -0
- package/src/meta-rules/rules/supply-chain/no-undeclared-dependencies.ts +180 -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.type-aware.eslint.config.mjs +36 -3
- package/strict.web.eslint.config.mjs +9 -0
|
@@ -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,11 @@ 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";
|
|
31
|
+
import { noUndeclaredDependenciesRule } from "./rules/supply-chain/no-undeclared-dependencies";
|
|
32
|
+
import { noCircularImportsRule } from "./rules/structure/no-circular-imports";
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
35
|
* All available meta-rules, ordered by category for readability.
|
|
@@ -42,6 +47,7 @@ export const META_RULES: readonly IMetaRule[] = [
|
|
|
42
47
|
dependencyOverridesRequireCommentRule,
|
|
43
48
|
productionMustNotUseDrizzlePushRule,
|
|
44
49
|
migrationsMustBeCheckedInRule,
|
|
50
|
+
noUndeclaredDependenciesRule,
|
|
45
51
|
|
|
46
52
|
// Source text
|
|
47
53
|
noEslintDisableCommentsRule,
|
|
@@ -66,4 +72,12 @@ export const META_RULES: readonly IMetaRule[] = [
|
|
|
66
72
|
workflowPermissionsLeastPrivilegeRule,
|
|
67
73
|
noPullRequestTargetUntrustedCheckoutRule,
|
|
68
74
|
noGithubContextInShellRule,
|
|
75
|
+
|
|
76
|
+
// Container
|
|
77
|
+
dockerfileBaseImagePinnedRule,
|
|
78
|
+
dockerfileNonRootUserRule,
|
|
79
|
+
dockerfileNoSecretsInEnvArgRule,
|
|
80
|
+
|
|
81
|
+
// Structure (cross-file)
|
|
82
|
+
noCircularImportsRule,
|
|
69
83
|
];
|
|
@@ -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,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
|
+
};
|