@cad0p/pi-steering 0.1.0 → 0.2.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/CHANGELOG.md +27 -4
- package/README.md +20 -6
- package/dist/__test-helpers__.d.ts +5 -4
- package/dist/__test-helpers__.d.ts.map +1 -1
- package/dist/__test-helpers__.js +5 -4
- package/dist/__test-helpers__.js.map +1 -1
- package/dist/bin/pi-steering.d.ts.map +1 -1
- package/dist/bin/pi-steering.js +22 -16
- package/dist/bin/pi-steering.js.map +1 -1
- package/dist/evaluator.d.ts +0 -41
- package/dist/evaluator.d.ts.map +1 -1
- package/dist/evaluator.js +42 -1
- package/dist/evaluator.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/ref-text.d.ts.map +1 -1
- package/dist/internal/ref-text.js +1 -1
- package/dist/internal/ref-text.js.map +1 -1
- package/dist/internal/session-runtime.d.ts.map +1 -1
- package/dist/internal/session-runtime.js +3 -2
- package/dist/internal/session-runtime.js.map +1 -1
- package/dist/loader.d.ts +23 -18
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +91 -87
- package/dist/loader.js.map +1 -1
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -1
- package/dist/plugins/git/cwd-extensions.js.map +1 -1
- package/dist/schema.d.ts +4 -4
- package/dist/testing/index.d.ts +4 -5
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +4 -4
- package/dist/testing/index.js.map +1 -1
- package/examples/README.md +3 -3
- package/examples/combined-git-discipline/README.md +2 -2
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +16 -4
- package/examples/combined-git-discipline/package.json +16 -16
- package/examples/draft-prs-only/README.md +2 -2
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +16 -4
- package/examples/draft-prs-only/package.json +16 -16
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +16 -4
- package/examples/dynamic-reason-runtime-cwd/package.json +16 -16
- package/examples/dynamic-reason-runtime-cwd/steering.ts +5 -1
- package/examples/force-push-strict/README.md +1 -1
- package/examples/force-push-strict/node_modules/.bin/pi-steering +16 -4
- package/examples/force-push-strict/package.json +16 -16
- package/examples/no-amend/README.md +2 -2
- package/examples/no-amend/node_modules/.bin/pi-steering +16 -4
- package/examples/no-amend/package.json +16 -16
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +16 -4
- package/examples/work-item-plugin/package.json +17 -17
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +5 -1
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +5 -1
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +5 -1
- package/package.json +2 -2
- package/src/__test-helpers__.ts +5 -4
- package/src/bin/pi-steering.test.ts +14 -4
- package/src/bin/pi-steering.ts +22 -16
- package/src/evaluator.ts +6 -6
- package/src/factory-time-load.test.ts +24 -18
- package/src/index.test.ts +4 -4
- package/src/index.ts +1 -1
- package/src/internal/ref-text.ts +5 -1
- package/src/internal/session-runtime.test.ts +2 -2
- package/src/internal/session-runtime.ts +3 -2
- package/src/loader.test.ts +226 -74
- package/src/loader.ts +99 -82
- package/src/plugins/git/cwd-extensions.ts +1 -1
- package/src/schema.ts +4 -4
- package/src/testing/index.ts +7 -7
package/src/loader.test.ts
CHANGED
|
@@ -2,28 +2,30 @@
|
|
|
2
2
|
// Part of pi-steering.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Two-layer discovery + merge tests for {@link loadConfigs},
|
|
6
6
|
* {@link buildConfig}, and {@link loadSteeringConfig}.
|
|
7
7
|
*
|
|
8
8
|
* Uses the same scratch-HOME + `mkdtempSync` pattern as the v1 JSON
|
|
9
9
|
* loader tests (`../loader.test.ts`) to keep global config leakage
|
|
10
|
-
* out of the test run
|
|
11
|
-
*
|
|
10
|
+
* out of the test run — the isolated `$HOME` also points the global
|
|
11
|
+
* layer at a scratch `<tmp>/.pi/agent/steering/`. Fixtures are
|
|
12
|
+
* written fresh per test so runs are reproducible without
|
|
13
|
+
* repo-committed scratch files.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import assert from "node:assert/strict";
|
|
15
17
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { tmpdir } from "node:os";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
19
|
import { join } from "node:path";
|
|
18
20
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
19
21
|
import { useIsolatedHome } from "./__test-helpers__.ts";
|
|
20
22
|
import {
|
|
21
|
-
ancestorChain,
|
|
22
23
|
buildConfig,
|
|
23
24
|
configCandidates,
|
|
24
25
|
findConfigFile,
|
|
25
26
|
loadConfigs,
|
|
26
27
|
loadSteeringConfig,
|
|
28
|
+
resolveAgentDir,
|
|
27
29
|
} from "./loader.ts";
|
|
28
30
|
import type { Plugin, SteeringConfig } from "./schema.ts";
|
|
29
31
|
|
|
@@ -57,63 +59,46 @@ describe("loader: configCandidates", () => {
|
|
|
57
59
|
assert.equal(a, "/tmp/x/.pi/steering/index.ts");
|
|
58
60
|
assert.equal(b, "/tmp/x/.pi/steering.ts");
|
|
59
61
|
});
|
|
60
|
-
});
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
const chain = ancestorChain("/home/user/projects/foo/bar");
|
|
68
|
-
assert.deepEqual(chain, [
|
|
69
|
-
"/home/user/projects/foo/bar",
|
|
70
|
-
"/home/user/projects/foo",
|
|
71
|
-
"/home/user/projects",
|
|
72
|
-
"/home/user",
|
|
73
|
-
]);
|
|
74
|
-
} finally {
|
|
75
|
-
if (prior === undefined) delete process.env["HOME"];
|
|
76
|
-
else process.env["HOME"] = prior;
|
|
77
|
-
}
|
|
63
|
+
it("honors a custom slot", () => {
|
|
64
|
+
const [a, b] = configCandidates("/tmp/x", "steering");
|
|
65
|
+
assert.equal(a, "/tmp/x/steering/index.ts");
|
|
66
|
+
assert.equal(b, "/tmp/x/steering.ts");
|
|
78
67
|
});
|
|
68
|
+
});
|
|
79
69
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
describe("loader: resolveAgentDir", () => {
|
|
71
|
+
useIsolatedHome("pi-steering-v2-agentdir-");
|
|
72
|
+
|
|
73
|
+
it("falls back to ~/.pi/agent when PI_CODING_AGENT_DIR is unset", () => {
|
|
74
|
+
const prior = process.env["PI_CODING_AGENT_DIR"];
|
|
75
|
+
delete process.env["PI_CODING_AGENT_DIR"];
|
|
83
76
|
try {
|
|
84
|
-
|
|
85
|
-
assert.deepEqual(chain, ["/a/b", "/a", "/"]);
|
|
77
|
+
assert.equal(resolveAgentDir(), join(homedir(), ".pi", "agent"));
|
|
86
78
|
} finally {
|
|
87
|
-
if (prior !== undefined) process.env["
|
|
79
|
+
if (prior !== undefined) process.env["PI_CODING_AGENT_DIR"] = prior;
|
|
88
80
|
}
|
|
89
81
|
});
|
|
90
82
|
|
|
91
|
-
it("
|
|
92
|
-
const prior = process.env["
|
|
93
|
-
|
|
94
|
-
process.env["HOME"] = home;
|
|
83
|
+
it("treats an empty-string PI_CODING_AGENT_DIR as unset", () => {
|
|
84
|
+
const prior = process.env["PI_CODING_AGENT_DIR"];
|
|
85
|
+
process.env["PI_CODING_AGENT_DIR"] = "";
|
|
95
86
|
try {
|
|
96
|
-
|
|
97
|
-
assert.deepEqual(chain, [home]);
|
|
87
|
+
assert.equal(resolveAgentDir(), join(homedir(), ".pi", "agent"));
|
|
98
88
|
} finally {
|
|
99
|
-
if (prior === undefined) delete process.env["
|
|
100
|
-
else process.env["
|
|
101
|
-
rmSync(home, { recursive: true, force: true });
|
|
89
|
+
if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
|
|
90
|
+
else process.env["PI_CODING_AGENT_DIR"] = prior;
|
|
102
91
|
}
|
|
103
92
|
});
|
|
104
93
|
|
|
105
|
-
it("
|
|
106
|
-
const prior = process.env["
|
|
107
|
-
process.env["
|
|
94
|
+
it("expands a bare ~ PI_CODING_AGENT_DIR to homedir", () => {
|
|
95
|
+
const prior = process.env["PI_CODING_AGENT_DIR"];
|
|
96
|
+
process.env["PI_CODING_AGENT_DIR"] = "~";
|
|
108
97
|
try {
|
|
109
|
-
|
|
110
|
-
// walk should run to filesystem root rather than terminating
|
|
111
|
-
// at the (never-reached) $HOME sentinel.
|
|
112
|
-
const chain = ancestorChain("/other/path");
|
|
113
|
-
assert.deepEqual(chain, ["/other/path", "/other", "/"]);
|
|
98
|
+
assert.equal(resolveAgentDir(), homedir());
|
|
114
99
|
} finally {
|
|
115
|
-
if (prior === undefined) delete process.env["
|
|
116
|
-
else process.env["
|
|
100
|
+
if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
|
|
101
|
+
else process.env["PI_CODING_AGENT_DIR"] = prior;
|
|
117
102
|
}
|
|
118
103
|
});
|
|
119
104
|
});
|
|
@@ -165,10 +150,23 @@ describe("loader: findConfigFile", () => {
|
|
|
165
150
|
/both .pi\/steering.ts and .pi\/steering\/index.ts/,
|
|
166
151
|
);
|
|
167
152
|
});
|
|
153
|
+
|
|
154
|
+
it("finds the global slot (steering) under the agent dir", () => {
|
|
155
|
+
writeConfig(join(tmp, "steering.ts"), configModule("{}"));
|
|
156
|
+
writeConfig(join(tmp, "steering", "index.ts"), configModule("{}"));
|
|
157
|
+
const { file, diagnostic } = findConfigFile(tmp, "steering");
|
|
158
|
+
assert.equal(file, join(tmp, "steering", "index.ts"));
|
|
159
|
+
assert.ok(
|
|
160
|
+
diagnostic,
|
|
161
|
+
"expected a coexistence diagnostic for the global slot",
|
|
162
|
+
);
|
|
163
|
+
assert.match(diagnostic.message, /steering\.ts and steering\/index\.ts/);
|
|
164
|
+
});
|
|
168
165
|
});
|
|
169
166
|
|
|
170
167
|
// ---------------------------------------------------------------------------
|
|
171
|
-
// loadConfigs —
|
|
168
|
+
// loadConfigs — two-layer discovery, stray-file diagnostic, bad layer
|
|
169
|
+
// handling
|
|
172
170
|
// ---------------------------------------------------------------------------
|
|
173
171
|
|
|
174
172
|
describe("loader: loadConfigs", () => {
|
|
@@ -185,24 +183,131 @@ describe("loader: loadConfigs", () => {
|
|
|
185
183
|
assert.deepEqual(diagnostics, []);
|
|
186
184
|
});
|
|
187
185
|
|
|
188
|
-
it("
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
mkdirSync(inner, { recursive: true });
|
|
186
|
+
it("loads the project layer first, then the global layer", async () => {
|
|
187
|
+
const proj = join(tmp, "proj");
|
|
188
|
+
mkdirSync(proj, { recursive: true });
|
|
192
189
|
writeConfig(
|
|
193
|
-
join(
|
|
194
|
-
configModule("{ disabledRules: ['
|
|
190
|
+
join(proj, ".pi", "steering.ts"),
|
|
191
|
+
configModule("{ disabledRules: ['project'] }"),
|
|
195
192
|
);
|
|
196
193
|
writeConfig(
|
|
197
|
-
join(
|
|
198
|
-
configModule("{ disabledRules: ['
|
|
194
|
+
join(tmp, ".pi", "agent", "steering", "index.ts"),
|
|
195
|
+
configModule("{ disabledRules: ['global'] }"),
|
|
199
196
|
);
|
|
200
|
-
const { layers } = await loadConfigs(
|
|
201
|
-
//
|
|
197
|
+
const { layers } = await loadConfigs(proj);
|
|
198
|
+
// Project (inner) → global (outer) order.
|
|
202
199
|
assert.deepEqual(
|
|
203
200
|
layers.map((l) => l.disabledRules?.[0]),
|
|
204
|
-
["
|
|
201
|
+
["project", "global"],
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not collect intermediate ancestor layers (walk-up removed)", async () => {
|
|
206
|
+
// Pins the breaking change: a config in an ancestor directory
|
|
207
|
+
// between cwd and HOME is no longer discovered — only the cwd
|
|
208
|
+
// project layer and the agent-dir global layer load.
|
|
209
|
+
const cwd = join(tmp, "a", "b");
|
|
210
|
+
mkdirSync(cwd, { recursive: true });
|
|
211
|
+
writeConfig(
|
|
212
|
+
join(tmp, "a", ".pi", "steering.ts"),
|
|
213
|
+
configModule("{ disabledRules: ['ancestor'] }"),
|
|
214
|
+
);
|
|
215
|
+
const { layers, diagnostics } = await loadConfigs(cwd);
|
|
216
|
+
assert.deepEqual(layers, []);
|
|
217
|
+
assert.deepEqual(diagnostics, []);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("loads legacy ~/.pi/steering/ only when cwd is home", async () => {
|
|
221
|
+
// The old global location still works in exactly one situation:
|
|
222
|
+
// launching from $HOME itself, where the project layer
|
|
223
|
+
// `<cwd>/.pi/steering/` IS the legacy `~/.pi/steering/` path.
|
|
224
|
+
const sub = join(tmp, "sub");
|
|
225
|
+
mkdirSync(sub, { recursive: true });
|
|
226
|
+
writeConfig(
|
|
227
|
+
join(tmp, ".pi", "steering.ts"),
|
|
228
|
+
configModule("{ disabledRules: ['legacy'] }"),
|
|
229
|
+
);
|
|
230
|
+
writeConfig(
|
|
231
|
+
join(tmp, ".pi", "agent", "steering", "index.ts"),
|
|
232
|
+
configModule("{ disabledRules: ['global'] }"),
|
|
233
|
+
);
|
|
234
|
+
const fromSub = await loadConfigs(sub);
|
|
235
|
+
assert.deepEqual(
|
|
236
|
+
fromSub.layers.map((l) => l.disabledRules?.[0]),
|
|
237
|
+
["global"],
|
|
238
|
+
"legacy ~/.pi/steering/ must NOT load below home",
|
|
239
|
+
);
|
|
240
|
+
const fromHome = await loadConfigs(tmp);
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
fromHome.layers.map((l) => l.disabledRules?.[0]),
|
|
243
|
+
["legacy", "global"],
|
|
244
|
+
"at cwd === home the project layer is the legacy path",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("honors PI_CODING_AGENT_DIR for the global layer", async () => {
|
|
249
|
+
const prior = process.env["PI_CODING_AGENT_DIR"];
|
|
250
|
+
process.env["PI_CODING_AGENT_DIR"] = join(tmp, "custom-agent");
|
|
251
|
+
try {
|
|
252
|
+
const proj = join(tmp, "proj");
|
|
253
|
+
mkdirSync(proj, { recursive: true });
|
|
254
|
+
writeConfig(
|
|
255
|
+
join(tmp, "custom-agent", "steering", "index.ts"),
|
|
256
|
+
configModule("{ disabledRules: ['custom'] }"),
|
|
257
|
+
);
|
|
258
|
+
const { layers } = await loadConfigs(proj);
|
|
259
|
+
assert.deepEqual(
|
|
260
|
+
layers.map((l) => l.disabledRules?.[0]),
|
|
261
|
+
["custom"],
|
|
262
|
+
);
|
|
263
|
+
} finally {
|
|
264
|
+
if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
|
|
265
|
+
else process.env["PI_CODING_AGENT_DIR"] = prior;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("tilde-expands a ~/… PI_CODING_AGENT_DIR under HOME", async () => {
|
|
270
|
+
const prior = process.env["PI_CODING_AGENT_DIR"];
|
|
271
|
+
process.env["PI_CODING_AGENT_DIR"] = "~/my-agent";
|
|
272
|
+
try {
|
|
273
|
+
const proj = join(tmp, "proj");
|
|
274
|
+
mkdirSync(proj, { recursive: true });
|
|
275
|
+
writeConfig(
|
|
276
|
+
join(tmp, "my-agent", "steering", "index.ts"),
|
|
277
|
+
configModule("{ disabledRules: ['tilde'] }"),
|
|
278
|
+
);
|
|
279
|
+
const { layers } = await loadConfigs(proj);
|
|
280
|
+
assert.deepEqual(
|
|
281
|
+
layers.map((l) => l.disabledRules?.[0]),
|
|
282
|
+
["tilde"],
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"];
|
|
286
|
+
else process.env["PI_CODING_AGENT_DIR"] = prior;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("records a layer-import-failed diagnostic for the GLOBAL layer under the agent dir", async () => {
|
|
291
|
+
// Mirrors the project-layer import-failed pin: the shared
|
|
292
|
+
// loadLayer path must surface the same diagnostic shape when the
|
|
293
|
+
// failing module lives at `<agentDir>/steering.ts` (default agent
|
|
294
|
+
// dir under the isolated HOME).
|
|
295
|
+
const proj = join(tmp, "proj");
|
|
296
|
+
mkdirSync(proj, { recursive: true });
|
|
297
|
+
writeConfig(
|
|
298
|
+
join(tmp, ".pi", "agent", "steering.ts"),
|
|
299
|
+
"export default { rules: {{ not valid ts }} };",
|
|
300
|
+
);
|
|
301
|
+
const { layers, diagnostics } = await loadConfigs(proj);
|
|
302
|
+
assert.deepEqual(layers, []);
|
|
303
|
+
const hit = diagnostics.find((d) => d.kind === "layer-import-failed");
|
|
304
|
+
assert.ok(
|
|
305
|
+
hit,
|
|
306
|
+
`expected a layer-import-failed diagnostic; got: ${JSON.stringify(diagnostics)}`,
|
|
205
307
|
);
|
|
308
|
+
assert.equal(hit.type, "warning");
|
|
309
|
+
assert.equal(hit.path, join(tmp, ".pi", "agent", "steering.ts"));
|
|
310
|
+
assert.match(hit.message, /failed to import/);
|
|
206
311
|
});
|
|
207
312
|
|
|
208
313
|
it("prefers index.ts over steering.ts at the same layer", async () => {
|
|
@@ -403,30 +508,52 @@ describe("loader: loadConfigs", () => {
|
|
|
403
508
|
);
|
|
404
509
|
});
|
|
405
510
|
|
|
406
|
-
it("handles heterogeneous
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// Both layers should be collected
|
|
410
|
-
// loader tripping on the form mismatch.
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
mkdirSync(inner, { recursive: true });
|
|
511
|
+
it("handles heterogeneous forms across the two layers (project flat + global dir)", async () => {
|
|
512
|
+
// Project (session cwd) uses the single-file form
|
|
513
|
+
// .pi/steering.ts; global layer uses the directory form
|
|
514
|
+
// <agentDir>/steering/index.ts. Both layers should be collected
|
|
515
|
+
// project-first without the loader tripping on the form mismatch.
|
|
516
|
+
const proj = join(tmp, "proj");
|
|
517
|
+
mkdirSync(proj, { recursive: true });
|
|
414
518
|
writeConfig(
|
|
415
|
-
join(
|
|
416
|
-
configModule("{ disabledRules: ['
|
|
519
|
+
join(proj, ".pi", "steering.ts"),
|
|
520
|
+
configModule("{ disabledRules: ['project-flat'] }"),
|
|
417
521
|
);
|
|
418
522
|
writeConfig(
|
|
419
|
-
join(
|
|
420
|
-
configModule("{ disabledRules: ['
|
|
523
|
+
join(tmp, ".pi", "agent", "steering", "index.ts"),
|
|
524
|
+
configModule("{ disabledRules: ['global-dir'] }"),
|
|
421
525
|
);
|
|
422
|
-
const { layers } = await loadConfigs(
|
|
526
|
+
const { layers } = await loadConfigs(proj);
|
|
423
527
|
assert.deepEqual(
|
|
424
528
|
layers.map((l) => l.disabledRules?.[0]),
|
|
425
|
-
["
|
|
426
|
-
"expected
|
|
529
|
+
["project-flat", "global-dir"],
|
|
530
|
+
"expected project-first ordering regardless of per-layer form",
|
|
427
531
|
);
|
|
428
532
|
});
|
|
429
533
|
|
|
534
|
+
it("emits global-layer stray-file diagnostics with paths under the agent dir", async () => {
|
|
535
|
+
const cwd = join(tmp, "proj");
|
|
536
|
+
mkdirSync(cwd, { recursive: true });
|
|
537
|
+
// Stray non-.ts file in the GLOBAL layer's steering/ dir, with no
|
|
538
|
+
// index.ts — the stray-file scan must run for both layers, not
|
|
539
|
+
// just the project one.
|
|
540
|
+
mkdirSync(join(tmp, ".pi", "agent", "steering"), { recursive: true });
|
|
541
|
+
writeFileSync(
|
|
542
|
+
join(tmp, ".pi", "agent", "steering", "rules.mjs"),
|
|
543
|
+
"// not ts",
|
|
544
|
+
"utf8",
|
|
545
|
+
);
|
|
546
|
+
const { diagnostics } = await loadConfigs(cwd);
|
|
547
|
+
const stray = diagnostics.filter((d) => d.kind === "layer-stray-file");
|
|
548
|
+
assert.equal(stray.length, 1);
|
|
549
|
+
assert.equal(
|
|
550
|
+
stray[0]?.path,
|
|
551
|
+
join(tmp, ".pi", "agent", "steering", "rules.mjs"),
|
|
552
|
+
);
|
|
553
|
+
assert.equal(stray[0]?.type, "warning");
|
|
554
|
+
assert.match(stray[0]?.message ?? "", /under steering\//);
|
|
555
|
+
});
|
|
556
|
+
|
|
430
557
|
it("re-imports config when file content changes between calls", async () => {
|
|
431
558
|
// Regression: Node's ESM module map is keyed on URL and caches
|
|
432
559
|
// indefinitely within a process. Without cache-busting, an edit
|
|
@@ -954,6 +1081,31 @@ describe("loader: loadSteeringConfig", () => {
|
|
|
954
1081
|
assert.deepEqual(diagnostics, []);
|
|
955
1082
|
});
|
|
956
1083
|
|
|
1084
|
+
it("project rule overrides global rule by name", async () => {
|
|
1085
|
+
// End-to-end: the project layer's rule shadows the global layer's
|
|
1086
|
+
// same-named rule (project wins on name-keyed merge), with no
|
|
1087
|
+
// collision diagnostic — cross-layer override is the documented
|
|
1088
|
+
// customization path.
|
|
1089
|
+
const cwd = join(tmp, "proj");
|
|
1090
|
+
mkdirSync(cwd, { recursive: true });
|
|
1091
|
+
writeConfig(
|
|
1092
|
+
join(cwd, ".pi", "steering.ts"),
|
|
1093
|
+
configModule(
|
|
1094
|
+
`{ rules: [{ name: "dup", tool: "bash", field: "command", pattern: /^PROJECT/, reason: "project" }] }`,
|
|
1095
|
+
),
|
|
1096
|
+
);
|
|
1097
|
+
writeConfig(
|
|
1098
|
+
join(tmp, ".pi", "agent", "steering", "index.ts"),
|
|
1099
|
+
configModule(
|
|
1100
|
+
`{ rules: [{ name: "dup", tool: "bash", field: "command", pattern: /^GLOBAL/, reason: "global" }] }`,
|
|
1101
|
+
),
|
|
1102
|
+
);
|
|
1103
|
+
const { config: merged, diagnostics } = await loadSteeringConfig(cwd);
|
|
1104
|
+
assert.equal(merged.rules?.length, 1);
|
|
1105
|
+
assert.equal(merged.rules?.[0]?.reason, "project");
|
|
1106
|
+
assert.deepEqual(diagnostics, []);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
957
1109
|
it("surfaces both loader-side and merge-side diagnostics in a single array", async () => {
|
|
958
1110
|
// Stage a dual-form coexistence (loader-side warning) plus a
|
|
959
1111
|
// within-layer rule-name collision (merge-side warning) so we
|
package/src/loader.ts
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
// Part of pi-steering.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* TS config loader.
|
|
6
|
-
* or `.pi/steering.ts`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* TS config loader. Two fixed layers: the project layer at
|
|
6
|
+
* `<cwd>/.pi/steering/` (or `.pi/steering.ts`) and the global layer
|
|
7
|
+
* at `<agentDir>/steering/`, dynamic-import each, merge inner-first
|
|
8
|
+
* (project layer wins on name-keyed collisions). Per-symbol JSDoc
|
|
9
|
+
* carries the contract; see also the {@link SteeringDiagnostic} /
|
|
10
|
+
* {@link SteeringDiagnosticKind} JSDoc for the diagnostic stream.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
13
|
-
import {
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
14
16
|
import { pathToFileURL } from "node:url";
|
|
15
17
|
import { EVALUATOR_BUILTIN_TRACKERS } from "./evaluator.ts";
|
|
16
18
|
import { runMergerPipeline } from "./internal/session-runtime.ts";
|
|
@@ -53,25 +55,25 @@ function assertNodeVersion(): void {
|
|
|
53
55
|
// ---------------------------------------------------------------------------
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
|
-
* Candidate file paths for a given directory's
|
|
57
|
-
* in priority order. First existing file wins.
|
|
58
|
+
* Candidate file paths for a given directory's `slot` (default
|
|
59
|
+
* `.pi/steering`), in priority order. First existing file wins.
|
|
58
60
|
*
|
|
59
61
|
* Exported for tests — not part of the library's public API.
|
|
60
62
|
*/
|
|
61
|
-
export function configCandidates(dir: string): string[] {
|
|
62
|
-
return [
|
|
63
|
-
join(dir, ".pi", "steering", "index.ts"),
|
|
64
|
-
join(dir, ".pi", "steering.ts"),
|
|
65
|
-
];
|
|
63
|
+
export function configCandidates(dir: string, slot = ".pi/steering"): string[] {
|
|
64
|
+
return [join(dir, slot, "index.ts"), join(dir, `${slot}.ts`)];
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
/**
|
|
69
|
-
* Return the non-`.ts` files that exist under `<dir
|
|
68
|
+
* Return the non-`.ts` files that exist under `<dir>/<slot>` so
|
|
70
69
|
* callers can warn about them. Uses a best-effort fs read: a missing
|
|
71
70
|
* directory returns an empty list.
|
|
72
71
|
*/
|
|
73
|
-
function unexpectedFilesUnderSteering(
|
|
74
|
-
|
|
72
|
+
function unexpectedFilesUnderSteering(
|
|
73
|
+
dir: string,
|
|
74
|
+
slot = ".pi/steering",
|
|
75
|
+
): string[] {
|
|
76
|
+
const steeringDir = join(dir, slot);
|
|
75
77
|
if (!existsSync(steeringDir)) return [];
|
|
76
78
|
try {
|
|
77
79
|
const entries = readdirSync(steeringDir);
|
|
@@ -95,42 +97,40 @@ function unexpectedFilesUnderSteering(dir: string): string[] {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
100
|
+
* Resolve the pi agent directory: `$PI_CODING_AGENT_DIR` when set
|
|
101
|
+
* (tilde-expanded: `"~"` → home, `"~/x"` → `<home>/x`, anything else
|
|
102
|
+
* as-is), else `<home>/.pi/agent`. Mirrors pi's `getAgentDir()` in
|
|
103
|
+
* @earendil-works/pi-coding-agent.
|
|
101
104
|
*
|
|
102
105
|
* Exported for tests.
|
|
103
106
|
*/
|
|
104
|
-
export function
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
seen.add(current);
|
|
112
|
-
out.push(current);
|
|
113
|
-
if (current === home || current === "/") break;
|
|
114
|
-
const parent = dirname(current);
|
|
115
|
-
if (parent === current) break; // filesystem root
|
|
116
|
-
current = parent;
|
|
107
|
+
export function resolveAgentDir(): string {
|
|
108
|
+
const envDir = process.env["PI_CODING_AGENT_DIR"];
|
|
109
|
+
if (envDir !== undefined && envDir !== "") {
|
|
110
|
+
// mirror pi's expandTildePath: "~" -> homedir, "~/x" -> homedir/x, else as-is
|
|
111
|
+
if (envDir === "~") return homedir();
|
|
112
|
+
if (envDir.startsWith("~/")) return join(homedir(), envDir.slice(2));
|
|
113
|
+
return envDir;
|
|
117
114
|
}
|
|
118
|
-
return
|
|
115
|
+
return join(homedir(), ".pi", "agent");
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
/**
|
|
122
119
|
* Find the config file (if any) for a single layer. Returns the
|
|
123
120
|
* resolved file path and a `layer-form-coexistence` diagnostic when
|
|
124
|
-
* both
|
|
125
|
-
*
|
|
121
|
+
* both `<slot>/index.ts` and `<slot>.ts` coexist in the same
|
|
122
|
+
* directory (the directory form wins).
|
|
126
123
|
*
|
|
127
124
|
* Exported for tests.
|
|
128
125
|
*/
|
|
129
|
-
export function findConfigFile(
|
|
126
|
+
export function findConfigFile(
|
|
127
|
+
dir: string,
|
|
128
|
+
slot = ".pi/steering",
|
|
129
|
+
): {
|
|
130
130
|
file: string | null;
|
|
131
131
|
diagnostic: SteeringDiagnostic | null;
|
|
132
132
|
} {
|
|
133
|
-
const [indexForm, flatForm] = configCandidates(dir);
|
|
133
|
+
const [indexForm, flatForm] = configCandidates(dir, slot);
|
|
134
134
|
const indexExists = indexForm !== undefined && existsSync(indexForm);
|
|
135
135
|
const flatExists = flatForm !== undefined && existsSync(flatForm);
|
|
136
136
|
let diagnostic: SteeringDiagnostic | null = null;
|
|
@@ -144,8 +144,8 @@ export function findConfigFile(dir: string): {
|
|
|
144
144
|
kind: "layer-form-coexistence",
|
|
145
145
|
path: dir,
|
|
146
146
|
message:
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
`both ${slot}.ts and ${slot}/index.ts exist; using ` +
|
|
148
|
+
`directory form. Delete ${slot}.ts to remove this warning.`,
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
if (indexExists) return { file: indexForm ?? null, diagnostic };
|
|
@@ -223,12 +223,15 @@ async function importConfigFile(path: string): Promise<SteeringConfig> {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
|
-
*
|
|
227
|
-
* (
|
|
228
|
-
*
|
|
226
|
+
* Load the two fixed config layers for `cwd`: the project layer at
|
|
227
|
+
* `<cwd>/.pi/steering/` (or `.pi/steering.ts`) and the global layer
|
|
228
|
+
* at `<agentDir>/steering/` (see {@link resolveAgentDir}). Returns
|
|
229
|
+
* the layers INNER-FIRST (project first, then global; caller passes
|
|
230
|
+
* to {@link buildConfig}, which expects inner-first so early entries
|
|
231
|
+
* take precedence on collisions).
|
|
229
232
|
*
|
|
230
233
|
* Issues encountered along the way (per-layer import failure, dual
|
|
231
|
-
* form coexistence, stray non-`.ts` file under
|
|
234
|
+
* form coexistence, stray non-`.ts` file under the layer directory)
|
|
232
235
|
* surface as structured {@link SteeringDiagnostic} entries on the
|
|
233
236
|
* returned object. The loader does not log to `console.warn` directly
|
|
234
237
|
* — the bridge runtime owns the policy decision (throw vs. log) once
|
|
@@ -242,46 +245,59 @@ export async function loadConfigs(cwd: string): Promise<{
|
|
|
242
245
|
}> {
|
|
243
246
|
assertNodeVersion();
|
|
244
247
|
|
|
245
|
-
const dirs = ancestorChain(cwd);
|
|
246
248
|
const layers: SteeringConfig[] = [];
|
|
247
249
|
const diagnostics: SteeringDiagnostic[] = [];
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
250
|
+
await loadLayer(cwd, ".pi/steering", layers, diagnostics);
|
|
251
|
+
await loadLayer(resolveAgentDir(), "steering", layers, diagnostics);
|
|
252
|
+
return { layers, diagnostics };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Load a single layer: `slot` under `dir` (candidate file discovery,
|
|
257
|
+
* coexistence diagnostic, stray-file scan, dynamic import). Shared by
|
|
258
|
+
* the project layer (`cwd` + `.pi/steering`) and the global layer
|
|
259
|
+
* (`agentDir` + `steering`).
|
|
260
|
+
*/
|
|
261
|
+
async function loadLayer(
|
|
262
|
+
dir: string,
|
|
263
|
+
slot: string,
|
|
264
|
+
layers: SteeringConfig[],
|
|
265
|
+
diagnostics: SteeringDiagnostic[],
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
const { file, diagnostic } = findConfigFile(dir, slot);
|
|
268
|
+
if (diagnostic !== null) diagnostics.push(diagnostic);
|
|
269
|
+
if (file === null) {
|
|
270
|
+
// Surface stray files under `<slot>/` that the loader won't pick
|
|
271
|
+
// up. Only check when the directory exists but has no `index.ts`
|
|
272
|
+
// — otherwise a project without any steering directory would
|
|
273
|
+
// emit noise.
|
|
274
|
+
const slotDir = join(dir, slot);
|
|
275
|
+
if (existsSync(slotDir)) {
|
|
276
|
+
for (const stray of unexpectedFilesUnderSteering(dir, slot)) {
|
|
277
|
+
diagnostics.push({
|
|
278
|
+
type: "warning",
|
|
279
|
+
kind: "layer-stray-file",
|
|
280
|
+
path: stray,
|
|
281
|
+
message: `ignoring non-.ts file under ${slot}/`,
|
|
282
|
+
});
|
|
266
283
|
}
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
try {
|
|
270
|
-
layers.push(await importConfigFile(file));
|
|
271
|
-
} catch (err) {
|
|
272
|
-
// Use err.message to drop the `Error: ` class prefix; native
|
|
273
|
-
// runtime errors (jiti syntax errors) may embed their path inside
|
|
274
|
-
// the message and we accept that duplication.
|
|
275
|
-
const body = err instanceof Error ? err.message : String(err);
|
|
276
|
-
diagnostics.push({
|
|
277
|
-
type: "warning",
|
|
278
|
-
kind: "layer-import-failed",
|
|
279
|
-
path: file,
|
|
280
|
-
message: `failed to import: ${body}`,
|
|
281
|
-
});
|
|
282
284
|
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
layers.push(await importConfigFile(file));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// Use err.message to drop the `Error: ` class prefix; native
|
|
291
|
+
// runtime errors (jiti syntax errors) may embed their path inside
|
|
292
|
+
// the message and we accept that duplication.
|
|
293
|
+
const body = err instanceof Error ? err.message : String(err);
|
|
294
|
+
diagnostics.push({
|
|
295
|
+
type: "warning",
|
|
296
|
+
kind: "layer-import-failed",
|
|
297
|
+
path: file,
|
|
298
|
+
message: `failed to import: ${body}`,
|
|
299
|
+
});
|
|
283
300
|
}
|
|
284
|
-
return { layers, diagnostics };
|
|
285
301
|
}
|
|
286
302
|
|
|
287
303
|
// ---------------------------------------------------------------------------
|
|
@@ -428,10 +444,11 @@ function mergeStringUnion(
|
|
|
428
444
|
}
|
|
429
445
|
|
|
430
446
|
/**
|
|
431
|
-
* Inner-wins boolean merge over
|
|
432
|
-
* (inner-first); returns the first explicit boolean or
|
|
433
|
-
* Used by `buildConfig` and the session runtime for the
|
|
434
|
-
* boolean fields. Internal — not in the package's
|
|
447
|
+
* Inner-wins boolean merge over the inner-first layers. Walks
|
|
448
|
+
* left-to-right (inner-first); returns the first explicit boolean or
|
|
449
|
+
* `undefined`. Used by `buildConfig` and the session runtime for the
|
|
450
|
+
* inner-wins boolean fields. Internal — not in the package's
|
|
451
|
+
* `exports` surface.
|
|
435
452
|
*/
|
|
436
453
|
export function mergeBool(
|
|
437
454
|
layers: readonly SteeringConfig[],
|