@agjs/tsforge 0.1.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/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- package/strict.web.eslint.config.mjs +185 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { join, extname } from "node:path";
|
|
2
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
3
|
+
import type { IMetaRuleContext } from "./meta-rules.types";
|
|
4
|
+
|
|
5
|
+
/** Narrow `unknown` to a record without a type assertion. */
|
|
6
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return typeof value === "object" && value !== null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Directories to skip during recursive file walks. */
|
|
11
|
+
const IGNORE_SEGMENTS = new Set([
|
|
12
|
+
"node_modules",
|
|
13
|
+
"dist",
|
|
14
|
+
"build",
|
|
15
|
+
".git",
|
|
16
|
+
".next",
|
|
17
|
+
".turbo",
|
|
18
|
+
".cache",
|
|
19
|
+
".vite",
|
|
20
|
+
"coverage",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** Source files: .ts and .tsx under src/, tests/, scripts/. */
|
|
24
|
+
function collectSourceFiles(root: string): string[] {
|
|
25
|
+
const out: string[] = [];
|
|
26
|
+
|
|
27
|
+
const scanDir = (dir: string, relBase: string): void => {
|
|
28
|
+
let entries: string[];
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
entries = readdirSync(dir);
|
|
32
|
+
} catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const full = join(dir, entry);
|
|
38
|
+
const rel = join(relBase, entry);
|
|
39
|
+
|
|
40
|
+
// Skip ignored directories
|
|
41
|
+
if (IGNORE_SEGMENTS.has(entry)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const stat = statSync(full);
|
|
47
|
+
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
scanDir(full, rel);
|
|
50
|
+
} else if (stat.isFile()) {
|
|
51
|
+
const ext = extname(entry);
|
|
52
|
+
|
|
53
|
+
if (ext === ".ts" || ext === ".tsx") {
|
|
54
|
+
out.push(rel);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Skip unreadable entries
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (const baseDir of ["src", "tests", "scripts"]) {
|
|
64
|
+
scanDir(join(root, baseDir), baseDir);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Config files: tsconfig*, eslint*, package.json, *.config.* at root. */
|
|
71
|
+
function collectConfigFiles(root: string): string[] {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const entries = readdirSync(root);
|
|
76
|
+
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const full = join(root, entry);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const stat = statSync(full);
|
|
82
|
+
|
|
83
|
+
if (!stat.isFile()) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Match tsconfig.*, eslint.*, package.json, *.config.*
|
|
88
|
+
if (
|
|
89
|
+
entry.startsWith("tsconfig") ||
|
|
90
|
+
entry.startsWith("eslint") ||
|
|
91
|
+
entry === "package.json" ||
|
|
92
|
+
entry.includes(".config.")
|
|
93
|
+
) {
|
|
94
|
+
out.push(entry);
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Skip unreadable entries
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Root doesn't exist or is unreadable
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return out.sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** GitHub workflow files: .github/workflows/*.yml|yaml. */
|
|
108
|
+
function collectWorkflowFiles(root: string): string[] {
|
|
109
|
+
const out: string[] = [];
|
|
110
|
+
const workflowDir = join(root, ".github", "workflows");
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const entries = readdirSync(workflowDir);
|
|
114
|
+
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const ext = extname(entry);
|
|
117
|
+
|
|
118
|
+
if (ext === ".yml" || ext === ".yaml") {
|
|
119
|
+
out.push(join(".github", "workflows", entry));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Workflows dir doesn't exist
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return out.sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Parse package.json, returning null on error. */
|
|
130
|
+
function parsePackageJson(root: string): Record<string, unknown> | null {
|
|
131
|
+
const pkgPath = join(root, "package.json");
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const stat = statSync(pkgPath);
|
|
135
|
+
|
|
136
|
+
if (!stat.isFile()) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const text = readFileSync(pkgPath, "utf8");
|
|
141
|
+
const parsed: unknown = JSON.parse(text);
|
|
142
|
+
|
|
143
|
+
return isRecord(parsed) ? parsed : null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the rule context: list source/config/workflow files, parse package.json,
|
|
151
|
+
* set up a cached file reader.
|
|
152
|
+
*/
|
|
153
|
+
export function buildMetaRuleContext(
|
|
154
|
+
root: string,
|
|
155
|
+
activePacks: readonly string[]
|
|
156
|
+
): IMetaRuleContext {
|
|
157
|
+
const fileCache = new Map<string, string | null>();
|
|
158
|
+
|
|
159
|
+
const readFile = (relPath: string): string | null => {
|
|
160
|
+
if (fileCache.has(relPath)) {
|
|
161
|
+
return fileCache.get(relPath) ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const full = join(root, relPath);
|
|
166
|
+
const stat = statSync(full);
|
|
167
|
+
|
|
168
|
+
if (!stat.isFile()) {
|
|
169
|
+
fileCache.set(relPath, null);
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const text = readFileSync(full, "utf8");
|
|
175
|
+
|
|
176
|
+
fileCache.set(relPath, text);
|
|
177
|
+
|
|
178
|
+
return text;
|
|
179
|
+
} catch {
|
|
180
|
+
fileCache.set(relPath, null);
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
root,
|
|
188
|
+
packageJson: parsePackageJson(root),
|
|
189
|
+
sourceFiles: collectSourceFiles(root),
|
|
190
|
+
configFiles: collectConfigFiles(root),
|
|
191
|
+
workflowFiles: collectWorkflowFiles(root),
|
|
192
|
+
activePacks,
|
|
193
|
+
readFile,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta-rules: project structure & config guardrails that ESLint cannot express.
|
|
3
|
+
* Categories align with risk domains: supply-chain (deps), config (tsconfig/eslint),
|
|
4
|
+
* source-text (inline suppressions), testing (coverage), stack-layout (dirs),
|
|
5
|
+
* and ci (workflows).
|
|
6
|
+
*/
|
|
7
|
+
export type MetaRuleCategory =
|
|
8
|
+
| "supply-chain"
|
|
9
|
+
| "config"
|
|
10
|
+
| "source-text"
|
|
11
|
+
| "testing"
|
|
12
|
+
| "stack-layout"
|
|
13
|
+
| "ci";
|
|
14
|
+
|
|
15
|
+
/** A single rule violation (file, rule, message). */
|
|
16
|
+
export interface IMetaRuleViolation {
|
|
17
|
+
readonly file: string; // repo-relative path
|
|
18
|
+
readonly ruleId: string;
|
|
19
|
+
readonly severity: "error" | "warn";
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Context for a rule run: the project root, parsed package.json, file lists,
|
|
25
|
+
* active packs from stack detection, and a cached file reader.
|
|
26
|
+
*/
|
|
27
|
+
export interface IMetaRuleContext {
|
|
28
|
+
readonly root: string;
|
|
29
|
+
readonly packageJson: Record<string, unknown> | null;
|
|
30
|
+
readonly sourceFiles: readonly string[]; // repo-relative .ts/.tsx
|
|
31
|
+
readonly configFiles: readonly string[]; // tsconfig*, eslint*, package.json, *.config.*
|
|
32
|
+
readonly workflowFiles: readonly string[]; // .github/workflows/*.yml|yaml
|
|
33
|
+
readonly activePacks: readonly string[]; // pack ids from stack detection
|
|
34
|
+
readonly readFile: (relPath: string) => string | null; // cached, safe
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A single meta-rule that checks project invariants. */
|
|
38
|
+
export interface IMetaRule {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly category: MetaRuleCategory;
|
|
41
|
+
readonly description: string;
|
|
42
|
+
/** Pack IDs this rule applies to; undefined = always applies. */
|
|
43
|
+
readonly appliesTo?: readonly string[];
|
|
44
|
+
readonly severity: "error" | "warn";
|
|
45
|
+
/** Synchronous rule run. */
|
|
46
|
+
readonly run: (ctx: IMetaRuleContext) => IMetaRuleViolation[];
|
|
47
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface IPackageJsonDeps {
|
|
2
|
+
readonly dependencies?: Record<string, string>;
|
|
3
|
+
readonly devDependencies?: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Narrow `unknown` to a record without a type assertion. */
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === "object" && value !== null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Extract string-valued dependency map from a package.json object. */
|
|
12
|
+
function toStringRecord(value: unknown): Record<string, string> | undefined {
|
|
13
|
+
if (!isRecord(value)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const out: Record<string, string> = {};
|
|
18
|
+
|
|
19
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
20
|
+
if (typeof entry === "string") {
|
|
21
|
+
out[key] = entry;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse package.json JSON object, returning null on error. */
|
|
29
|
+
export function parsePackageJsonObject(
|
|
30
|
+
parsed: unknown
|
|
31
|
+
): IPackageJsonDeps | null {
|
|
32
|
+
if (!isRecord(parsed)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let dependencies: Record<string, string> | undefined;
|
|
37
|
+
let devDependencies: Record<string, string> | undefined;
|
|
38
|
+
|
|
39
|
+
const depsValue = parsed.dependencies;
|
|
40
|
+
const devDepsValue = parsed.devDependencies;
|
|
41
|
+
|
|
42
|
+
if (depsValue !== undefined) {
|
|
43
|
+
dependencies = toStringRecord(depsValue);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (devDepsValue !== undefined) {
|
|
47
|
+
devDependencies = toStringRecord(devDepsValue);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { dependencies, devDependencies };
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IMetaRule } from "./meta-rules.types";
|
|
2
|
+
import { packageExactDepsRule } from "./rules/supply-chain/package-exact-deps";
|
|
3
|
+
import { noOverlappingLibsRule } from "./rules/supply-chain/no-overlapping-libs";
|
|
4
|
+
import { noEslintDisableCommentsRule } from "./rules/source-text/no-eslint-disable-comments";
|
|
5
|
+
import { noTsSuppressionRule } from "./rules/source-text/no-ts-suppressions";
|
|
6
|
+
import { tsconfigPathsExistRule } from "./rules/config/tsconfig-paths-exist";
|
|
7
|
+
import { tsconfigStrictRule } from "./rules/config/tsconfig-strict";
|
|
8
|
+
import { testSiblingRequiredRule } from "./rules/testing/test-sibling-required";
|
|
9
|
+
import { workflowActionsPinnedRule } from "./rules/ci/workflow-actions-pinned";
|
|
10
|
+
import { workflowRunnerPinnedRule } from "./rules/ci/workflow-runner-pinned";
|
|
11
|
+
import { workflowTimeoutRequiredRule } from "./rules/ci/workflow-timeout-required";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* All available meta-rules, ordered by category for readability.
|
|
15
|
+
* Apply-filtering (appliesTo) happens in the runner per context.
|
|
16
|
+
*/
|
|
17
|
+
export const META_RULES: readonly IMetaRule[] = [
|
|
18
|
+
// Supply chain
|
|
19
|
+
packageExactDepsRule,
|
|
20
|
+
noOverlappingLibsRule,
|
|
21
|
+
|
|
22
|
+
// Source text
|
|
23
|
+
noEslintDisableCommentsRule,
|
|
24
|
+
noTsSuppressionRule,
|
|
25
|
+
|
|
26
|
+
// Config
|
|
27
|
+
tsconfigPathsExistRule,
|
|
28
|
+
tsconfigStrictRule,
|
|
29
|
+
|
|
30
|
+
// Testing
|
|
31
|
+
testSiblingRequiredRule,
|
|
32
|
+
|
|
33
|
+
// CI
|
|
34
|
+
workflowActionsPinnedRule,
|
|
35
|
+
workflowRunnerPinnedRule,
|
|
36
|
+
workflowTimeoutRequiredRule,
|
|
37
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions `uses:` directive must pin to either:
|
|
5
|
+
* - A full SHA (e.g. uses: actions/checkout@a1b2c3d...)
|
|
6
|
+
* - A version tag (e.g. uses: actions/checkout@v3)
|
|
7
|
+
*
|
|
8
|
+
* Floating refs (e.g. uses: actions/checkout@main) are not allowed.
|
|
9
|
+
*/
|
|
10
|
+
const USES_PATTERN = /uses:\s*(.+?)(?:\s*#.*)?$/u;
|
|
11
|
+
const VALID_PIN_PATTERN = /^[\w./-]+@(?:[a-f0-9]{40}|v?\d+(?:\.\d+)*)$/u;
|
|
12
|
+
|
|
13
|
+
export const workflowActionsPinnedRule: IMetaRule = {
|
|
14
|
+
id: "workflow-actions-pinned",
|
|
15
|
+
category: "ci",
|
|
16
|
+
description:
|
|
17
|
+
"GitHub Actions `uses:` directives must pin to a version tag (v1, v2, etc.) or full SHA, not floating refs like @main.",
|
|
18
|
+
severity: "warn",
|
|
19
|
+
run({ workflowFiles, readFile }) {
|
|
20
|
+
const violations: IMetaRuleViolation[] = [];
|
|
21
|
+
|
|
22
|
+
for (const file of workflowFiles) {
|
|
23
|
+
const text = readFile(file);
|
|
24
|
+
|
|
25
|
+
if (text === null) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const match = USES_PATTERN.exec(line);
|
|
33
|
+
|
|
34
|
+
if (match?.[1] === undefined) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const usesValue = match[1].trim().replace(/['"]/gu, "");
|
|
39
|
+
|
|
40
|
+
// Skip composite actions and local actions (./...)
|
|
41
|
+
if (usesValue.startsWith("./")) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if pinned to version or SHA
|
|
46
|
+
if (!VALID_PIN_PATTERN.test(usesValue)) {
|
|
47
|
+
violations.push({
|
|
48
|
+
file,
|
|
49
|
+
ruleId: "workflow-actions-pinned",
|
|
50
|
+
severity: "warn",
|
|
51
|
+
message: `Action \`${usesValue}\` is not pinned to a version tag or SHA — pin to a stable release (e.g. @v3) or full commit SHA.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return violations;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub runner images should pin to an explicit OS version (e.g. ubuntu-24.04)
|
|
5
|
+
* instead of floating tags (ubuntu-latest). Floating tags can change between
|
|
6
|
+
* runs with no repo diff, causing non-deterministic CI behavior.
|
|
7
|
+
*/
|
|
8
|
+
const RUNS_ON_PATTERN = /^\s*runs-on:\s*(?<label>\S+)\s*(?:#.*)?$/u;
|
|
9
|
+
|
|
10
|
+
export const workflowRunnerPinnedRule: IMetaRule = {
|
|
11
|
+
id: "workflow-runner-pinned",
|
|
12
|
+
category: "ci",
|
|
13
|
+
description:
|
|
14
|
+
"Workflows must pin runner images to an explicit OS version (e.g. ubuntu-24.04) instead of floating *-latest labels.",
|
|
15
|
+
severity: "warn",
|
|
16
|
+
run({ workflowFiles, readFile }) {
|
|
17
|
+
const violations: IMetaRuleViolation[] = [];
|
|
18
|
+
|
|
19
|
+
for (const file of workflowFiles) {
|
|
20
|
+
const text = readFile(file);
|
|
21
|
+
|
|
22
|
+
if (text === null) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const lines = text.split("\n");
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const match = RUNS_ON_PATTERN.exec(line);
|
|
30
|
+
const label = match?.groups?.label;
|
|
31
|
+
|
|
32
|
+
if (label === undefined) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Skip matrix variables (start with $)
|
|
37
|
+
if (label.startsWith("$")) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if the label ends with -latest (floating)
|
|
42
|
+
const normalized = label.replace(/['"]/gu, "");
|
|
43
|
+
|
|
44
|
+
if (normalized.endsWith("-latest")) {
|
|
45
|
+
violations.push({
|
|
46
|
+
file,
|
|
47
|
+
ruleId: "workflow-runner-pinned",
|
|
48
|
+
severity: "warn",
|
|
49
|
+
message: `runs-on: ${normalized} floats with GitHub's runner image migrations — tool versions change between runs with no repo diff. Pin an explicit OS version (e.g. ubuntu-24.04).`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return violations;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions jobs should declare a timeout-minutes so a hung step fails fast
|
|
5
|
+
* instead of occupying a runner for GitHub's 6-hour default.
|
|
6
|
+
* Reusable workflow calls (job uses: ...) are exempt since they set timeouts internally.
|
|
7
|
+
*/
|
|
8
|
+
const JOB_KEY_PATTERN = /^ {2}([\w-]+):\s*(?:#.*)?$/u;
|
|
9
|
+
|
|
10
|
+
interface IJobBlock {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly lines: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function collectJobBlocks(text: string): IJobBlock[] {
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
const blocks: IJobBlock[] = [];
|
|
18
|
+
let inJobs = false;
|
|
19
|
+
let current: { name: string; lines: string[] } | null = null;
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
// Start of jobs: section
|
|
23
|
+
if (/^jobs:\s*(?:#.*)?$/u.test(line)) {
|
|
24
|
+
inJobs = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!inJobs) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// End of jobs: section (top-level key)
|
|
33
|
+
if (/^\S/u.test(line)) {
|
|
34
|
+
inJobs = false;
|
|
35
|
+
|
|
36
|
+
if (current !== null) {
|
|
37
|
+
blocks.push(current);
|
|
38
|
+
current = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Job definition line
|
|
45
|
+
const jobMatch = JOB_KEY_PATTERN.exec(line);
|
|
46
|
+
|
|
47
|
+
if (jobMatch?.[1] !== undefined) {
|
|
48
|
+
if (current !== null) {
|
|
49
|
+
blocks.push(current);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
current = { name: jobMatch[1], lines: [] };
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (current !== null) {
|
|
57
|
+
current.lines.push(line);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (current !== null) {
|
|
62
|
+
blocks.push(current);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return blocks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const workflowTimeoutRequiredRule: IMetaRule = {
|
|
69
|
+
id: "workflow-timeout-required",
|
|
70
|
+
category: "ci",
|
|
71
|
+
description:
|
|
72
|
+
"GitHub Actions jobs require an explicit timeout-minutes (reusable-workflow calls exempt).",
|
|
73
|
+
severity: "warn",
|
|
74
|
+
run({ workflowFiles, readFile }) {
|
|
75
|
+
const violations: IMetaRuleViolation[] = [];
|
|
76
|
+
|
|
77
|
+
for (const file of workflowFiles) {
|
|
78
|
+
const text = readFile(file);
|
|
79
|
+
|
|
80
|
+
if (text === null) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const jobs = collectJobBlocks(text);
|
|
85
|
+
|
|
86
|
+
for (const job of jobs) {
|
|
87
|
+
// Check if this is a reusable workflow call
|
|
88
|
+
const isReusableCall = job.lines.some((line) =>
|
|
89
|
+
/^ {4}uses:\s*\S/u.test(line)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (isReusableCall) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check for timeout-minutes
|
|
97
|
+
const hasTimeout = job.lines.some((line) =>
|
|
98
|
+
/^ {4}timeout-minutes:\s*[1-9]\d*\s*(?:#.*)?$/u.test(line)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (!hasTimeout) {
|
|
102
|
+
violations.push({
|
|
103
|
+
file,
|
|
104
|
+
ruleId: "workflow-timeout-required",
|
|
105
|
+
severity: "warn",
|
|
106
|
+
message: `Job "${job.name}" has no job-level \`timeout-minutes:\` — a hung step runs for GitHub's 6h default and blocks the PR check.`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return violations;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { join, dirname } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
4
|
+
|
|
5
|
+
/** Narrow `unknown` to a record without a type assertion. */
|
|
6
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return typeof value === "object" && value !== null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const GLOB_CHARS_REGEX = /[*?{}]/u;
|
|
11
|
+
|
|
12
|
+
/** Strip block and line comments from JSON before parsing. */
|
|
13
|
+
function stripJsonComments(text: string): string {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
|
16
|
+
.replace(/^\s*\/\/.*$/gmu, "")
|
|
17
|
+
.replace(/,\s*([\]}])/gu, "$1");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract literal (non-glob) entries from tsconfig include/files that should
|
|
22
|
+
* point to real files on disk.
|
|
23
|
+
*/
|
|
24
|
+
function readLiteralEntries(tsconfigPath: string): string[] {
|
|
25
|
+
let parsed: unknown;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const text = readFileSync(tsconfigPath, "utf8");
|
|
29
|
+
|
|
30
|
+
parsed = JSON.parse(stripJsonComments(text));
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!isRecord(parsed)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const entries: string[] = [];
|
|
40
|
+
const candidates: unknown[] = [];
|
|
41
|
+
|
|
42
|
+
if ("include" in parsed) {
|
|
43
|
+
const includeValue = parsed.include;
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(includeValue)) {
|
|
46
|
+
candidates.push(includeValue);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ("files" in parsed) {
|
|
51
|
+
const filesValue = parsed.files;
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(filesValue)) {
|
|
54
|
+
candidates.push(filesValue);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
if (!Array.isArray(candidate)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const item of candidate) {
|
|
64
|
+
if (typeof item !== "string" || GLOB_CHARS_REGEX.test(item)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Skip entries under hidden dirs (.astro/types.d.ts) — build-generated
|
|
69
|
+
const normalized = item.replace(/^\.\//u, "");
|
|
70
|
+
|
|
71
|
+
if (normalized.startsWith(".")) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
entries.push(item);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const tsconfigPathsExistRule: IMetaRule = {
|
|
83
|
+
id: "tsconfig-paths-exist",
|
|
84
|
+
category: "config",
|
|
85
|
+
description:
|
|
86
|
+
"Literal tsconfig include/files entries must point to files that exist on disk (glob patterns exempt).",
|
|
87
|
+
severity: "error",
|
|
88
|
+
run({ root }) {
|
|
89
|
+
const violations: IMetaRuleViolation[] = [];
|
|
90
|
+
const tsconfigPath = join(root, "tsconfig.json");
|
|
91
|
+
|
|
92
|
+
// Check if tsconfig.json exists
|
|
93
|
+
try {
|
|
94
|
+
statSync(tsconfigPath);
|
|
95
|
+
} catch {
|
|
96
|
+
return violations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const entries = readLiteralEntries(tsconfigPath);
|
|
100
|
+
const baseDir = dirname(tsconfigPath);
|
|
101
|
+
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const fullPath = join(baseDir, entry);
|
|
104
|
+
|
|
105
|
+
if (!existsSync(fullPath)) {
|
|
106
|
+
violations.push({
|
|
107
|
+
file: "tsconfig.json",
|
|
108
|
+
ruleId: "tsconfig-paths-exist",
|
|
109
|
+
severity: "error",
|
|
110
|
+
message: `include/files entry \`${entry}\` does not exist on disk — stale config references misdocument the project shape (globs are exempt).`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return violations;
|
|
116
|
+
},
|
|
117
|
+
};
|