@agjs/tsforge 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.1.5",
4
+ "version": "0.1.7",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -289,6 +289,16 @@
289
289
  "bad": "// Example that violates the rule",
290
290
  "good": "// Corrected version"
291
291
  },
292
+ "tsforge/no-import-build-output": {
293
+ "what": "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
294
+ "bad": "// Example that violates the rule",
295
+ "good": "// Corrected version"
296
+ },
297
+ "tsforge/no-import-test-from-source": {
298
+ "what": "Disallow production/source files from importing test files. Tests may depend on source, never the reverse — test code must not ship in the production graph.",
299
+ "bad": "// Example that violates the rule",
300
+ "good": "// Corrected version"
301
+ },
292
302
  "tsforge/pkce-required-for-oidc": {
293
303
  "what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
294
304
  "bad": "// Example that violates the rule",
package/src/loop/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path";
1
2
  import type { ITask } from "../spec";
2
3
  import type { IChatMessage, IModelResponse, IProvider } from "../inference";
3
4
  import { validate, type ErrorParser } from "../validate";
@@ -8,7 +9,7 @@ import type { IRunResult, IRunOptions, Reporter } from "./loop.types";
8
9
  import { flags } from "../config";
9
10
  import { SYSTEM, seedPrompt } from "./prompt";
10
11
  import { detectStack } from "../stack-detection";
11
- import { TtsrManager } from "./ttsr";
12
+ import { TtsrManager, parseProjectRules, type ITtsrRule } from "./ttsr";
12
13
  import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
13
14
  import {
14
15
  type ILoopCtx,
@@ -65,8 +66,27 @@ function handleDegeneration(
65
66
  };
66
67
  }
67
68
 
68
- /** Build and configure a TTSR manager if enabled. Returns null if disabled. */
69
- function initTtsrManager(): TtsrManager | null {
69
+ /** Read and parse `<cwd>/.tsforge/rules.json` if present. Missing or invalid
70
+ * files yield no rules (parseProjectRules tolerates malformed JSON). */
71
+ export async function loadProjectTtsrRules(cwd: string): Promise<ITtsrRule[]> {
72
+ const file = Bun.file(join(cwd, ".tsforge", "rules.json"));
73
+
74
+ if (!(await file.exists())) {
75
+ return [];
76
+ }
77
+
78
+ return parseProjectRules(await file.text());
79
+ }
80
+
81
+ /** Build and configure a TTSR manager if enabled. Returns null if disabled.
82
+ * Built-in defaults register first, then optional project rules from
83
+ * `<cwd>/.tsforge/rules.json`; `addRule` ignores duplicate names, so a
84
+ * built-in safety rule always wins over a same-named project rule. */
85
+ export async function initTtsrManager(
86
+ cwd: string,
87
+ report: Reporter,
88
+ taskId: string
89
+ ): Promise<TtsrManager | null> {
70
90
  if (!flags.ttsr()) {
71
91
  return null;
72
92
  }
@@ -77,6 +97,22 @@ function initTtsrManager(): TtsrManager | null {
77
97
  manager.addRule(rule);
78
98
  }
79
99
 
100
+ let added = 0;
101
+
102
+ for (const rule of await loadProjectTtsrRules(cwd)) {
103
+ if (manager.addRule(rule)) {
104
+ added += 1;
105
+ }
106
+ }
107
+
108
+ if (added > 0) {
109
+ report({
110
+ kind: "ttsr",
111
+ task: taskId,
112
+ message: `loaded ${added} custom TTSR rule(s) from .tsforge/rules.json`,
113
+ });
114
+ }
115
+
80
116
  return manager;
81
117
  }
82
118
 
@@ -262,7 +298,7 @@ export async function runTask(
262
298
  thinkingTokenBudget ??
263
299
  (hasExistingCode ? undefined : LOOP_LIMITS.scratchThinkingBudget);
264
300
 
265
- const ttsrManager = initTtsrManager();
301
+ const ttsrManager = await initTtsrManager(cwd, report, task.id);
266
302
 
267
303
  const ctx: ILoopCtx = {
268
304
  task,
@@ -8,6 +8,7 @@ import { elysiaPack } from "./elysia";
8
8
  import { envAccessPack } from "./env-access";
9
9
  import { i18nKeysPack } from "./i18n-keys";
10
10
  import { jwtCookiesPack } from "./jwt-cookies";
11
+ import { moduleBoundariesPack } from "./module-boundaries";
11
12
  import { oauthSecurityPack } from "./oauth-security";
12
13
  import { reactComponentArchitecturePack } from "./react-component-architecture";
13
14
  import { structuredLoggingPack } from "./structured-logging";
@@ -25,6 +26,7 @@ export const RULE_PACKS = {
25
26
  "env-access": envAccessPack,
26
27
  "i18n-keys": i18nKeysPack,
27
28
  "jwt-cookies": jwtCookiesPack,
29
+ "module-boundaries": moduleBoundariesPack,
28
30
  "oauth-security": oauthSecurityPack,
29
31
  "react-component-architecture": reactComponentArchitecturePack,
30
32
  "structured-logging": structuredLoggingPack,
@@ -0,0 +1,23 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { noImportBuildOutputRule } from "./rules/no-import-build-output";
4
+ import { noImportTestFromSourceRule } from "./rules/no-import-test-from-source";
5
+ import type { IRulePack } from "../rule-packs.types";
6
+
7
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
8
+ "no-import-build-output": noImportBuildOutputRule,
9
+ "no-import-test-from-source": noImportTestFromSourceRule,
10
+ };
11
+
12
+ export const moduleBoundariesPack: IRulePack = {
13
+ id: "module-boundaries",
14
+ description:
15
+ "Module boundary hygiene: keep the test/production and source/build-output boundaries clean so the dependency graph stays sound.",
16
+ rules,
17
+ rulesConfig: {
18
+ "no-import-build-output": "error",
19
+ "no-import-test-from-source": "error",
20
+ },
21
+ };
22
+
23
+ export default moduleBoundariesPack;
@@ -0,0 +1,71 @@
1
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { hasDirSegment, isRelativeImport } from "../utils";
5
+
6
+ export const RULE_NAME = "no-import-build-output";
7
+
8
+ export interface NoImportBuildOutputOptions {
9
+ readonly outputDirs?: readonly string[];
10
+ }
11
+
12
+ type RuleOptions = [NoImportBuildOutputOptions];
13
+ type MessageIds = "buildOutputImported";
14
+
15
+ const DEFAULT_OUTPUT_DIRS: readonly string[] = [
16
+ "dist",
17
+ "build",
18
+ "out",
19
+ ".next",
20
+ "coverage",
21
+ ];
22
+
23
+ const optionSchema: JSONSchema4 = {
24
+ type: "object",
25
+ additionalProperties: false,
26
+ properties: {
27
+ outputDirs: {
28
+ type: "array",
29
+ uniqueItems: true,
30
+ items: { type: "string" },
31
+ },
32
+ },
33
+ };
34
+
35
+ export const noImportBuildOutputRule = createRule<RuleOptions, MessageIds>({
36
+ name: RULE_NAME,
37
+ meta: {
38
+ type: "problem",
39
+ docs: {
40
+ description:
41
+ "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
42
+ },
43
+ schema: [optionSchema],
44
+ messages: {
45
+ buildOutputImported:
46
+ "Do not import from build output ('{{source}}'). Import the source module directly so the build graph stays the single source of truth.",
47
+ },
48
+ },
49
+ defaultOptions: [{ outputDirs: [...DEFAULT_OUTPUT_DIRS] }],
50
+ create(context, [options]) {
51
+ const outputDirs = new Set(options.outputDirs ?? DEFAULT_OUTPUT_DIRS);
52
+
53
+ return {
54
+ ImportDeclaration(node) {
55
+ const source = node.source.value;
56
+
57
+ if (typeof source !== "string" || !isRelativeImport(source)) {
58
+ return;
59
+ }
60
+
61
+ if (hasDirSegment(source, outputDirs)) {
62
+ context.report({
63
+ node: node.source,
64
+ messageId: "buildOutputImported",
65
+ data: { source },
66
+ });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ });
@@ -0,0 +1,74 @@
1
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { hasDirSegment, isRelativeImport, isTestFileName } from "../utils";
5
+
6
+ export const RULE_NAME = "no-import-test-from-source";
7
+
8
+ export interface NoImportTestFromSourceOptions {
9
+ readonly testDirNames?: readonly string[];
10
+ }
11
+
12
+ type RuleOptions = [NoImportTestFromSourceOptions];
13
+ type MessageIds = "testImportedFromSource";
14
+
15
+ const DEFAULT_TEST_DIR_NAMES: readonly string[] = ["__tests__", "__mocks__"];
16
+
17
+ const optionSchema: JSONSchema4 = {
18
+ type: "object",
19
+ additionalProperties: false,
20
+ properties: {
21
+ testDirNames: {
22
+ type: "array",
23
+ uniqueItems: true,
24
+ items: { type: "string" },
25
+ },
26
+ },
27
+ };
28
+
29
+ export const noImportTestFromSourceRule = createRule<RuleOptions, MessageIds>({
30
+ name: RULE_NAME,
31
+ meta: {
32
+ type: "problem",
33
+ docs: {
34
+ description:
35
+ "Disallow production/source files from importing test files. Tests may depend on source, never the reverse — test code must not ship in the production graph.",
36
+ },
37
+ schema: [optionSchema],
38
+ messages: {
39
+ testImportedFromSource:
40
+ "Source files must not import test files ('{{source}}'). Move shared helpers into a non-test module so production code never depends on tests.",
41
+ },
42
+ },
43
+ defaultOptions: [{ testDirNames: [...DEFAULT_TEST_DIR_NAMES] }],
44
+ create(context, [options]) {
45
+ const testDirs = new Set(options.testDirNames ?? DEFAULT_TEST_DIR_NAMES);
46
+
47
+ // A test file may freely import other test files; only enforce the
48
+ // boundary when the importing file is itself non-test.
49
+ if (
50
+ isTestFileName(context.filename) ||
51
+ hasDirSegment(context.filename, testDirs)
52
+ ) {
53
+ return {};
54
+ }
55
+
56
+ return {
57
+ ImportDeclaration(node) {
58
+ const source = node.source.value;
59
+
60
+ if (typeof source !== "string" || !isRelativeImport(source)) {
61
+ return;
62
+ }
63
+
64
+ if (isTestFileName(source) || hasDirSegment(source, testDirs)) {
65
+ context.report({
66
+ node: node.source,
67
+ messageId: "testImportedFromSource",
68
+ data: { source },
69
+ });
70
+ }
71
+ },
72
+ };
73
+ },
74
+ });
@@ -0,0 +1,29 @@
1
+ /** True for relative import specifiers (`./x`, `../x`) — i.e. paths inside the
2
+ * project, as opposed to bare package specifiers (`react`, `@scope/pkg`). */
3
+ export function isRelativeImport(source: string): boolean {
4
+ return source.startsWith("./") || source.startsWith("../");
5
+ }
6
+
7
+ /** Path segments of a "/"-separated specifier, dropping "", ".", "..". */
8
+ export function pathSegments(specifier: string): string[] {
9
+ return specifier
10
+ .split("/")
11
+ .filter((seg) => seg.length > 0 && seg !== "." && seg !== "..");
12
+ }
13
+
14
+ /** True if any directory segment of the specifier is in `dirs`. */
15
+ export function hasDirSegment(
16
+ specifier: string,
17
+ dirs: ReadonlySet<string>
18
+ ): boolean {
19
+ return pathSegments(specifier).some((seg) => dirs.has(seg));
20
+ }
21
+
22
+ /** True if the specifier's final segment names a test/spec module
23
+ * (`foo.test`, `foo.test.ts`, `foo.spec.tsx`, …). */
24
+ export function isTestFileName(specifier: string): boolean {
25
+ const segments = specifier.split("/");
26
+ const base = segments[segments.length - 1] ?? "";
27
+
28
+ return /\.(?:test|spec)(?:\.[^.]+)?$/.test(base);
29
+ }