@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 +1 -1
- package/src/config/external-plugins.ts +152 -0
- package/src/config/tsforge-config.ts +17 -0
- package/src/loop/run.ts +20 -4
- package/src/loop/session.ts +18 -1
- package/src/rule-packs/index.ts +27 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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",
|
package/src/loop/session.ts
CHANGED
|
@@ -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:
|
|
453
|
+
packs:
|
|
454
|
+
externalPackIds.length > 0
|
|
455
|
+
? [...activePacks, ...externalPackIds]
|
|
456
|
+
: activePacks,
|
|
440
457
|
};
|
|
441
458
|
const ruleOverrides = normalizeRuleOverrides(projectConfig);
|
|
442
459
|
|
package/src/rule-packs/index.ts
CHANGED
|
@@ -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 =
|
|
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) {
|