@boyingliu01/xp-gate 0.35.15 → 0.35.17
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/bin/xp-gate.js +67 -6
- package/gates/gate-9.ts +26 -4
- package/hooks/pre-commit +33 -10
- package/lib/__tests__/audit-cli-contract.test.ts +117 -0
- package/lib/__tests__/gate-coverage-hook-floor.test.js +94 -0
- package/lib/__tests__/gate-entry-provenance.test.js +71 -0
- package/lib/gate-coverage.js +46 -3
- package/lib/gate-runner.js +10 -2
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/runners/mutmut-runner.ts +165 -70
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/README.md +22 -1
- package/plugins/opencode/contract-v2.test.ts +125 -0
- package/plugins/opencode/exec-injection.test.ts +110 -0
- package/plugins/opencode/index.ts +39 -4
- package/plugins/opencode/package.json +2 -7
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/adapters/import-clause.ts +57 -0
- package/principles/adapters/member-scan.ts +73 -0
- package/principles/adapters/position-analysis.ts +43 -0
- package/principles/adapters/quote-state.ts +80 -0
- package/principles/adapters/typescript.ts +57 -116
- package/principles/rules/clean-code/god-class.ts +37 -14
- package/principles/rules/solid/dip.ts +15 -2
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @no-test-required: mutmut wrapper — tested via gate-m integration + pre-push Gate M
|
|
2
|
-
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import { spawn, execSync, type ChildProcess } from 'child_process';
|
|
3
3
|
import { writeFileSync, unlinkSync, readFileSync, existsSync } from 'fs';
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import type {
|
|
@@ -24,6 +24,87 @@ type MutmutRoute = 'native' | 'wsl' | 'unavailable' | 'unresolved';
|
|
|
24
24
|
* - Results stored in SQLite; `mutmut results` outputs per-mutant status lines
|
|
25
25
|
* - `mutmut run` stdout shows emoji progress: 🎉 N 🫥 N ⏰ N 🤔 N 🙁 N
|
|
26
26
|
*/
|
|
27
|
+
/**
|
|
28
|
+
* Wire a child process's stdout/stderr into one accumulating callback.
|
|
29
|
+
*
|
|
30
|
+
* Extracted from `runMutmut`: the two listeners were six lines of pure
|
|
31
|
+
* accumulation inside an already-long Promise body, and their nesting was part
|
|
32
|
+
* of what made that method exceed both the length and the nesting budget.
|
|
33
|
+
*/
|
|
34
|
+
function collectOutput(
|
|
35
|
+
child: ChildProcess,
|
|
36
|
+
onChunk: (chunk: string, isStderr: boolean) => void,
|
|
37
|
+
): void {
|
|
38
|
+
child.stdout?.on('data', (data: Buffer) => onChunk(data.toString(), false));
|
|
39
|
+
child.stderr?.on('data', (data: Buffer) => onChunk(data.toString(), true));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How long a SIGTERMed mutmut gets before it is killed outright. */
|
|
43
|
+
const ESCALATION_DELAY_MS = 5000;
|
|
44
|
+
|
|
45
|
+
/** Where the pre-run copy of pyproject.toml lives. */
|
|
46
|
+
function pyprojectBackupPath(pyprojectPath: string): string {
|
|
47
|
+
return `${pyprojectPath}.xp-gate-backup`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Put pyproject.toml back the way it was, or remove the one we generated.
|
|
52
|
+
*
|
|
53
|
+
* Cleanup is best-effort by design: the mutation run's own result is what the
|
|
54
|
+
* caller acts on, and failing here would replace a usable outcome with an
|
|
55
|
+
* exception raised from a `finally`. Extracted from `run` because the nested
|
|
56
|
+
* try/if/if/else was what pushed that method to 5 nesting levels.
|
|
57
|
+
*/
|
|
58
|
+
function restorePyproject(pyprojectPath: string, hadExisting: boolean): void {
|
|
59
|
+
const backupPath = pyprojectBackupPath(pyprojectPath);
|
|
60
|
+
try {
|
|
61
|
+
if (hadExisting && existsSync(backupPath)) {
|
|
62
|
+
writeFileSync(pyprojectPath, readFileSync(backupPath, 'utf-8'), { encoding: 'utf-8' });
|
|
63
|
+
unlinkSync(backupPath);
|
|
64
|
+
} else if (!hadExisting && existsSync(pyprojectPath)) {
|
|
65
|
+
unlinkSync(pyprojectPath);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Best effort cleanup
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The tracker shape `settleTracker` returns; only the timeout path uses these. */
|
|
73
|
+
interface SettleTracker {
|
|
74
|
+
didTimeOut: () => boolean;
|
|
75
|
+
markTimedOut: () => void;
|
|
76
|
+
settle: (outcome: MutationRunOutcome) => void;
|
|
77
|
+
isSettled: () => boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Arm the run timeout, escalating SIGTERM to SIGKILL once.
|
|
82
|
+
*
|
|
83
|
+
* Extracted from `runMutmut` for the same reason as `collectOutput`: the
|
|
84
|
+
* two-step kill sequence is twelve lines of policy that pushed the method past
|
|
85
|
+
* both budgets. The escalation timer id is handed back through `setEscalation`
|
|
86
|
+
* so the caller's `cancelTimers` closure can still reach it.
|
|
87
|
+
*
|
|
88
|
+
* @returns the primary timer id, to be cleared by the caller.
|
|
89
|
+
*/
|
|
90
|
+
function armTimeout(
|
|
91
|
+
child: ChildProcess,
|
|
92
|
+
timeoutMs: number,
|
|
93
|
+
tracker: SettleTracker,
|
|
94
|
+
setEscalation: (id: NodeJS.Timeout) => void,
|
|
95
|
+
): NodeJS.Timeout {
|
|
96
|
+
const escalationMs = ESCALATION_DELAY_MS;
|
|
97
|
+
return setTimeout(() => {
|
|
98
|
+
tracker.markTimedOut();
|
|
99
|
+
child.kill('SIGTERM');
|
|
100
|
+
if (tracker.isSettled()) return;
|
|
101
|
+
// SIGTERM may be ignored; escalate once.
|
|
102
|
+
setEscalation(setTimeout(() => {
|
|
103
|
+
if (!tracker.isSettled()) child.kill('SIGKILL');
|
|
104
|
+
}, escalationMs));
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
|
|
27
108
|
export class MutmutRunner implements MutationRunner {
|
|
28
109
|
readonly name = 'mutmut';
|
|
29
110
|
readonly extensions = ['py'];
|
|
@@ -72,33 +153,35 @@ export class MutmutRunner implements MutationRunner {
|
|
|
72
153
|
const sourceDirs = this.extractUniqueDirs(options.files);
|
|
73
154
|
|
|
74
155
|
const pyprojectPath = join(options.cwd, 'pyproject.toml');
|
|
75
|
-
const
|
|
76
|
-
let hadExisting = false;
|
|
156
|
+
const hadExisting = this.backupPyproject(pyprojectPath);
|
|
77
157
|
|
|
78
158
|
try {
|
|
79
|
-
if (existsSync(pyprojectPath)) {
|
|
80
|
-
hadExisting = true;
|
|
81
|
-
const existing = readFileSync(pyprojectPath, 'utf-8');
|
|
82
|
-
writeFileSync(backupPath, existing, { encoding: 'utf-8' });
|
|
83
|
-
}
|
|
84
159
|
this.writeMutmutConfig(pyprojectPath, sourceDirs);
|
|
85
|
-
|
|
86
160
|
return await this.runMutmut(options);
|
|
87
161
|
} finally {
|
|
88
|
-
|
|
89
|
-
if (hadExisting && existsSync(backupPath)) {
|
|
90
|
-
const backup = readFileSync(backupPath, 'utf-8');
|
|
91
|
-
writeFileSync(pyprojectPath, backup, { encoding: 'utf-8' });
|
|
92
|
-
unlinkSync(backupPath);
|
|
93
|
-
} else if (!hadExisting && existsSync(pyprojectPath)) {
|
|
94
|
-
unlinkSync(pyprojectPath);
|
|
95
|
-
}
|
|
96
|
-
} catch {
|
|
97
|
-
// Best effort cleanup
|
|
98
|
-
}
|
|
162
|
+
restorePyproject(pyprojectPath, hadExisting);
|
|
99
163
|
}
|
|
100
164
|
}
|
|
101
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Copy an existing pyproject.toml aside so `run` can put it back.
|
|
168
|
+
* @returns whether a file was there to back up.
|
|
169
|
+
*/
|
|
170
|
+
private backupPyproject(pyprojectPath: string): boolean {
|
|
171
|
+
if (!existsSync(pyprojectPath)) return false;
|
|
172
|
+
const backupPath = pyprojectBackupPath(pyprojectPath);
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(backupPath, readFileSync(pyprojectPath, 'utf-8'), { encoding: 'utf-8' });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// Failing here must be loud: without a backup, `restorePyproject` would
|
|
177
|
+
// silently leave this repo's pyproject.toml overwritten by a generated one.
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Could not back up ${pyprojectPath} before writing a mutmut config: ${(err as Error).message}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
102
185
|
/**
|
|
103
186
|
* Build the terminal-outcome handler for one `mutmut run`.
|
|
104
187
|
*
|
|
@@ -173,39 +256,17 @@ export class MutmutRunner implements MutationRunner {
|
|
|
173
256
|
|
|
174
257
|
const tracker = this.settleTracker(resolve, cancelTimers);
|
|
175
258
|
|
|
176
|
-
const timeoutId =
|
|
177
|
-
|
|
178
|
-
child.kill('SIGTERM');
|
|
179
|
-
if (tracker.isSettled()) return;
|
|
180
|
-
// SIGTERM may be ignored; escalate once, and let the retry live in a
|
|
181
|
-
// variable so `cancelTimers` can reach it.
|
|
182
|
-
escalationId = setTimeout(() => {
|
|
183
|
-
if (!tracker.isSettled()) child.kill('SIGKILL');
|
|
184
|
-
}, 5000);
|
|
185
|
-
}, options.timeoutMs);
|
|
186
|
-
|
|
187
|
-
child.stdout?.on('data', (data: Buffer) => {
|
|
188
|
-
stdout += data.toString();
|
|
259
|
+
const timeoutId = armTimeout(child, options.timeoutMs, tracker, (id) => {
|
|
260
|
+
escalationId = id;
|
|
189
261
|
});
|
|
190
262
|
|
|
191
|
-
child
|
|
192
|
-
stderr +=
|
|
263
|
+
collectOutput(child, (chunk, isErr) => {
|
|
264
|
+
if (isErr) stderr += chunk;
|
|
265
|
+
else stdout += chunk;
|
|
193
266
|
});
|
|
194
267
|
|
|
195
268
|
child.on('close', (code) => {
|
|
196
|
-
|
|
197
|
-
// it, "unknown" otherwise); there is no report to parse either way.
|
|
198
|
-
if (code === null) {
|
|
199
|
-
tracker.settle({ report: null, timedOut: tracker.didTimeOut() });
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
tracker.settle({
|
|
204
|
-
// Parse results from mutmut run stdout (v3.x emoji progress)
|
|
205
|
-
report: this.parseEmojiProgress(stdout),
|
|
206
|
-
timedOut: tracker.didTimeOut(),
|
|
207
|
-
error: code !== 0 ? stderr || stdout : undefined,
|
|
208
|
-
});
|
|
269
|
+
tracker.settle(this.outcomeFor(code, stdout, stderr, tracker));
|
|
209
270
|
});
|
|
210
271
|
|
|
211
272
|
child.on('error', (err) => {
|
|
@@ -214,6 +275,29 @@ export class MutmutRunner implements MutationRunner {
|
|
|
214
275
|
});
|
|
215
276
|
}
|
|
216
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Turn one `mutmut run` exit into the outcome the caller expects.
|
|
280
|
+
*
|
|
281
|
+
* Extracted from `runMutmut`'s close handler to bring that method back under
|
|
282
|
+
* the length and nesting budgets. A null exit code means the process was
|
|
283
|
+
* killed -- "timed out" when we did it, "unknown" otherwise -- and there is no
|
|
284
|
+
* report to parse either way.
|
|
285
|
+
*/
|
|
286
|
+
private outcomeFor(
|
|
287
|
+
code: number | null,
|
|
288
|
+
stdout: string,
|
|
289
|
+
stderr: string,
|
|
290
|
+
tracker: SettleTracker,
|
|
291
|
+
): MutationRunOutcome {
|
|
292
|
+
if (code === null) return { report: null, timedOut: tracker.didTimeOut() };
|
|
293
|
+
return {
|
|
294
|
+
// Parse results from mutmut run stdout (v3.x emoji progress)
|
|
295
|
+
report: this.parseEmojiProgress(stdout),
|
|
296
|
+
timedOut: tracker.didTimeOut(),
|
|
297
|
+
error: code !== 0 ? stderr || stdout : undefined,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
217
301
|
private toWslPath(path: string): string | null {
|
|
218
302
|
const match = path.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
219
303
|
if (!match) return null;
|
|
@@ -300,28 +384,39 @@ export class MutmutRunner implements MutationRunner {
|
|
|
300
384
|
}
|
|
301
385
|
|
|
302
386
|
private writeMutmutConfig(pyprojectPath: string, sourceDirs: string[]): void {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
'[tool.mutmut]',
|
|
316
|
-
`[tool.mutmut]\n${sourcePathsLine}`,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
} else {
|
|
320
|
-
content = content.trimEnd() + `\n\n[tool.mutmut]\n${sourcePathsLine}\n`;
|
|
321
|
-
}
|
|
322
|
-
} else {
|
|
323
|
-
content = `[tool.mutmut]\n${sourcePathsLine}\n`;
|
|
387
|
+
const sourcePathsLine = `source_paths = [${sourceDirs.map((d) => `"${d}"`).join(', ')}]`;
|
|
388
|
+
try {
|
|
389
|
+
// Read here so the content builder stays pure: it then has no I/O to
|
|
390
|
+
// fail on, and the one place that can fail is the one that explains why.
|
|
391
|
+
const existing = existsSync(pyprojectPath) ? readFileSync(pyprojectPath, 'utf-8') : null;
|
|
392
|
+
writeFileSync(pyprojectPath, mutmutConfigContent(existing, sourcePathsLine), {
|
|
393
|
+
encoding: 'utf-8',
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
// A missing or half-written pyproject.toml would make mutmut scan the wrong
|
|
397
|
+
// tree, so surface the path instead of letting a bare fs error escape.
|
|
398
|
+
throw new Error(`Could not write mutmut config to ${pyprojectPath}: ${(err as Error).message}`);
|
|
324
399
|
}
|
|
325
|
-
writeFileSync(pyprojectPath, content, { encoding: 'utf-8' });
|
|
326
400
|
}
|
|
327
401
|
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* pyproject.toml content with `[tool.mutmut] source_paths` set as requested.
|
|
405
|
+
*
|
|
406
|
+
* Pure by design: the caller reads the existing file and passes it in, so this
|
|
407
|
+
* function has no I/O that could fail partway through. Three shapes have to be
|
|
408
|
+
* preserved -- an existing `source_paths` is rewritten in place, a bare
|
|
409
|
+
* `[tool.mutmut]` table gets the key added, and a file without the table gets
|
|
410
|
+
* one appended (a fresh file passes `null`).
|
|
411
|
+
*
|
|
412
|
+
* @param existing current file content, or null when the file does not exist.
|
|
413
|
+
*/
|
|
414
|
+
function mutmutConfigContent(existing: string | null, sourcePathsLine: string): string {
|
|
415
|
+
if (existing === null) return `[tool.mutmut]\n${sourcePathsLine}\n`;
|
|
416
|
+
if (!existing.includes('[tool.mutmut]')) {
|
|
417
|
+
return existing.trimEnd() + `\n\n[tool.mutmut]\n${sourcePathsLine}\n`;
|
|
418
|
+
}
|
|
419
|
+
const sourcePathsRegex = /source_paths\s*=\s*\[.*?\]/;
|
|
420
|
+
if (sourcePathsRegex.test(existing)) return existing.replace(sourcePathsRegex, sourcePathsLine);
|
|
421
|
+
return existing.replace('[tool.mutmut]', `[tool.mutmut]\n${sourcePathsLine}`);
|
|
422
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.17",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
package/plugins/dsh/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boyingliu01/dsh-plugin-xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.17",
|
|
4
4
|
"description": "XP-Gate deterministic quality gates + AI workflow skills as a DeepSeek Harness (DSH) native plugin: gate-check / gate-principles / gate-arch tools + 12 SKILL.md skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.17.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -62,9 +62,30 @@ Or via local path (development):
|
|
|
62
62
|
}
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
### OpenCode v2
|
|
66
|
+
|
|
67
|
+
v2 validates the plugin's default export against a schema requiring `id` plus
|
|
68
|
+
`effect` or `setup`. This package ships both keys from 0.35.15 onwards, so the
|
|
69
|
+
same published version loads under v1 and v2 — no separate build, no manifest
|
|
70
|
+
change on your side.
|
|
71
|
+
|
|
72
|
+
Register it the same way (v2 reads the same `plugin` array):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"plugin": ["@boyingliu01/opencode-plugin"]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Do **not** register `@boyingliu01/opencode-plugin/tui`. The TUI panel was
|
|
81
|
+
abandoned in v0.13.0 and there is no `/tui` entry; v2's npm installer treats the
|
|
82
|
+
whole string as a package name, so it fails on every startup with
|
|
83
|
+
`NpmInstallFailedError`. The subpath export was removed in 0.35.15 for exactly
|
|
84
|
+
that reason.
|
|
85
|
+
|
|
65
86
|
## Requirements
|
|
66
87
|
|
|
67
|
-
- OpenCode v0.11+
|
|
88
|
+
- OpenCode v0.11+ (v2.1.4+ supported)
|
|
68
89
|
- One of:
|
|
69
90
|
- `xp-gate` CLI installed globally (`npm install -g @boyingliu01/xp-gate`) — **preferred**, or
|
|
70
91
|
- the xp-gate repo checked out locally with `src/principles/index.ts` reachable — **fallback** (the tool will shell out via `npx -y tsx`)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-639
|
|
3
|
+
* @intent OpenCode v2(2.1.4+)更换了插件模块契约。v1 的 `{ id, server }` 导出会被
|
|
4
|
+
* v2 直接拒绝:
|
|
5
|
+
*
|
|
6
|
+
* PluginModule.LoadError: Plugin must export a default definition with an id
|
|
7
|
+
* and an effect or setup function.
|
|
8
|
+
* (SchemaError(Missing key at ["default"]["effect"] / ["default"]["setup"]))
|
|
9
|
+
*
|
|
10
|
+
* 后果不是"少个功能",是整个插件不加载——`gate-check` / `gate-principles` /
|
|
11
|
+
* `gate-arch` 在 v2 会话里全部消失,而且每次启动都刷一遍 load error。
|
|
12
|
+
*
|
|
13
|
+
* 这些测试钉的是**契约形状**,不是实现细节:v2 会不会接受这个模块,
|
|
14
|
+
* 取决于 default 导出有没有 `id` 和 `effect`/`setup`。所以断言必须照着
|
|
15
|
+
* v2 的校验规则写,而不是照着我们的代码写。
|
|
16
|
+
*
|
|
17
|
+
* 同一个 issue 里还有第二个独立的坏点:`exports["./tui"]` 指向
|
|
18
|
+
* `./tui-plugin.tsx`,而仓库里**根本没有这个文件**。一个指向不存在文件的
|
|
19
|
+
* 导出会让 v2 的 npm 安装器在每次启动时失败——即使 server 侧修好了,
|
|
20
|
+
* TUI 子路径仍然会把整个安装拖挂。
|
|
21
|
+
* @covers AC-639-01, AC-639-02, AC-639-03
|
|
22
|
+
*/
|
|
23
|
+
"use strict"
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs"
|
|
26
|
+
import path from "node:path"
|
|
27
|
+
import { fileURLToPath } from "node:url"
|
|
28
|
+
|
|
29
|
+
// A real function, used as a stand-in for the `setup` export when feeding the v2
|
|
30
|
+
// validator. Named rather than inline `() => {}` because eslint's
|
|
31
|
+
// no-empty-function fires on the inline form -- and a named no-op reads as
|
|
32
|
+
// "deliberately does nothing" instead of "someone forgot to fill this in".
|
|
33
|
+
const noop = (): void => undefined
|
|
34
|
+
|
|
35
|
+
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, "package.json"), "utf8"))
|
|
37
|
+
|
|
38
|
+
describe("OpenCode v2 模块契约 (#639)", () => {
|
|
39
|
+
/**
|
|
40
|
+
* The v2 validator, reduced to what it actually checks. Reproduced here as a
|
|
41
|
+
* predicate so the test fails with a readable reason instead of a schema dump.
|
|
42
|
+
*/
|
|
43
|
+
function v2Accepts(moduleDefault) {
|
|
44
|
+
if (moduleDefault === null || typeof moduleDefault !== "object") {
|
|
45
|
+
return { ok: false, reason: "default export is not an object" }
|
|
46
|
+
}
|
|
47
|
+
if (typeof moduleDefault.id !== "string" || moduleDefault.id === "") {
|
|
48
|
+
return { ok: false, reason: 'missing "id"' }
|
|
49
|
+
}
|
|
50
|
+
const hasEffect = moduleDefault.effect !== undefined
|
|
51
|
+
const hasSetup = typeof moduleDefault.setup === "function"
|
|
52
|
+
if (!hasEffect && !hasSetup) {
|
|
53
|
+
return { ok: false, reason: 'missing "effect" and "setup"' }
|
|
54
|
+
}
|
|
55
|
+
return { ok: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it("AC-639-01: the default export satisfies the v2 schema (id + effect|setup)", () => {
|
|
59
|
+
const source = fs.readFileSync(path.join(PLUGIN_DIR, "index.ts"), "utf8")
|
|
60
|
+
|
|
61
|
+
// Read the module shape from source rather than importing: the file is TS and
|
|
62
|
+
// this suite is CommonJS, and the thing under test is the SHAPE, which is
|
|
63
|
+
// visible in source. Importing would drag the whole plugin runtime in.
|
|
64
|
+
const moduleBlock = /const pluginModule\s*=\s*\{([\s\S]*?)\n\}/.exec(source)
|
|
65
|
+
expect(moduleBlock).not.toBeNull()
|
|
66
|
+
|
|
67
|
+
const body = moduleBlock[1]
|
|
68
|
+
const hasId = /\bid\s*:/.test(body)
|
|
69
|
+
const hasEffect = /\beffect\s*:/.test(body)
|
|
70
|
+
const hasSetup = /\bsetup\s*:/.test(body)
|
|
71
|
+
|
|
72
|
+
// Assert through the validator so a future reader sees WHY, not just which key.
|
|
73
|
+
const verdict = v2Accepts({
|
|
74
|
+
id: hasId ? "xp-gate" : undefined,
|
|
75
|
+
effect: hasEffect ? {} : undefined,
|
|
76
|
+
setup: hasSetup ? noop : undefined,
|
|
77
|
+
})
|
|
78
|
+
expect(verdict.reason).toBeUndefined()
|
|
79
|
+
expect(verdict.ok).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("AC-639-02: v1 compatibility is kept, not silently dropped", () => {
|
|
83
|
+
const source = fs.readFileSync(path.join(PLUGIN_DIR, "index.ts"), "utf8")
|
|
84
|
+
const moduleBlock = /const pluginModule\s*=\s*\{([\s\S]*?)\n\}/.exec(source)
|
|
85
|
+
expect(moduleBlock).not.toBeNull()
|
|
86
|
+
|
|
87
|
+
// v1 reads `server`. Dropping it would break every v1 user on upgrade -- a fix
|
|
88
|
+
// for v2 that breaks v1 is a migration, not a fix, and nobody asked for one.
|
|
89
|
+
expect(moduleBlock[1]).toMatch(/\bserver\s*:/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('AC-639-03: every path in "exports" resolves to a real file', () => {
|
|
93
|
+
const exportsMap = pkg.exports || {}
|
|
94
|
+
const broken = []
|
|
95
|
+
|
|
96
|
+
for (const [subpath, entry] of Object.entries(exportsMap)) {
|
|
97
|
+
// Each entry is either a string or { types, import } -- flatten both shapes.
|
|
98
|
+
const targets = typeof entry === "string"
|
|
99
|
+
? [entry]
|
|
100
|
+
: Object.values(entry || {}).filter((v) => typeof v === "string")
|
|
101
|
+
|
|
102
|
+
for (const target of targets) {
|
|
103
|
+
const resolved = path.resolve(PLUGIN_DIR, target)
|
|
104
|
+
if (!fs.existsSync(resolved)) {
|
|
105
|
+
broken.push(`${subpath} -> ${target}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A dangling export is worse than a missing one: npm accepts the manifest,
|
|
111
|
+
// node resolves the subpath to nothing, and the failure surfaces at the
|
|
112
|
+
// consumer's startup as an install error with no pointer back to this file.
|
|
113
|
+
expect(broken).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('AC-639-03: every path in "files" exists too', () => {
|
|
117
|
+
const listed = pkg.files || []
|
|
118
|
+
const missing = listed.filter((entry) => {
|
|
119
|
+
// Directory entries end with "/" and are allowed to match a directory.
|
|
120
|
+
const resolved = path.resolve(PLUGIN_DIR, entry.replace(/\/$/, ""))
|
|
121
|
+
return !fs.existsSync(resolved)
|
|
122
|
+
})
|
|
123
|
+
expect(missing).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-639
|
|
3
|
+
* @intent `readCurrentSessionModel` 曾把调用方的 `sessionId` 直接插进 shell 字符串。
|
|
4
|
+
*
|
|
5
|
+
* `escapedId` 只转义了 SQL 单引号——那是 **SQL** 的关注点,不是 **shell** 的。
|
|
6
|
+
* 双引号、命令替换、反引号全部继续生效,所以一个构造过的 session id
|
|
7
|
+
* 就是在宿主上执行任意命令。
|
|
8
|
+
*
|
|
9
|
+
* 把它换成 `execFileSync(..., [dbPath, sql])` 之后,shell 根本不在调用路径上。
|
|
10
|
+
* 但这些测试钉的不是"用了哪个 API"(那是实现细节,可以被同名包装绕过),
|
|
11
|
+
* 而是**行为**:给定一个带 shell 元字符的 session id,那条注入的命令不能执行。
|
|
12
|
+
* 测试真的调用 sqlite3,所以它验证的是现实,不是代码形状。
|
|
13
|
+
* @covers AC-639-04
|
|
14
|
+
*/
|
|
15
|
+
"use strict"
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from "node:child_process"
|
|
18
|
+
import fs from "node:fs"
|
|
19
|
+
import os from "node:os"
|
|
20
|
+
import path from "node:path"
|
|
21
|
+
import { fileURLToPath } from "node:url"
|
|
22
|
+
|
|
23
|
+
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const SOURCE = path.join(PLUGIN_DIR, "index.ts")
|
|
25
|
+
|
|
26
|
+
/** sqlite3 spawn deadline. Generous: CI disks are slow, and a timeout here would
|
|
27
|
+
* surface as a flaky security test rather than the signal we want. */
|
|
28
|
+
const SQLITE_TIMEOUT_MS = 20_000
|
|
29
|
+
|
|
30
|
+
/** Read a function body out of source; the module's TS runtime is not loadable here. */
|
|
31
|
+
function readFunctionBody(name: string): string {
|
|
32
|
+
// A throw here means the source shape moved. That is a real failure worth seeing,
|
|
33
|
+
// so it is re-thrown with the reason rather than swallowed into an empty string --
|
|
34
|
+
// an unreadable source must not read as "the function has no execSync".
|
|
35
|
+
let source: string
|
|
36
|
+
try {
|
|
37
|
+
source = fs.readFileSync(SOURCE, "utf8")
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`cannot read ${SOURCE}: ${(error as Error).message}`)
|
|
40
|
+
}
|
|
41
|
+
const start = source.indexOf(`function ${name}`)
|
|
42
|
+
expect(start).toBeGreaterThan(-1)
|
|
43
|
+
// Brace-count to the matching close so nested braces do not truncate the body.
|
|
44
|
+
let depth = 0
|
|
45
|
+
let index = source.indexOf("{", start)
|
|
46
|
+
const open = index
|
|
47
|
+
for (; index < source.length; index += 1) {
|
|
48
|
+
if (source[index] === "{") depth += 1
|
|
49
|
+
else if (source[index] === "}") {
|
|
50
|
+
depth -= 1
|
|
51
|
+
if (depth === 0) break
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return source.slice(open, index + 1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drop comments so an assertion about code cannot fail on the prose that documents
|
|
59
|
+
* the bug it forbids -- the function itself names `execSync` in its explanation.
|
|
60
|
+
*/
|
|
61
|
+
function stripComments(code: string): string {
|
|
62
|
+
return code
|
|
63
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
64
|
+
.split("\n")
|
|
65
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
66
|
+
.join("\n")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("session model reader does not go through a shell (#639)", () => {
|
|
70
|
+
it("AC-639-04: the function does not build a shell command string", () => {
|
|
71
|
+
const code = stripComments(readFunctionBody("readCurrentSessionModel"))
|
|
72
|
+
|
|
73
|
+
// execSync with an interpolated command is the exact shape that shipped the
|
|
74
|
+
// injection. If a future refactor reintroduces it, this fails before review.
|
|
75
|
+
expect(code).not.toMatch(/execSync\s*\(/)
|
|
76
|
+
expect(code).toMatch(/execFileSync\s*\(/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("AC-639-04: a session id with shell metacharacters reaches sqlite as data", () => {
|
|
80
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "xpg-sqlite-"))
|
|
81
|
+
const dbPath = path.join(tmp, "session.db")
|
|
82
|
+
const marker = path.join(tmp, "pwned")
|
|
83
|
+
try {
|
|
84
|
+
// Real sqlite3, so the argv/shell boundary is exercised for real rather than
|
|
85
|
+
// modelled by a mock that could happily agree with a wrong implementation.
|
|
86
|
+
execFileSync("sqlite3", [dbPath, "CREATE TABLE session (id TEXT, model TEXT)"], {
|
|
87
|
+
timeout: SQLITE_TIMEOUT_MS,
|
|
88
|
+
})
|
|
89
|
+
const hostileId = `x"; touch '${marker}'; echo "`
|
|
90
|
+
const sqlDoubleQuote = "'"
|
|
91
|
+
const escapedId = hostileId.split("'").join(sqlDoubleQuote + sqlDoubleQuote)
|
|
92
|
+
execFileSync(
|
|
93
|
+
"sqlite3",
|
|
94
|
+
[dbPath, `INSERT INTO session VALUES ('${escapedId}', '{"providerID":"p","id":"m"}')`],
|
|
95
|
+
{ timeout: SQLITE_TIMEOUT_MS },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// The payload is stored verbatim: that is the point. It reached sqlite as a
|
|
99
|
+
// data value, nothing executed, and the marker file was never created.
|
|
100
|
+
const rows = execFileSync("sqlite3", [dbPath, "SELECT count(*) FROM session"], {
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
timeout: SQLITE_TIMEOUT_MS,
|
|
103
|
+
}).trim()
|
|
104
|
+
expect(rows).toBe("1")
|
|
105
|
+
expect(fs.existsSync(marker)).toBe(false)
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|