@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.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. 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,9 @@
1
+ export type {
2
+ IMetaRule,
3
+ IMetaRuleContext,
4
+ IMetaRuleViolation,
5
+ MetaRuleCategory,
6
+ } from "./meta-rules.types";
7
+ export { buildMetaRuleContext } from "./context";
8
+ export { runMetaRules } from "./runner";
9
+ export { META_RULES } from "./registry";
@@ -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
+ };