@agjs/tsforge 0.1.9 → 0.1.10

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.9",
4
+ "version": "0.1.10",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -0,0 +1,152 @@
1
+ import { resolve } from "node:path";
2
+ import { isRecord } from "../lib/guards";
3
+ import { registerExternalPack } from "../rule-packs";
4
+ import type { IRulePack } from "../rule-packs/rule-packs.types";
5
+
6
+ /** One external plugin entry from tsforge.config.json `plugins`. */
7
+ export interface IExternalPlugin {
8
+ /** Module specifier or path (relative paths resolve against the repo root). */
9
+ readonly path: string;
10
+ /** Named exports to load as rule packs. Omit to load every exported pack. */
11
+ readonly packs?: readonly string[];
12
+ }
13
+
14
+ function errMessage(err: unknown): string {
15
+ return err instanceof Error ? err.message : String(err);
16
+ }
17
+
18
+ /** Type guard: a well-formed IRulePack (no `as` — every field is checked). */
19
+ export function isRulePack(value: unknown): value is IRulePack {
20
+ if (!isRecord(value)) {
21
+ return false;
22
+ }
23
+
24
+ if (typeof value.id !== "string" || value.id.length === 0) {
25
+ return false;
26
+ }
27
+
28
+ if (typeof value.description !== "string") {
29
+ return false;
30
+ }
31
+
32
+ if (!isRecord(value.rules) || !isRecord(value.rulesConfig)) {
33
+ return false;
34
+ }
35
+
36
+ for (const severity of Object.values(value.rulesConfig)) {
37
+ if (severity !== "error" && severity !== "warn") {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ return true;
43
+ }
44
+
45
+ /** Parse the `plugins` config field into validated entries. */
46
+ export function parsePlugins(raw: unknown): IExternalPlugin[] {
47
+ if (!Array.isArray(raw)) {
48
+ return [];
49
+ }
50
+
51
+ const plugins: IExternalPlugin[] = [];
52
+
53
+ for (const item of raw) {
54
+ if (
55
+ !isRecord(item) ||
56
+ typeof item.path !== "string" ||
57
+ item.path.length === 0
58
+ ) {
59
+ continue;
60
+ }
61
+
62
+ const packs = Array.isArray(item.packs)
63
+ ? item.packs.filter((p): p is string => typeof p === "string")
64
+ : undefined;
65
+
66
+ plugins.push({
67
+ path: item.path,
68
+ ...(packs !== undefined && packs.length > 0 ? { packs } : {}),
69
+ });
70
+ }
71
+
72
+ return plugins;
73
+ }
74
+
75
+ /** Collect the candidate exports to validate from a loaded module. */
76
+ function candidateExports(
77
+ mod: Record<string, unknown>,
78
+ names: readonly string[] | undefined
79
+ ): unknown[] {
80
+ if (names === undefined) {
81
+ return Object.values(mod);
82
+ }
83
+
84
+ return names.map((name) => mod[name]);
85
+ }
86
+
87
+ /**
88
+ * Dynamically import each plugin and collect its valid exported rule packs.
89
+ * Never throws — an unimportable module or an export that is not a valid pack is
90
+ * reported and skipped, so a broken plugin can't take down a run.
91
+ */
92
+ export async function loadExternalPacks(
93
+ plugins: readonly IExternalPlugin[],
94
+ cwd: string,
95
+ report: (message: string) => void
96
+ ): Promise<IRulePack[]> {
97
+ const out: IRulePack[] = [];
98
+
99
+ for (const plugin of plugins) {
100
+ const specifier = plugin.path.startsWith(".")
101
+ ? resolve(cwd, plugin.path)
102
+ : plugin.path;
103
+
104
+ let mod: unknown;
105
+
106
+ try {
107
+ mod = await import(specifier);
108
+ } catch (err) {
109
+ report(`plugin '${plugin.path}' failed to load: ${errMessage(err)}`);
110
+
111
+ continue;
112
+ }
113
+
114
+ if (!isRecord(mod)) {
115
+ continue;
116
+ }
117
+
118
+ for (const candidate of candidateExports(mod, plugin.packs)) {
119
+ if (isRulePack(candidate)) {
120
+ out.push(candidate);
121
+ report(`plugin '${plugin.path}': loaded pack '${candidate.id}'`);
122
+ } else {
123
+ report(
124
+ `plugin '${plugin.path}': an export is not a valid rule pack — skipped`
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Load every configured plugin, register its packs in the rule-pack registry,
135
+ * and return the registered pack ids (to fold into the active pack list so the
136
+ * gate runs them). Never throws.
137
+ */
138
+ export async function loadAndRegisterPlugins(
139
+ plugins: readonly IExternalPlugin[],
140
+ cwd: string,
141
+ report: (message: string) => void
142
+ ): Promise<string[]> {
143
+ const packs = await loadExternalPacks(plugins, cwd, report);
144
+ const ids: string[] = [];
145
+
146
+ for (const pack of packs) {
147
+ registerExternalPack(pack);
148
+ ids.push(pack.id);
149
+ }
150
+
151
+ return ids;
152
+ }
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
  import { isRecord } from "../lib/guards";
3
3
  import { PACK_REGISTRY } from "../stack-detection";
4
4
  import { parseMcpServers, type IMcpServerConfig } from "../mcp";
5
+ import { parsePlugins, type IExternalPlugin } from "./external-plugins";
5
6
 
6
7
  /**
7
8
  * User-defined configuration from tsforge.config.json
@@ -31,6 +32,13 @@ export interface ITsforgeProjectConfig {
31
32
  * interpolated from the environment at load time. Opt-in: absent ⇒ no MCP.
32
33
  */
33
34
  readonly mcpServers?: Readonly<Record<string, IMcpServerConfig>>;
35
+
36
+ /**
37
+ * External plugins providing extra rule packs, loaded without recompiling
38
+ * tsforge. Each entry names a module (or relative path) and, optionally, which
39
+ * exported packs to use. Opt-in: absent ⇒ only built-in packs.
40
+ */
41
+ readonly plugins?: readonly IExternalPlugin[];
34
42
  }
35
43
 
36
44
  function warnConfig(msg: string): void {
@@ -177,6 +185,7 @@ function buildConfigFields(
177
185
  packs?: { include?: readonly string[]; exclude?: readonly string[] };
178
186
  rules?: Record<string, "error" | "warn" | "off">;
179
187
  mcpServers?: Record<string, IMcpServerConfig>;
188
+ plugins?: readonly IExternalPlugin[];
180
189
  } = {};
181
190
 
182
191
  if (parsed.stack !== undefined) {
@@ -211,6 +220,14 @@ function buildConfigFields(
211
220
  }
212
221
  }
213
222
 
223
+ if (parsed.plugins !== undefined) {
224
+ const plugins = parsePlugins(parsed.plugins);
225
+
226
+ if (plugins.length > 0) {
227
+ configFields.plugins = plugins;
228
+ }
229
+ }
230
+
214
231
  return configFields;
215
232
  }
216
233
 
package/src/loop/run.ts CHANGED
@@ -191,20 +191,31 @@ function effectiveParserFor(
191
191
  return flags.legacyFeedback() ? parseEslintJson : parse;
192
192
  }
193
193
 
194
- /** Detect the stack and fold in tsforge.config.json pack/rule overrides. */
195
- async function resolveStackForRun(cwd: string): Promise<{
194
+ /** Detect the stack and fold in tsforge.config.json pack/rule overrides, plus any
195
+ * rule packs from configured external plugins. */
196
+ async function resolveStackForRun(
197
+ cwd: string,
198
+ report: (message: string) => void
199
+ ): Promise<{
196
200
  stackProfile: Awaited<ReturnType<typeof detectStack>>;
197
201
  ruleOverrides: Readonly<Record<string, "error" | "warn" | "off">>;
198
202
  }> {
199
203
  const detectedProfile = await detectStack(cwd);
200
204
  const { loadTsforgeConfig, resolveActivePacks, normalizeRuleOverrides } =
201
205
  await import("../config/tsforge-config");
206
+ const { loadAndRegisterPlugins } = await import("../config/external-plugins");
202
207
  const cfg = await loadTsforgeConfig(cwd);
208
+ const activePacks = resolveActivePacks(detectedProfile.packs, cfg);
209
+ const externalIds =
210
+ cfg.plugins === undefined
211
+ ? []
212
+ : await loadAndRegisterPlugins(cfg.plugins, cwd, report);
203
213
 
204
214
  return {
205
215
  stackProfile: {
206
216
  ...detectedProfile,
207
- packs: resolveActivePacks(detectedProfile.packs, cfg),
217
+ packs:
218
+ externalIds.length > 0 ? [...activePacks, ...externalIds] : activePacks,
208
219
  },
209
220
  ruleOverrides: normalizeRuleOverrides(cfg),
210
221
  };
@@ -268,7 +279,12 @@ export async function runTask(
268
279
  });
269
280
 
270
281
  // Detect stack once per run, early; tsforge.config.json may adjust it
271
- const { stackProfile, ruleOverrides } = await resolveStackForRun(cwd);
282
+ const { stackProfile, ruleOverrides } = await resolveStackForRun(
283
+ cwd,
284
+ (message) => {
285
+ report({ kind: "tool", task: task.id, message });
286
+ }
287
+ );
272
288
 
273
289
  report({
274
290
  kind: "tool",
@@ -26,6 +26,7 @@ import {
26
26
  resolveActivePacks,
27
27
  } from "../config/tsforge-config";
28
28
  import { connectMcpServers } from "../mcp";
29
+ import { loadAndRegisterPlugins } from "../config/external-plugins";
29
30
  import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
30
31
  import type { Reporter } from "./loop.types";
31
32
  import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
@@ -434,9 +435,25 @@ export class Session {
434
435
  // pack selection and rule-severity overrides.
435
436
  const detected = await detectStack(cfg.cwd);
436
437
  const projectConfig = await loadTsforgeConfig(cfg.cwd);
438
+ const activePacks = resolveActivePacks(detected.packs, projectConfig);
439
+ // Opt-in: load rule packs from external plugins and fold their ids into the
440
+ // active packs so the gate runs them. loadAndRegisterPlugins never throws.
441
+ const externalPackIds =
442
+ projectConfig.plugins === undefined
443
+ ? []
444
+ : await loadAndRegisterPlugins(
445
+ projectConfig.plugins,
446
+ cfg.cwd,
447
+ (message) => {
448
+ report({ kind: "tool", task: SESSION_ID, message });
449
+ }
450
+ );
437
451
  const stackProfile = {
438
452
  ...detected,
439
- packs: resolveActivePacks(detected.packs, projectConfig),
453
+ packs:
454
+ externalPackIds.length > 0
455
+ ? [...activePacks, ...externalPackIds]
456
+ : activePacks,
440
457
  };
441
458
  const ruleOverrides = normalizeRuleOverrides(projectConfig);
442
459
 
@@ -1,5 +1,6 @@
1
1
  import type { TSESLint } from "@typescript-eslint/utils";
2
2
 
3
+ import type { IRulePack } from "./rule-packs.types";
3
4
  import { bullmqPack } from "./bullmq";
4
5
  import { commentHygienePack } from "./comment-hygiene";
5
6
  import { codeFlowPack } from "./code-flow";
@@ -43,6 +44,31 @@ function isRulePackId(id: unknown): id is IRulePackId {
43
44
  return typeof id === "string" && id in RULE_PACKS;
44
45
  }
45
46
 
47
+ /** Externally-registered rule packs (from tsforge.config.json `plugins`). Kept
48
+ * separate from the built-in RULE_PACKS so a user pack can never shadow a
49
+ * built-in by id; rule-name collisions still fail the build in
50
+ * buildPackEslintConfig. */
51
+ const EXTERNAL_PACKS = new Map<string, IRulePack>();
52
+
53
+ /** Register an external rule pack so its id resolves in buildPackEslintConfig. */
54
+ export function registerExternalPack(pack: IRulePack): void {
55
+ EXTERNAL_PACKS.set(pack.id, pack);
56
+ }
57
+
58
+ /** Drop all registered external packs (used by tests for isolation). */
59
+ export function clearExternalPacks(): void {
60
+ EXTERNAL_PACKS.clear();
61
+ }
62
+
63
+ /** Resolve a pack id to its definition, built-ins first, then external packs. */
64
+ function lookupPack(packId: string): IRulePack | undefined {
65
+ if (isRulePackId(packId)) {
66
+ return RULE_PACKS[packId];
67
+ }
68
+
69
+ return EXTERNAL_PACKS.get(packId);
70
+ }
71
+
46
72
  /** Apply rule overrides: "off" drops a rule, error/warn replaces its severity. */
47
73
  function applyOverrides(
48
74
  mergedRulesConfig: Readonly<Record<string, "error" | "warn">>,
@@ -93,7 +119,7 @@ export function buildPackEslintConfig(
93
119
  const seenRuleNames = new Set<string>();
94
120
 
95
121
  for (const packId of packIds) {
96
- const pack = isRulePackId(packId) ? RULE_PACKS[packId] : undefined;
122
+ const pack = lookupPack(packId);
97
123
 
98
124
  // Skip pack IDs known to stack-detection but absent from RULE_PACKS
99
125
  if (pack === undefined) {