@cad0p/pi-steering 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/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- package/src/testing/index.ts +1472 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the `@cad0p/pi-steering` CLI (`./pi-steering.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Runs the CLI as a subprocess via `node --experimental-strip-types
|
|
8
|
+
* src/bin/pi-steering.ts …`. This mirrors real invocation (the built
|
|
9
|
+
* shebang script runs under a fresh node) and sidesteps the
|
|
10
|
+
* `node:test` worker's stdout sharing — patching
|
|
11
|
+
* `process.stdout.write` in-process swallows the worker's TAP frames.
|
|
12
|
+
*
|
|
13
|
+
* File IO fixtures live under `mkdtempSync`; each test cleans up its
|
|
14
|
+
* directory in `afterEach`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import {
|
|
20
|
+
mkdirSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { writeSteeringDirConfig } from "../__test-helpers__.ts";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// subprocess runner
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const CLI_PATH = resolve(
|
|
37
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
38
|
+
"pi-steering.ts",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
interface RunResult {
|
|
42
|
+
code: number;
|
|
43
|
+
stdout: string;
|
|
44
|
+
stderr: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the CLI as a child process under `node --experimental-strip-types`.
|
|
49
|
+
* Returns the exit code and captured stdout/stderr. Never throws for
|
|
50
|
+
* non-zero exit codes — the caller asserts on `code`.
|
|
51
|
+
*
|
|
52
|
+
* Accepts an optional `cwd` so tests for the `list` subcommand can
|
|
53
|
+
* point the walk-up loader at a scratch directory without polluting
|
|
54
|
+
* the project.
|
|
55
|
+
*/
|
|
56
|
+
function runCli(...args: string[]): Promise<RunResult>;
|
|
57
|
+
function runCli(opts: { cwd?: string }, ...args: string[]): Promise<RunResult>;
|
|
58
|
+
function runCli(
|
|
59
|
+
first?: string | { cwd?: string },
|
|
60
|
+
...rest: string[]
|
|
61
|
+
): Promise<RunResult> {
|
|
62
|
+
let cwd: string | undefined;
|
|
63
|
+
let args: string[];
|
|
64
|
+
if (typeof first === "object" && first !== null) {
|
|
65
|
+
cwd = first.cwd;
|
|
66
|
+
args = rest;
|
|
67
|
+
} else {
|
|
68
|
+
args = first === undefined ? [...rest] : [first, ...rest];
|
|
69
|
+
}
|
|
70
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
71
|
+
const child = spawn(
|
|
72
|
+
process.execPath,
|
|
73
|
+
["--experimental-strip-types", CLI_PATH, ...args],
|
|
74
|
+
{
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
child.stdout.setEncoding("utf8");
|
|
82
|
+
child.stderr.setEncoding("utf8");
|
|
83
|
+
child.stdout.on("data", (chunk: string) => {
|
|
84
|
+
stdout += chunk;
|
|
85
|
+
});
|
|
86
|
+
child.stderr.on("data", (chunk: string) => {
|
|
87
|
+
stderr += chunk;
|
|
88
|
+
});
|
|
89
|
+
child.on("error", rejectPromise);
|
|
90
|
+
child.on("close", (code) => {
|
|
91
|
+
// Filter the Node experimental-strip-types warning so tests
|
|
92
|
+
// assert against clean stderr. The warning is
|
|
93
|
+
// version-dependent; drop any line mentioning it.
|
|
94
|
+
const cleanedStderr = stderr
|
|
95
|
+
.split("\n")
|
|
96
|
+
.filter((line) => !/ExperimentalWarning/.test(line))
|
|
97
|
+
.filter((line) => !/Use `node --trace-warnings/.test(line))
|
|
98
|
+
.join("\n");
|
|
99
|
+
resolvePromise({
|
|
100
|
+
code: code ?? -1,
|
|
101
|
+
stdout,
|
|
102
|
+
stderr: cleanedStderr,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// tmpdir fixture
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
let scratch: string;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
scratch = mkdtempSync(join(tmpdir(), "pi-steering-cli-"));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// top-level
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("pi-steering CLI: help + dispatch", () => {
|
|
127
|
+
it("--help prints usage and exits 0", async () => {
|
|
128
|
+
const r = await runCli("--help");
|
|
129
|
+
assert.equal(r.code, 0);
|
|
130
|
+
assert.match(r.stdout, /pi-steering — tools for/);
|
|
131
|
+
assert.match(r.stdout, /import-json/);
|
|
132
|
+
assert.match(r.stdout, /list \[--format=/);
|
|
133
|
+
assert.equal(r.stderr.trim(), "");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("-h prints usage and exits 0", async () => {
|
|
137
|
+
const r = await runCli("-h");
|
|
138
|
+
assert.equal(r.code, 0);
|
|
139
|
+
assert.match(r.stdout, /USAGE/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("no args prints usage and exits 0", async () => {
|
|
143
|
+
const r = await runCli();
|
|
144
|
+
assert.equal(r.code, 0);
|
|
145
|
+
assert.match(r.stdout, /USAGE/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("unknown subcommand writes to stderr and exits 1", async () => {
|
|
149
|
+
const r = await runCli("bogus-sub");
|
|
150
|
+
assert.equal(r.code, 1);
|
|
151
|
+
assert.match(r.stderr, /unknown subcommand "bogus-sub"/);
|
|
152
|
+
// Help still gets written to stdout for context.
|
|
153
|
+
assert.match(r.stdout, /USAGE/);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// import-json: argument parsing
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe("pi-steering import-json: argument parsing", () => {
|
|
162
|
+
it("requires an input file", async () => {
|
|
163
|
+
const r = await runCli("import-json");
|
|
164
|
+
assert.equal(r.code, 1);
|
|
165
|
+
assert.match(r.stderr, /requires an input file/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects unknown flags", async () => {
|
|
169
|
+
const r = await runCli("import-json", "--nope");
|
|
170
|
+
assert.equal(r.code, 1);
|
|
171
|
+
assert.match(r.stderr, /unknown flag "--nope"/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects -o without an argument", async () => {
|
|
175
|
+
const r = await runCli("import-json", "input.json", "-o");
|
|
176
|
+
assert.equal(r.code, 1);
|
|
177
|
+
assert.match(r.stderr, /-o requires an argument/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("rejects more than one positional argument", async () => {
|
|
181
|
+
const r = await runCli("import-json", "a.json", "b.json");
|
|
182
|
+
assert.equal(r.code, 1);
|
|
183
|
+
assert.match(r.stderr, /too many positional arguments/);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// import-json: IO error paths
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
describe("pi-steering import-json: IO errors", () => {
|
|
192
|
+
it("missing file -> stderr + exit 1", async () => {
|
|
193
|
+
const r = await runCli("import-json", join(scratch, "nope.json"));
|
|
194
|
+
assert.equal(r.code, 1);
|
|
195
|
+
assert.match(r.stderr, /cannot read /);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("invalid JSON -> stderr + exit 1", async () => {
|
|
199
|
+
const path = join(scratch, "bad.json");
|
|
200
|
+
writeFileSync(path, "{ not json", "utf8");
|
|
201
|
+
const r = await runCli("import-json", path);
|
|
202
|
+
assert.equal(r.code, 1);
|
|
203
|
+
assert.match(r.stderr, /not valid JSON/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// import-json: happy path
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
const VALID_V1 = {
|
|
212
|
+
disable: ["no-force-push"],
|
|
213
|
+
rules: [
|
|
214
|
+
{
|
|
215
|
+
name: "no-amend",
|
|
216
|
+
tool: "bash",
|
|
217
|
+
field: "command",
|
|
218
|
+
pattern: "^git\\s+commit\\b.*--amend",
|
|
219
|
+
reason: "Don't rewrite history.",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
describe("pi-steering import-json: conversion", () => {
|
|
225
|
+
it("stdout mode: writes defineConfig output and exits 0", async () => {
|
|
226
|
+
const path = join(scratch, "steering.json");
|
|
227
|
+
writeFileSync(path, JSON.stringify(VALID_V1), "utf8");
|
|
228
|
+
|
|
229
|
+
const r = await runCli("import-json", path);
|
|
230
|
+
assert.equal(r.code, 0);
|
|
231
|
+
assert.equal(r.stderr.trim(), "");
|
|
232
|
+
assert.match(r.stdout, /import \{ defineConfig \} from "@cad0p\/pi-steering"/);
|
|
233
|
+
assert.match(r.stdout, /export default defineConfig\(/);
|
|
234
|
+
assert.match(r.stdout, /"no-amend"/);
|
|
235
|
+
assert.match(r.stdout, /"Don't rewrite history\."/);
|
|
236
|
+
// Preserve the disabled-rules list. Note the rename: v1 JSON's
|
|
237
|
+
// `disable` key becomes v2 TS `disabledRules` on output.
|
|
238
|
+
assert.match(r.stdout, /"disabledRules":\s*\[\s*"no-force-push"\s*\]/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("-o mode: writes file + reports path, exits 0", async () => {
|
|
242
|
+
const inputPath = join(scratch, "steering.json");
|
|
243
|
+
const outputPath = join(scratch, "steering.ts");
|
|
244
|
+
writeFileSync(inputPath, JSON.stringify(VALID_V1), "utf8");
|
|
245
|
+
|
|
246
|
+
const r = await runCli("import-json", inputPath, "-o", outputPath);
|
|
247
|
+
assert.equal(r.code, 0);
|
|
248
|
+
assert.match(r.stdout, new RegExp(`Wrote ${outputPath}`));
|
|
249
|
+
assert.equal(r.stderr.trim(), "");
|
|
250
|
+
|
|
251
|
+
const written = readFileSync(outputPath, "utf8");
|
|
252
|
+
assert.match(written, /import \{ defineConfig \}/);
|
|
253
|
+
assert.match(written, /"no-amend"/);
|
|
254
|
+
// The generated file should be valid enough that a user can
|
|
255
|
+
// drop it in and `tsc --noEmit` it. Sanity check: trailing
|
|
256
|
+
// newline, no BOM.
|
|
257
|
+
assert.ok(written.endsWith("\n"));
|
|
258
|
+
assert.ok(!written.startsWith("\uFEFF"));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("--output long form works the same as -o", async () => {
|
|
262
|
+
const inputPath = join(scratch, "steering.json");
|
|
263
|
+
const outputPath = join(scratch, "out.ts");
|
|
264
|
+
writeFileSync(inputPath, JSON.stringify(VALID_V1), "utf8");
|
|
265
|
+
|
|
266
|
+
const r = await runCli("import-json", inputPath, "--output", outputPath);
|
|
267
|
+
assert.equal(r.code, 0);
|
|
268
|
+
const written = readFileSync(outputPath, "utf8");
|
|
269
|
+
assert.match(written, /defineConfig/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("FromJSONError propagates as exit 2 with path info", async () => {
|
|
273
|
+
// `plugins` is a v2-only construct; `fromJSON` rejects it.
|
|
274
|
+
const inputPath = join(scratch, "steering.json");
|
|
275
|
+
writeFileSync(
|
|
276
|
+
inputPath,
|
|
277
|
+
JSON.stringify({ plugins: [{ name: "git" }] }),
|
|
278
|
+
"utf8",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const r = await runCli("import-json", inputPath);
|
|
282
|
+
assert.equal(r.code, 2);
|
|
283
|
+
assert.match(r.stderr, /conversion failed at <root>\.plugins/);
|
|
284
|
+
assert.equal(r.stdout, "");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// `list` subcommand
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
describe("pi-steering list", () => {
|
|
293
|
+
it("prints 'no config' when no .pi/steering exists", async () => {
|
|
294
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
295
|
+
assert.equal(r.code, 0);
|
|
296
|
+
assert.match(r.stdout, /No steering config found\./);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("--format=json with no config returns empty structure", async () => {
|
|
300
|
+
const r = await runCli({ cwd: scratch }, "list", "--format=json");
|
|
301
|
+
assert.equal(r.code, 0);
|
|
302
|
+
const parsed = JSON.parse(r.stdout) as {
|
|
303
|
+
plugins: unknown[];
|
|
304
|
+
userRules: unknown[];
|
|
305
|
+
disabled: { rules: unknown[] };
|
|
306
|
+
};
|
|
307
|
+
assert.deepEqual(parsed.plugins, []);
|
|
308
|
+
assert.deepEqual(parsed.userRules, []);
|
|
309
|
+
assert.deepEqual(parsed.disabled.rules, []);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("text format groups plugin + user rules and lists disables", async () => {
|
|
313
|
+
writeSteeringDirConfig(
|
|
314
|
+
scratch,
|
|
315
|
+
`export default {
|
|
316
|
+
plugins: [
|
|
317
|
+
{
|
|
318
|
+
name: "git",
|
|
319
|
+
rules: [
|
|
320
|
+
{
|
|
321
|
+
name: "no-main-commit",
|
|
322
|
+
tool: "bash",
|
|
323
|
+
field: "command",
|
|
324
|
+
pattern: /^git\\s+commit/,
|
|
325
|
+
when: { branch: /^main$/ },
|
|
326
|
+
reason: "no",
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
rules: [
|
|
332
|
+
{
|
|
333
|
+
name: "my-rule",
|
|
334
|
+
tool: "bash",
|
|
335
|
+
field: "command",
|
|
336
|
+
pattern: /^echo/,
|
|
337
|
+
reason: "no",
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
disabledRules: ["some-disabled-rule"],
|
|
341
|
+
};`,
|
|
342
|
+
);
|
|
343
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
344
|
+
assert.equal(r.code, 0);
|
|
345
|
+
assert.match(r.stdout, /Resolved config: 1 plugin, 2 rules, 0 observers\./);
|
|
346
|
+
assert.match(r.stdout, /git\s+\[pi-steering\/plugins\/git\]/);
|
|
347
|
+
assert.match(r.stdout, /no-main-commit\s+bash\s+when: branch/);
|
|
348
|
+
assert.match(r.stdout, /User \(\.pi\/steering\/index\.ts\):/);
|
|
349
|
+
assert.match(r.stdout, /my-rule\s+bash/);
|
|
350
|
+
assert.match(r.stdout, /Disabled rules: some-disabled-rule/);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("--format=json emits a parseable structure with all sections", async () => {
|
|
354
|
+
writeSteeringDirConfig(
|
|
355
|
+
scratch,
|
|
356
|
+
`export default {
|
|
357
|
+
plugins: [{ name: "git", rules: [] }],
|
|
358
|
+
rules: [
|
|
359
|
+
{
|
|
360
|
+
name: "u1",
|
|
361
|
+
tool: "bash",
|
|
362
|
+
field: "command",
|
|
363
|
+
pattern: /^ls/,
|
|
364
|
+
reason: "no",
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
observers: [
|
|
368
|
+
{
|
|
369
|
+
name: "obs1",
|
|
370
|
+
writes: ["thing-happened"],
|
|
371
|
+
onResult: () => {},
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
};`,
|
|
375
|
+
);
|
|
376
|
+
const r = await runCli({ cwd: scratch }, "list", "--format=json");
|
|
377
|
+
assert.equal(r.code, 0);
|
|
378
|
+
const parsed = JSON.parse(r.stdout) as {
|
|
379
|
+
plugins: Array<{ name: string; source?: string; rules: unknown[] }>;
|
|
380
|
+
userRules: Array<{ name: string; tool: string }>;
|
|
381
|
+
userObservers: Array<{ name: string; writes: string[] }>;
|
|
382
|
+
disabled: { rules: unknown[]; plugins: unknown[] };
|
|
383
|
+
};
|
|
384
|
+
assert.equal(parsed.plugins[0]?.name, "git");
|
|
385
|
+
assert.equal(parsed.plugins[0]?.source, "pi-steering/plugins/git");
|
|
386
|
+
assert.equal(parsed.userRules[0]?.name, "u1");
|
|
387
|
+
assert.equal(parsed.userObservers[0]?.name, "obs1");
|
|
388
|
+
assert.deepEqual(parsed.userObservers[0]?.writes, ["thing-happened"]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("rejects an unknown --format value", async () => {
|
|
392
|
+
const r = await runCli({ cwd: scratch }, "list", "--format=yaml");
|
|
393
|
+
assert.equal(r.code, 1);
|
|
394
|
+
assert.match(r.stderr, /unknown --format value "yaml"/);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("rejects unknown flags", async () => {
|
|
398
|
+
const r = await runCli({ cwd: scratch }, "list", "--nope");
|
|
399
|
+
assert.equal(r.code, 1);
|
|
400
|
+
assert.match(r.stderr, /unknown flag "--nope"/);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("list --help prints per-subcommand help", async () => {
|
|
404
|
+
const r = await runCli({ cwd: scratch }, "list", "--help");
|
|
405
|
+
assert.equal(r.code, 0);
|
|
406
|
+
assert.match(r.stdout, /pi-steering list — show the resolved config/);
|
|
407
|
+
assert.match(r.stdout, /--format=text\|json/);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("summarizes happened: predicate with its event", async () => {
|
|
411
|
+
writeSteeringDirConfig(
|
|
412
|
+
scratch,
|
|
413
|
+
`export default {
|
|
414
|
+
rules: [
|
|
415
|
+
{
|
|
416
|
+
name: "rq",
|
|
417
|
+
tool: "bash",
|
|
418
|
+
field: "command",
|
|
419
|
+
pattern: /^git push/,
|
|
420
|
+
when: { happened: { event: "tests-passed", in: "agent_loop" } },
|
|
421
|
+
reason: "no",
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
};`,
|
|
425
|
+
);
|
|
426
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
427
|
+
assert.equal(r.code, 0);
|
|
428
|
+
assert.match(r.stdout, /when: happened:tests-passed/);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("marks disabled rules with '(disabled)' suffix in text output (F4)", async () => {
|
|
432
|
+
writeSteeringDirConfig(
|
|
433
|
+
scratch,
|
|
434
|
+
`export default {
|
|
435
|
+
plugins: [
|
|
436
|
+
{
|
|
437
|
+
name: "git",
|
|
438
|
+
rules: [
|
|
439
|
+
{ name: "active-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
|
|
440
|
+
{ name: "disabled-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
disabledRules: ["disabled-rule"],
|
|
445
|
+
};`,
|
|
446
|
+
);
|
|
447
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
448
|
+
assert.equal(r.code, 0);
|
|
449
|
+
// Active rule: no suffix.
|
|
450
|
+
assert.match(r.stdout, /active-rule\s+bash\s*$/m);
|
|
451
|
+
// Disabled rule: (disabled) suffix.
|
|
452
|
+
assert.match(r.stdout, /disabled-rule\s+bash\s+\(disabled\)/);
|
|
453
|
+
// Footer unchanged.
|
|
454
|
+
assert.match(r.stdout, /Disabled rules: disabled-rule/);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("marks disabled plugins with '(disabled)' suffix on the header (F4)", async () => {
|
|
458
|
+
writeSteeringDirConfig(
|
|
459
|
+
scratch,
|
|
460
|
+
`export default {
|
|
461
|
+
plugins: [
|
|
462
|
+
{
|
|
463
|
+
name: "git",
|
|
464
|
+
rules: [
|
|
465
|
+
{ name: "some-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
],
|
|
469
|
+
disabledPlugins: ["git"],
|
|
470
|
+
};`,
|
|
471
|
+
);
|
|
472
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
473
|
+
assert.equal(r.code, 0);
|
|
474
|
+
// Plugin header carries the (disabled) suffix.
|
|
475
|
+
assert.match(
|
|
476
|
+
r.stdout,
|
|
477
|
+
/git\s+\[pi-steering\/plugins\/git\]\s+\(disabled\)/,
|
|
478
|
+
);
|
|
479
|
+
assert.match(r.stdout, /Disabled plugins: git/);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("JSON output tags disabled rules and plugins with 'disabled: true' (F4)", async () => {
|
|
483
|
+
writeSteeringDirConfig(
|
|
484
|
+
scratch,
|
|
485
|
+
`export default {
|
|
486
|
+
plugins: [
|
|
487
|
+
{
|
|
488
|
+
name: "git",
|
|
489
|
+
rules: [
|
|
490
|
+
{ name: "active-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
|
|
491
|
+
{ name: "disabled-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
{ name: "also-disabled" },
|
|
495
|
+
],
|
|
496
|
+
disabledRules: ["disabled-rule"],
|
|
497
|
+
disabledPlugins: ["also-disabled"],
|
|
498
|
+
};`,
|
|
499
|
+
);
|
|
500
|
+
const r = await runCli({ cwd: scratch }, "list", "--format=json");
|
|
501
|
+
assert.equal(r.code, 0);
|
|
502
|
+
const parsed = JSON.parse(r.stdout) as {
|
|
503
|
+
plugins: Array<{
|
|
504
|
+
name: string;
|
|
505
|
+
disabled?: boolean;
|
|
506
|
+
rules: Array<{ name: string; disabled?: boolean }>;
|
|
507
|
+
}>;
|
|
508
|
+
};
|
|
509
|
+
const git = parsed.plugins.find((p) => p.name === "git");
|
|
510
|
+
const also = parsed.plugins.find((p) => p.name === "also-disabled");
|
|
511
|
+
assert.ok(git);
|
|
512
|
+
assert.ok(also);
|
|
513
|
+
assert.equal(
|
|
514
|
+
git.disabled,
|
|
515
|
+
undefined,
|
|
516
|
+
"git plugin is active; no disabled flag",
|
|
517
|
+
);
|
|
518
|
+
assert.equal(
|
|
519
|
+
also.disabled,
|
|
520
|
+
true,
|
|
521
|
+
"also-disabled plugin carries disabled: true",
|
|
522
|
+
);
|
|
523
|
+
const active = git.rules.find((r) => r.name === "active-rule");
|
|
524
|
+
const disabled = git.rules.find((r) => r.name === "disabled-rule");
|
|
525
|
+
assert.ok(active);
|
|
526
|
+
assert.ok(disabled);
|
|
527
|
+
assert.equal(active.disabled, undefined);
|
|
528
|
+
assert.equal(disabled.disabled, true);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// list: diagnostic surfacing on stderr
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
describe("pi-steering list: diagnostics on stderr", () => {
|
|
537
|
+
it("writes a layer-stray-file diagnostic to stderr in the legacy single-line shape", async () => {
|
|
538
|
+
const pi = join(scratch, ".pi", "steering");
|
|
539
|
+
mkdirSync(pi, { recursive: true });
|
|
540
|
+
writeFileSync(join(pi, "rules.json"), "{}", "utf8");
|
|
541
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
542
|
+
assert.equal(r.code, 0);
|
|
543
|
+
// Diagnostic on stderr; stdout shows the empty-config render.
|
|
544
|
+
assert.match(
|
|
545
|
+
r.stderr,
|
|
546
|
+
/\[pi-steering\] .*rules\.json: ignoring non-\.ts file under \.pi\/steering\//,
|
|
547
|
+
`expected stray-file diagnostic on stderr; got: ${r.stderr}`,
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("writes a layer-import-failed diagnostic to stderr when a layer fails to import", async () => {
|
|
552
|
+
const pi = join(scratch, ".pi");
|
|
553
|
+
mkdirSync(pi, { recursive: true });
|
|
554
|
+
writeFileSync(
|
|
555
|
+
join(pi, "steering.ts"),
|
|
556
|
+
"export default { rules: {{ not valid ts }} };",
|
|
557
|
+
"utf8",
|
|
558
|
+
);
|
|
559
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
560
|
+
assert.equal(r.code, 0);
|
|
561
|
+
assert.match(
|
|
562
|
+
r.stderr,
|
|
563
|
+
/\[pi-steering\] .*\.pi\/steering\.ts: failed to import:/,
|
|
564
|
+
`expected layer-import-failed diagnostic on stderr; got: ${r.stderr}`,
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("writes an error-class merge diagnostic to stderr with the [error] tag exactly once", async () => {
|
|
569
|
+
writeSteeringDirConfig(
|
|
570
|
+
scratch,
|
|
571
|
+
`const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
|
|
572
|
+
export default {
|
|
573
|
+
plugins: [
|
|
574
|
+
{ name: "pa", trackers: { branch: t } },
|
|
575
|
+
{ name: "pb", trackers: { branch: t } },
|
|
576
|
+
],
|
|
577
|
+
};`,
|
|
578
|
+
);
|
|
579
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
580
|
+
// Error-class diagnostic → exit 1 so CI pipelines using
|
|
581
|
+
// `pi-steering list` as a config-lint pre-flight gate the run
|
|
582
|
+
// without parsing stderr.
|
|
583
|
+
assert.equal(r.code, 1);
|
|
584
|
+
assert.match(
|
|
585
|
+
r.stderr,
|
|
586
|
+
/\[pi-steering\] \[error\] tracker name collision/,
|
|
587
|
+
`expected [error]-tagged tracker collision on stderr; got: ${r.stderr}`,
|
|
588
|
+
);
|
|
589
|
+
// The shared merge-pipeline helper short-circuits between
|
|
590
|
+
// buildConfig and resolvePlugins on error-class merge
|
|
591
|
+
// diagnostics, so the same collision should appear exactly once
|
|
592
|
+
// even though both buildConfig and resolvePlugins independently
|
|
593
|
+
// detect tracker-name collisions.
|
|
594
|
+
const occurrences = r.stderr.match(/tracker name collision/g) ?? [];
|
|
595
|
+
assert.equal(
|
|
596
|
+
occurrences.length,
|
|
597
|
+
1,
|
|
598
|
+
`expected exactly one tracker-name-collision line on stderr; got ${occurrences.length}: ${r.stderr}`,
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("surfaces BOTH a tracker-name-collision AND a malformed user-config rule name on stderr in one run", async () => {
|
|
603
|
+
// Combined-error case: a config with both a merge-side error
|
|
604
|
+
// (tracker-name-collision) AND a user-config-side error
|
|
605
|
+
// (malformed rule name) should produce both diagnostics on
|
|
606
|
+
// stderr in a single `pi-steering list` invocation. Before this change,
|
|
607
|
+
// `runMergerPipeline`'s short-circuit gated
|
|
608
|
+
// `validateUserConfigNames` behind the merge-error branch — the
|
|
609
|
+
// CLI user fixed the tracker, re-ran, and only THEN saw the
|
|
610
|
+
// malformed name. After this change, user-config name validation runs
|
|
611
|
+
// unconditionally so both classes of error surface together,
|
|
612
|
+
// matching the production runtime's aggregated throw.
|
|
613
|
+
writeSteeringDirConfig(
|
|
614
|
+
scratch,
|
|
615
|
+
`const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
|
|
616
|
+
export default {
|
|
617
|
+
plugins: [
|
|
618
|
+
{ name: "pa", trackers: { branch: t } },
|
|
619
|
+
{ name: "pb", trackers: { branch: t } },
|
|
620
|
+
],
|
|
621
|
+
rules: [
|
|
622
|
+
{
|
|
623
|
+
name: "phony] BAD",
|
|
624
|
+
tool: "bash",
|
|
625
|
+
field: "command",
|
|
626
|
+
pattern: /^never$/,
|
|
627
|
+
reason: "r",
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
};`,
|
|
631
|
+
);
|
|
632
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
633
|
+
assert.equal(
|
|
634
|
+
r.code,
|
|
635
|
+
1,
|
|
636
|
+
`expected exit 1 on combined error stream; got: code=${r.code}, stderr=${r.stderr}`,
|
|
637
|
+
);
|
|
638
|
+
assert.match(
|
|
639
|
+
r.stderr,
|
|
640
|
+
/\[pi-steering\] \[error\] tracker name collision/,
|
|
641
|
+
`expected [error]-tagged tracker-name-collision on stderr; got: ${r.stderr}`,
|
|
642
|
+
);
|
|
643
|
+
assert.match(
|
|
644
|
+
r.stderr,
|
|
645
|
+
/\[pi-steering\] \[error\] rule name "phony\] BAD" \(user config\).*disallowed/,
|
|
646
|
+
`expected [error]-tagged invalid-name on stderr; got: ${r.stderr}`,
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("writes an [error]-tagged reserved-tracker-name diagnostic from the plugin merger to stderr", async () => {
|
|
651
|
+
// Reserved-name violations fire only at the plugin-merger surface;
|
|
652
|
+
// without the merger pass in `runList`, a user running
|
|
653
|
+
// `pi-steering list` on a config with a reserved tracker name
|
|
654
|
+
// would see no error, then hit the same violation at extension factory time.
|
|
655
|
+
writeSteeringDirConfig(
|
|
656
|
+
scratch,
|
|
657
|
+
`const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
|
|
658
|
+
export default {
|
|
659
|
+
plugins: [
|
|
660
|
+
{ name: "reserved-plugin", trackers: { events: t } },
|
|
661
|
+
],
|
|
662
|
+
};`,
|
|
663
|
+
);
|
|
664
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
665
|
+
assert.equal(r.code, 1);
|
|
666
|
+
assert.match(
|
|
667
|
+
r.stderr,
|
|
668
|
+
/\[pi-steering\] \[error\] tracker name "events" is reserved/,
|
|
669
|
+
`expected reserved-tracker-name diagnostic on stderr; got: ${r.stderr}`,
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("writes an [error]-tagged invalid-name diagnostic from the plugin merger to stderr", async () => {
|
|
674
|
+
// Malformed plugin / rule / observer names flow through the
|
|
675
|
+
// merger's diagnostic stream after the validateName refactor.
|
|
676
|
+
writeSteeringDirConfig(
|
|
677
|
+
scratch,
|
|
678
|
+
`export default {
|
|
679
|
+
plugins: [
|
|
680
|
+
{
|
|
681
|
+
name: "forge-plugin",
|
|
682
|
+
rules: [
|
|
683
|
+
{
|
|
684
|
+
name: "bad name",
|
|
685
|
+
tool: "bash",
|
|
686
|
+
field: "command",
|
|
687
|
+
pattern: /^never$/,
|
|
688
|
+
reason: "r",
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
};`,
|
|
694
|
+
);
|
|
695
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
696
|
+
assert.equal(r.code, 1);
|
|
697
|
+
assert.match(
|
|
698
|
+
r.stderr,
|
|
699
|
+
/\[pi-steering\] \[error\] rule name "bad name" \(plugin "forge-plugin"\).*disallowed/,
|
|
700
|
+
`expected invalid-name diagnostic on stderr; got: ${r.stderr}`,
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("a clean config produces no diagnostic lines on stderr and exits 0", async () => {
|
|
705
|
+
writeSteeringDirConfig(
|
|
706
|
+
scratch,
|
|
707
|
+
`export default {
|
|
708
|
+
rules: [
|
|
709
|
+
{
|
|
710
|
+
name: "clean-rule",
|
|
711
|
+
tool: "bash",
|
|
712
|
+
field: "command",
|
|
713
|
+
pattern: /^never$/,
|
|
714
|
+
reason: "r",
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
};`,
|
|
718
|
+
);
|
|
719
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
720
|
+
assert.equal(r.code, 0);
|
|
721
|
+
const diagnosticLines = r.stderr
|
|
722
|
+
.split("\n")
|
|
723
|
+
.filter((line) => line.startsWith("[pi-steering]"));
|
|
724
|
+
assert.equal(
|
|
725
|
+
diagnosticLines.length,
|
|
726
|
+
0,
|
|
727
|
+
`expected zero diagnostic lines; got: ${JSON.stringify(diagnosticLines)}`,
|
|
728
|
+
);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("a warning-only diagnostic stream still exits 0 (warnings are fail-soft on the CLI)", async () => {
|
|
732
|
+
// Warning-class diagnostics like `layer-stray-file` are advisory
|
|
733
|
+
// — they don't prevent production from starting on this config.
|
|
734
|
+
// CI pipelines treating exit 1 as "config rejected" should not
|
|
735
|
+
// trip on warnings; the CLI only escalates exit code on error-
|
|
736
|
+
// class diagnostics.
|
|
737
|
+
const pi = join(scratch, ".pi", "steering");
|
|
738
|
+
mkdirSync(pi, { recursive: true });
|
|
739
|
+
writeFileSync(join(pi, "rules.json"), "{}", "utf8");
|
|
740
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
741
|
+
assert.equal(r.code, 0);
|
|
742
|
+
assert.match(
|
|
743
|
+
r.stderr,
|
|
744
|
+
/ignoring non-\.ts file/,
|
|
745
|
+
`expected stray-file diagnostic on stderr; got: ${r.stderr}`,
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("flags a malformed user-config rule name with an invalid-name diagnostic and exits 1", async () => {
|
|
750
|
+
// User-config rule names are validated only at extension factory time
|
|
751
|
+
// (via the evaluator's build-time throw). Without this CLI pass,
|
|
752
|
+
// `pi-steering list` would render the malformed rule as a valid
|
|
753
|
+
// listing on stdout, then production would refuse to start —
|
|
754
|
+
// authors using the CLI as pre-flight would get a false-green.
|
|
755
|
+
writeSteeringDirConfig(
|
|
756
|
+
scratch,
|
|
757
|
+
`export default {
|
|
758
|
+
rules: [
|
|
759
|
+
{
|
|
760
|
+
name: "phony] ALL CLEAR [real",
|
|
761
|
+
tool: "bash",
|
|
762
|
+
field: "command",
|
|
763
|
+
pattern: /^never$/,
|
|
764
|
+
reason: "r",
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
};`,
|
|
768
|
+
);
|
|
769
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
770
|
+
assert.equal(r.code, 1);
|
|
771
|
+
assert.match(
|
|
772
|
+
r.stderr,
|
|
773
|
+
/\[pi-steering\] \[error\] rule name "phony\] ALL CLEAR \[real" \(user config\).*disallowed/,
|
|
774
|
+
`expected user-config rule invalid-name diagnostic on stderr; got: ${r.stderr}`,
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("flags a malformed user-config observer name with an invalid-name diagnostic and exits 1", async () => {
|
|
779
|
+
// Same shape as the rule-name case but for observers — ensures
|
|
780
|
+
// the CLI's pre-flight surface covers both halves of the
|
|
781
|
+
// `validateUserConfigNames` helper.
|
|
782
|
+
writeSteeringDirConfig(
|
|
783
|
+
scratch,
|
|
784
|
+
`export default {
|
|
785
|
+
observers: [
|
|
786
|
+
{ name: "evil] obs", onResult: () => {} },
|
|
787
|
+
],
|
|
788
|
+
};`,
|
|
789
|
+
);
|
|
790
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
791
|
+
assert.equal(r.code, 1);
|
|
792
|
+
assert.match(
|
|
793
|
+
r.stderr,
|
|
794
|
+
/\[pi-steering\] \[error\] observer name "evil\] obs" \(user config\).*disallowed/,
|
|
795
|
+
`expected user-config observer invalid-name diagnostic on stderr; got: ${r.stderr}`,
|
|
796
|
+
);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("surfaces an `observer dropped` info-level breadcrumb on stderr when an observer's writes are unconsumed", async () => {
|
|
800
|
+
// CLI mirrors `buildSessionRuntime`'s `dropUnusedObservers` pass
|
|
801
|
+
// so `pi-steering list` surfaces the same breadcrumb a session
|
|
802
|
+
// would, via the `console.info` interception in
|
|
803
|
+
// `runCliMergeWithInfoCapture` (lands on stderr; stdout stays
|
|
804
|
+
// clean for the structured listing).
|
|
805
|
+
writeSteeringDirConfig(
|
|
806
|
+
scratch,
|
|
807
|
+
`export default {
|
|
808
|
+
observers: [
|
|
809
|
+
{
|
|
810
|
+
name: "unread",
|
|
811
|
+
writes: ["never_consumed"],
|
|
812
|
+
onResult: () => {},
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
rules: [
|
|
816
|
+
{
|
|
817
|
+
name: "r",
|
|
818
|
+
tool: "bash",
|
|
819
|
+
field: "command",
|
|
820
|
+
pattern: /^never$/,
|
|
821
|
+
reason: "r",
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
};`,
|
|
825
|
+
);
|
|
826
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
827
|
+
// Drop pass is informational, not error-class — exit 0.
|
|
828
|
+
assert.equal(
|
|
829
|
+
r.code,
|
|
830
|
+
0,
|
|
831
|
+
`expected exit 0 (info-level breadcrumb only); got code=${r.code}, stderr=${r.stderr}`,
|
|
832
|
+
);
|
|
833
|
+
assert.match(
|
|
834
|
+
r.stderr,
|
|
835
|
+
/\[pi-steering\] observer 'unread' dropped; its writes \(never_consumed\) are not consumed by any rule/,
|
|
836
|
+
`expected observer-drop breadcrumb on stderr; got: ${r.stderr}`,
|
|
837
|
+
);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it("surfaces an `observer dropped` breadcrumb when the consumer rule is disabled via `disabledRules` (parity with runtime)", async () => {
|
|
841
|
+
// CLI must filter `merged.rules` against `disabledRules` before
|
|
842
|
+
// `dropUnusedObservers` so the observer set the runtime would
|
|
843
|
+
// drop at extension factory time matches what `pi-steering list`
|
|
844
|
+
// reports.
|
|
845
|
+
writeSteeringDirConfig(
|
|
846
|
+
scratch,
|
|
847
|
+
`export default {
|
|
848
|
+
disabledRules: ["consumer"],
|
|
849
|
+
observers: [
|
|
850
|
+
{
|
|
851
|
+
name: "obs-x",
|
|
852
|
+
writes: ["X"],
|
|
853
|
+
onResult: () => {},
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
rules: [
|
|
857
|
+
{
|
|
858
|
+
name: "consumer",
|
|
859
|
+
tool: "bash",
|
|
860
|
+
field: "command",
|
|
861
|
+
pattern: /^never$/,
|
|
862
|
+
reason: "r",
|
|
863
|
+
when: { happened: { event: "X" } },
|
|
864
|
+
},
|
|
865
|
+
],
|
|
866
|
+
};`,
|
|
867
|
+
);
|
|
868
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
869
|
+
assert.equal(
|
|
870
|
+
r.code,
|
|
871
|
+
0,
|
|
872
|
+
`expected exit 0 (info-level breadcrumb only); got code=${r.code}, stderr=${r.stderr}`,
|
|
873
|
+
);
|
|
874
|
+
assert.match(
|
|
875
|
+
r.stderr,
|
|
876
|
+
/\[pi-steering\] observer 'obs-x' dropped; its writes \(X\) are not consumed by any rule/,
|
|
877
|
+
`expected observer-drop breadcrumb on stderr (consumer rule was disabled, observer should be reported as dropped to match runtime); got: ${r.stderr}`,
|
|
878
|
+
);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("does NOT drop the observer when the consumer rule is enabled (inverse parity)", async () => {
|
|
882
|
+
// Pins the inverse direction so a refactor that flips the
|
|
883
|
+
// filter (`disabledRules.has(r.name)` ↔ `!disabledRules.has`)
|
|
884
|
+
// surfaces here — the disabled-true case alone would not catch
|
|
885
|
+
// it.
|
|
886
|
+
writeSteeringDirConfig(
|
|
887
|
+
scratch,
|
|
888
|
+
`export default {
|
|
889
|
+
observers: [
|
|
890
|
+
{
|
|
891
|
+
name: "obs-x",
|
|
892
|
+
writes: ["X"],
|
|
893
|
+
onResult: () => {},
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
rules: [
|
|
897
|
+
{
|
|
898
|
+
name: "consumer",
|
|
899
|
+
tool: "bash",
|
|
900
|
+
field: "command",
|
|
901
|
+
pattern: /^never$/,
|
|
902
|
+
reason: "r",
|
|
903
|
+
when: { happened: { event: "X" } },
|
|
904
|
+
},
|
|
905
|
+
],
|
|
906
|
+
};`,
|
|
907
|
+
);
|
|
908
|
+
const r = await runCli({ cwd: scratch }, "list");
|
|
909
|
+
assert.equal(
|
|
910
|
+
r.code,
|
|
911
|
+
0,
|
|
912
|
+
`expected exit 0 (clean run); got code=${r.code}, stderr=${r.stderr}`,
|
|
913
|
+
);
|
|
914
|
+
assert.doesNotMatch(
|
|
915
|
+
r.stderr,
|
|
916
|
+
/\[pi-steering\] observer 'obs-x' dropped/,
|
|
917
|
+
`expected NO observer-drop breadcrumb on stderr (consumer rule is enabled, observer is consumed); got: ${r.stderr}`,
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
});
|