@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,91 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { 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
|
+
/** Strip block and line comments from JSON before parsing. */
|
|
11
|
+
function stripJsonComments(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
|
14
|
+
.replace(/^\s*\/\/.*$/gmu, "")
|
|
15
|
+
.replace(/,\s*([\]}])/gu, "$1");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if tsconfig has strict: true or all individual strict flags enabled.
|
|
20
|
+
*/
|
|
21
|
+
function isStrictEnabled(parsed: unknown): boolean {
|
|
22
|
+
if (!isRecord(parsed)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const compilerOptions = parsed.compilerOptions;
|
|
27
|
+
|
|
28
|
+
if (!isRecord(compilerOptions)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// If strict: true is set, all strict flags are implicitly enabled
|
|
33
|
+
if (compilerOptions.strict === true) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Otherwise, check individual strict flags
|
|
38
|
+
const strictFlags = [
|
|
39
|
+
"alwaysStrict",
|
|
40
|
+
"strictBindCallApply",
|
|
41
|
+
"strictFunctionTypes",
|
|
42
|
+
"strictNullChecks",
|
|
43
|
+
"strictPropertyInitialization",
|
|
44
|
+
"noImplicitAny",
|
|
45
|
+
"noImplicitThis",
|
|
46
|
+
"useUnknownInCatchVariables",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
return strictFlags.every((flag) => {
|
|
50
|
+
const flagValue = compilerOptions[flag];
|
|
51
|
+
|
|
52
|
+
return flagValue === true;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const tsconfigStrictRule: IMetaRule = {
|
|
57
|
+
id: "tsconfig-strict",
|
|
58
|
+
category: "config",
|
|
59
|
+
description:
|
|
60
|
+
"tsconfig.json should enable strict mode for type safety (strict: true or all individual strict flags).",
|
|
61
|
+
severity: "warn",
|
|
62
|
+
run({ root }) {
|
|
63
|
+
const violations: IMetaRuleViolation[] = [];
|
|
64
|
+
const tsconfigPath = join(root, "tsconfig.json");
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
statSync(tsconfigPath);
|
|
68
|
+
} catch {
|
|
69
|
+
return violations;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const text = readFileSync(tsconfigPath, "utf8");
|
|
74
|
+
const parsed: unknown = JSON.parse(stripJsonComments(text));
|
|
75
|
+
|
|
76
|
+
if (!isStrictEnabled(parsed)) {
|
|
77
|
+
violations.push({
|
|
78
|
+
file: "tsconfig.json",
|
|
79
|
+
ruleId: "tsconfig-strict",
|
|
80
|
+
severity: "warn",
|
|
81
|
+
message:
|
|
82
|
+
'tsconfig.json should enable strict mode via "strict": true in compilerOptions for full type safety.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Invalid JSON or file read error — skip this check
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return violations;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
const ESLINT_DISABLE_PATTERN = /\beslint-disable(?:-next-line|-line)?\b/u;
|
|
4
|
+
|
|
5
|
+
export const noEslintDisableCommentsRule: IMetaRule = {
|
|
6
|
+
id: "no-eslint-disable-comments",
|
|
7
|
+
category: "source-text",
|
|
8
|
+
description:
|
|
9
|
+
"Source files must not contain inline eslint-disable directives.",
|
|
10
|
+
severity: "error",
|
|
11
|
+
run({ sourceFiles, readFile }) {
|
|
12
|
+
const violations: IMetaRuleViolation[] = [];
|
|
13
|
+
|
|
14
|
+
for (const file of sourceFiles) {
|
|
15
|
+
const text = readFile(file);
|
|
16
|
+
|
|
17
|
+
if (text === null) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (ESLINT_DISABLE_PATTERN.test(text)) {
|
|
22
|
+
violations.push({
|
|
23
|
+
file,
|
|
24
|
+
ruleId: "no-eslint-disable-comments",
|
|
25
|
+
severity: "error",
|
|
26
|
+
message:
|
|
27
|
+
"Inline ESLint disables are not allowed. Fix the rule or add a scoped config override.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return violations;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
2
|
+
|
|
3
|
+
const TS_SUPPRESS_PATTERN = /@ts-(?:ignore|nocheck|expect-error)/u;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TypeScript suppression comments (@ts-ignore, @ts-nocheck, @ts-expect-error)
|
|
7
|
+
* are not allowed without a trailing justification comment.
|
|
8
|
+
*/
|
|
9
|
+
export const noTsSuppressionRule: IMetaRule = {
|
|
10
|
+
id: "no-ts-suppressions",
|
|
11
|
+
category: "source-text",
|
|
12
|
+
description:
|
|
13
|
+
"TypeScript suppression comments (@ts-ignore, @ts-nocheck, @ts-expect-error) are not allowed.",
|
|
14
|
+
severity: "error",
|
|
15
|
+
run({ sourceFiles, readFile }) {
|
|
16
|
+
const violations: IMetaRuleViolation[] = [];
|
|
17
|
+
|
|
18
|
+
for (const file of sourceFiles) {
|
|
19
|
+
const text = readFile(file);
|
|
20
|
+
|
|
21
|
+
if (text === null) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (TS_SUPPRESS_PATTERN.test(text)) {
|
|
26
|
+
violations.push({
|
|
27
|
+
file,
|
|
28
|
+
ruleId: "no-ts-suppressions",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message:
|
|
31
|
+
"TypeScript suppression comments are not allowed. Narrow the type instead.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return violations;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parsePackageJsonObject } from "../../parsers/package-json-parser";
|
|
2
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Library pairs that should not coexist in the same project.
|
|
6
|
+
* Each pair is [lib1, lib2] (order-insensitive).
|
|
7
|
+
*/
|
|
8
|
+
const FORBID_DEP_PAIRS: readonly (readonly [string, string])[] = [
|
|
9
|
+
// Overlapping HTTP clients
|
|
10
|
+
["axios", "node-fetch"],
|
|
11
|
+
// Overlapping toast/notification libraries
|
|
12
|
+
["react-hot-toast", "sonner"],
|
|
13
|
+
// Overlapping date libraries
|
|
14
|
+
["dayjs", "date-fns"],
|
|
15
|
+
["moment", "dayjs"],
|
|
16
|
+
// Overlapping utility libraries
|
|
17
|
+
["lodash", "ramda"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const noOverlappingLibsRule: IMetaRule = {
|
|
21
|
+
id: "no-overlapping-libs",
|
|
22
|
+
category: "supply-chain",
|
|
23
|
+
description:
|
|
24
|
+
"package.json must not list forbidden overlapping library pairs (e.g. axios + node-fetch).",
|
|
25
|
+
severity: "warn",
|
|
26
|
+
run({ packageJson }) {
|
|
27
|
+
const violations: IMetaRuleViolation[] = [];
|
|
28
|
+
|
|
29
|
+
if (packageJson === null) {
|
|
30
|
+
return violations;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parsed = parsePackageJsonObject(packageJson);
|
|
34
|
+
|
|
35
|
+
if (parsed === null) {
|
|
36
|
+
return violations;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const merged: Record<string, string> = {
|
|
40
|
+
...(parsed.dependencies ?? {}),
|
|
41
|
+
...(parsed.devDependencies ?? {}),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (const [firstLib, secondLib] of FORBID_DEP_PAIRS) {
|
|
45
|
+
if (merged[firstLib] !== undefined && merged[secondLib] !== undefined) {
|
|
46
|
+
violations.push({
|
|
47
|
+
file: "package.json",
|
|
48
|
+
ruleId: "no-overlapping-libs",
|
|
49
|
+
severity: "warn",
|
|
50
|
+
message: `Both "${firstLib}" and "${secondLib}" are listed — pick one (forbidden overlapping library stacks).`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return violations;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parsePackageJsonObject } from "../../parsers/package-json-parser";
|
|
2
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
3
|
+
|
|
4
|
+
const NON_EXACT_DEP_PATTERN = /(^[~^])|([<>=*xX])|(\s)|(\|\|)/u;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check that dependencies and devDependencies use exact versions (no ^, ~, ranges).
|
|
8
|
+
* peerDependencies are allowed to use ranges.
|
|
9
|
+
*/
|
|
10
|
+
export const packageExactDepsRule: IMetaRule = {
|
|
11
|
+
id: "package-exact-deps",
|
|
12
|
+
category: "supply-chain",
|
|
13
|
+
description:
|
|
14
|
+
"dependencies and devDependencies must use exact versions (no ^ or ~ ranges).",
|
|
15
|
+
severity: "warn",
|
|
16
|
+
run({ packageJson }) {
|
|
17
|
+
const violations: IMetaRuleViolation[] = [];
|
|
18
|
+
|
|
19
|
+
if (packageJson === null) {
|
|
20
|
+
return violations;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parsed = parsePackageJsonObject(packageJson);
|
|
24
|
+
|
|
25
|
+
if (parsed === null) {
|
|
26
|
+
return violations;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sections = [
|
|
30
|
+
["dependencies", parsed.dependencies],
|
|
31
|
+
["devDependencies", parsed.devDependencies],
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
for (const [section, entries] of sections) {
|
|
35
|
+
if (entries === undefined) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const [name, spec] of Object.entries(entries)) {
|
|
40
|
+
if (!NON_EXACT_DEP_PATTERN.test(spec)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
violations.push({
|
|
45
|
+
file: "package.json",
|
|
46
|
+
ruleId: "package-exact-deps",
|
|
47
|
+
severity: "warn",
|
|
48
|
+
message: `${section}.${name} is "${spec}" — dependencies and devDependencies must be exact versions; only peerDependencies should use ranges.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return violations;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import type { IMetaRule, IMetaRuleViolation } from "../../meta-rules.types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File suffixes that indicate logic files requiring tests.
|
|
7
|
+
*/
|
|
8
|
+
const LOGIC_FILE_SUFFIXES = /\.(service|utils|helpers|handler)\.ts$/u;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Directories that are logic-heavy by definition.
|
|
12
|
+
*/
|
|
13
|
+
const LOGIC_DIRS = new Set(["lib"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if a source file looks like a logic module that should have a test.
|
|
17
|
+
* Excludes test files, type-only modules, and barrel exports.
|
|
18
|
+
*/
|
|
19
|
+
function isLogicFile(file: string): boolean {
|
|
20
|
+
// Exclude existing test files
|
|
21
|
+
if (file.includes(".test.ts") || file.includes(".spec.ts")) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Exclude barrel exports and type-only modules
|
|
26
|
+
if (file.endsWith("/index.ts") || file.endsWith(".types.ts")) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check for logic file suffix
|
|
31
|
+
if (LOGIC_FILE_SUFFIXES.test(file)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check if in a logic directory
|
|
36
|
+
for (const logicDir of LOGIC_DIRS) {
|
|
37
|
+
if (
|
|
38
|
+
file.includes(`/lib/${logicDir}/`) ||
|
|
39
|
+
file.includes(`\\lib\\${logicDir}\\`)
|
|
40
|
+
) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const testSiblingRequiredRule: IMetaRule = {
|
|
49
|
+
id: "test-sibling-required",
|
|
50
|
+
category: "testing",
|
|
51
|
+
description:
|
|
52
|
+
"Logic modules (*.service.ts, *.utils.ts, etc.) should have a co-located *.test.ts sibling.",
|
|
53
|
+
severity: "warn",
|
|
54
|
+
run({ root, sourceFiles }) {
|
|
55
|
+
const violations: IMetaRuleViolation[] = [];
|
|
56
|
+
|
|
57
|
+
for (const file of sourceFiles) {
|
|
58
|
+
if (!isLogicFile(file)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Expected test location: src/foo/bar.ts → tests/foo/bar.test.ts
|
|
63
|
+
const normalized = file.replace(/\\/g, "/");
|
|
64
|
+
|
|
65
|
+
let expectedTest: string;
|
|
66
|
+
|
|
67
|
+
if (normalized.startsWith("src/")) {
|
|
68
|
+
const relativePath = normalized.slice(4); // Remove "src/"
|
|
69
|
+
|
|
70
|
+
expectedTest = join(
|
|
71
|
+
root,
|
|
72
|
+
"tests",
|
|
73
|
+
relativePath.replace(/\.ts$/, ".test.ts")
|
|
74
|
+
);
|
|
75
|
+
} else if (normalized.startsWith("scripts/")) {
|
|
76
|
+
const relativePath = normalized.slice(8); // Remove "scripts/"
|
|
77
|
+
|
|
78
|
+
expectedTest = join(
|
|
79
|
+
root,
|
|
80
|
+
"tests",
|
|
81
|
+
"scripts",
|
|
82
|
+
relativePath.replace(/\.ts$/, ".test.ts")
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
continue; // Unrecognized directory
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const stat = statSync(expectedTest);
|
|
90
|
+
|
|
91
|
+
if (stat.isFile()) {
|
|
92
|
+
continue; // Test exists
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Test does not exist
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const relativeExpectedTest = expectedTest.slice(root.length + 1);
|
|
99
|
+
|
|
100
|
+
violations.push({
|
|
101
|
+
file,
|
|
102
|
+
ruleId: "test-sibling-required",
|
|
103
|
+
severity: "warn",
|
|
104
|
+
message: `Missing unit-test sibling. Expected \`${relativeExpectedTest}\` to exist alongside this logic module.`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return violations;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMetaRule,
|
|
3
|
+
IMetaRuleContext,
|
|
4
|
+
IMetaRuleViolation,
|
|
5
|
+
} from "./meta-rules.types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run applicable meta-rules and return violations sorted deterministically
|
|
9
|
+
* (by file, then rule ID). Severity overrides can silence rules ("off") or
|
|
10
|
+
* change their severity (rule ID mapped to "error" | "warn" | "off").
|
|
11
|
+
*/
|
|
12
|
+
export function runMetaRules(
|
|
13
|
+
rules: readonly IMetaRule[],
|
|
14
|
+
ctx: IMetaRuleContext,
|
|
15
|
+
severityOverrides?: Readonly<Record<string, "error" | "warn" | "off">>
|
|
16
|
+
): IMetaRuleViolation[] {
|
|
17
|
+
const violations: IMetaRuleViolation[] = [];
|
|
18
|
+
|
|
19
|
+
for (const rule of rules) {
|
|
20
|
+
// Check if rule is silenced via overrides
|
|
21
|
+
if (severityOverrides?.[rule.id] === "off") {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check if rule applies to this project's active packs
|
|
26
|
+
if (rule.appliesTo !== undefined && rule.appliesTo.length > 0) {
|
|
27
|
+
const applies = rule.appliesTo.some((pack) =>
|
|
28
|
+
ctx.activePacks.includes(pack)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (!applies) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ruleViolations = rule.run(ctx);
|
|
37
|
+
const override = severityOverrides?.[rule.id];
|
|
38
|
+
|
|
39
|
+
// Apply severity override if present (and not "off")
|
|
40
|
+
if (override !== undefined && override !== "off") {
|
|
41
|
+
for (const v of ruleViolations) {
|
|
42
|
+
violations.push({
|
|
43
|
+
...v,
|
|
44
|
+
severity: override,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
violations.push(...ruleViolations);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Deterministic ordering: file path, then rule ID
|
|
53
|
+
violations.sort((a, b) => {
|
|
54
|
+
const fileCmp = a.file.localeCompare(b.file);
|
|
55
|
+
|
|
56
|
+
if (fileCmp !== 0) {
|
|
57
|
+
return fileCmp;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return a.ruleId.localeCompare(b.ruleId);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return violations;
|
|
64
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
4
|
+
import { isRecord } from "./lib/guards";
|
|
5
|
+
import { PROVIDER_DEFAULTS } from "./inference/inference.constants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The model registry — `~/.tsforge/models.json`, the central place a user
|
|
9
|
+
* configures N model endpoints and switches between them with `/model`. Mirrors
|
|
10
|
+
* the sessions/logs layout (under `$TSFORGE_HOME` if set, else the home dir).
|
|
11
|
+
* Loading is read-only and falls back to the built-in local-qwen default when no
|
|
12
|
+
* file exists, so behaviour is identical to the env-driven path until the user
|
|
13
|
+
* writes a registry.
|
|
14
|
+
*/
|
|
15
|
+
export interface IModelEntry {
|
|
16
|
+
/** Root of the OpenAI-compatible API, e.g. https://api.deepseek.com/v1 */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** Model id sent in the request, e.g. deepseek-reasoner */
|
|
19
|
+
model: string;
|
|
20
|
+
/** Inline API key. Prefer `apiKeyEnv` so the secret stays out of the file. */
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
/** Name of an env var holding the key — resolved at use time. Used when
|
|
23
|
+
* `apiKey` is unset, so the registry can be shared/committed without secrets. */
|
|
24
|
+
apiKeyEnv?: string;
|
|
25
|
+
/** Context window (tokens) for the status line + auto-compaction. */
|
|
26
|
+
contextWindow?: number;
|
|
27
|
+
/** Default thinking mode for this model. */
|
|
28
|
+
thinking?: boolean;
|
|
29
|
+
/** Per-response token cap override. */
|
|
30
|
+
maxTokens?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IModelsConfig {
|
|
34
|
+
/** Name of the active entry — always a key of `models`. */
|
|
35
|
+
active: string;
|
|
36
|
+
models: Record<string, IModelEntry>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The built-in local-qwen entry — matches PROVIDER_DEFAULTS so an absent
|
|
40
|
+
* registry behaves exactly like the current env-default path. */
|
|
41
|
+
const QWEN_LOCAL: IModelEntry = {
|
|
42
|
+
baseUrl: PROVIDER_DEFAULTS.baseUrl,
|
|
43
|
+
model: PROVIDER_DEFAULTS.model,
|
|
44
|
+
thinking: true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The default registry used when no models.json exists yet. */
|
|
48
|
+
export function defaultModelsConfig(): IModelsConfig {
|
|
49
|
+
return { active: "qwen-local", models: { "qwen-local": QWEN_LOCAL } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The registry path: `$TSFORGE_HOME`/.tsforge/models.json, else under home. */
|
|
53
|
+
export function modelsConfigPath(): string {
|
|
54
|
+
return join(process.env.TSFORGE_HOME ?? homedir(), ".tsforge", "models.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isModelEntry(value: unknown): value is IModelEntry {
|
|
58
|
+
return (
|
|
59
|
+
isRecord(value) &&
|
|
60
|
+
typeof value.baseUrl === "string" &&
|
|
61
|
+
typeof value.model === "string"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Validate a parsed object into a registry, with actionable errors — the file is
|
|
66
|
+
* hand-edited, so a clear message beats a silent fallback. */
|
|
67
|
+
export function parseModelsConfig(raw: unknown): IModelsConfig {
|
|
68
|
+
if (
|
|
69
|
+
!isRecord(raw) ||
|
|
70
|
+
typeof raw.active !== "string" ||
|
|
71
|
+
!isRecord(raw.models)
|
|
72
|
+
) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"models.json: expected { active: string, models: { <name>: { baseUrl, model } } }"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const models: Record<string, IModelEntry> = {};
|
|
79
|
+
|
|
80
|
+
for (const [name, entry] of Object.entries(raw.models)) {
|
|
81
|
+
if (!isModelEntry(entry)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`models.json: model "${name}" needs at least { baseUrl, model }`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
models[name] = entry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (models[raw.active] === undefined) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`models.json: active "${raw.active}" is not one of: ${Object.keys(models).join(", ")}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { active: raw.active, models };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read the registry (read-only). Missing file → the built-in default (no write);
|
|
100
|
+
* malformed file/JSON → a clear error naming the path. */
|
|
101
|
+
export async function loadModelsConfig(): Promise<IModelsConfig> {
|
|
102
|
+
let text: string;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
text = await readFile(modelsConfigPath(), "utf8");
|
|
106
|
+
} catch {
|
|
107
|
+
return defaultModelsConfig();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let raw: unknown;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
raw = JSON.parse(text);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(`models.json: invalid JSON at ${modelsConfigPath()}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parseModelsConfig(raw);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Write the registry: dir 0700, file 0600 (it may hold an inline key). */
|
|
122
|
+
export async function saveModelsConfig(cfg: IModelsConfig): Promise<void> {
|
|
123
|
+
const dir = join(process.env.TSFORGE_HOME ?? homedir(), ".tsforge");
|
|
124
|
+
|
|
125
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
126
|
+
const path = modelsConfigPath();
|
|
127
|
+
|
|
128
|
+
await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
129
|
+
await chmod(path, 0o600);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Switch the active model and persist. Throws (listing the options) on an
|
|
133
|
+
* unknown name so `/model <typo>` gets a helpful message, not a silent no-op. */
|
|
134
|
+
export async function setActiveModel(name: string): Promise<IModelsConfig> {
|
|
135
|
+
const cfg = await loadModelsConfig();
|
|
136
|
+
|
|
137
|
+
if (cfg.models[name] === undefined) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`unknown model "${name}" — configured: ${Object.keys(cfg.models).join(", ")}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const next: IModelsConfig = { active: name, models: cfg.models };
|
|
144
|
+
|
|
145
|
+
await saveModelsConfig(next);
|
|
146
|
+
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The API key for an entry: inline `apiKey` wins, else the env var named by
|
|
151
|
+
* `apiKeyEnv`. Undefined when neither is set (local endpoints need none). */
|
|
152
|
+
export function resolveApiKey(entry: IModelEntry): string | undefined {
|
|
153
|
+
if (entry.apiKey !== undefined && entry.apiKey.length > 0) {
|
|
154
|
+
return entry.apiKey;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (entry.apiKeyEnv !== undefined) {
|
|
158
|
+
return process.env[entry.apiKeyEnv];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** An ad-hoc entry built from TSFORGE_* env, or undefined when none are set.
|
|
165
|
+
* Explicit env is the escape hatch that overrides the registry on startup. */
|
|
166
|
+
export function envModelEntry(): IModelEntry | undefined {
|
|
167
|
+
const baseUrl = process.env.TSFORGE_BASE_URL;
|
|
168
|
+
const model = process.env.TSFORGE_MODEL;
|
|
169
|
+
|
|
170
|
+
if (baseUrl === undefined && model === undefined) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
baseUrl: baseUrl ?? PROVIDER_DEFAULTS.baseUrl,
|
|
176
|
+
model: model ?? PROVIDER_DEFAULTS.model,
|
|
177
|
+
apiKey: process.env.TSFORGE_API_KEY,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Resolve the model to use NOW: explicit TSFORGE_* env wins (escape hatch),
|
|
182
|
+
* else the registry's active entry. Returns the display name + the entry. */
|
|
183
|
+
export async function resolveActiveModel(): Promise<{
|
|
184
|
+
name: string;
|
|
185
|
+
entry: IModelEntry;
|
|
186
|
+
}> {
|
|
187
|
+
const env = envModelEntry();
|
|
188
|
+
|
|
189
|
+
if (env !== undefined) {
|
|
190
|
+
return { name: "env", entry: env };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const cfg = await loadModelsConfig();
|
|
194
|
+
|
|
195
|
+
return { name: cfg.active, entry: cfg.models[cfg.active] ?? QWEN_LOCAL };
|
|
196
|
+
}
|