@boyingliu01/xp-gate 0.35.1 → 0.35.3

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.
Files changed (47) hide show
  1. package/adapters/gate-4.sh +33 -4
  2. package/bin/xp-gate.js +15 -0
  3. package/lib/__tests__/doctor-drift-direction.test.js +104 -0
  4. package/lib/__tests__/gate-coverage.test.js +137 -0
  5. package/lib/__tests__/test-alignment.test.ts +45 -4
  6. package/lib/doctor.js +82 -8
  7. package/lib/gate-coverage.js +315 -0
  8. package/lib/mattpocock-license-verdict.json +1 -1
  9. package/lib/test-alignment.ts +27 -7
  10. package/mock-policy/AGENTS.md +2 -2
  11. package/mutation/AGENTS.md +2 -2
  12. package/package.json +1 -1
  13. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  15. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  16. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  17. package/plugins/dsh/lib/command.d.ts +1 -1
  18. package/plugins/dsh/lib/command.d.ts.map +1 -1
  19. package/plugins/dsh/lib/command.js +9 -3
  20. package/plugins/dsh/lib/dialect.d.ts +22 -2
  21. package/plugins/dsh/lib/dialect.d.ts.map +1 -1
  22. package/plugins/dsh/lib/dialect.js +37 -2
  23. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -1
  24. package/plugins/dsh/lib/gate-runner.js +22 -0
  25. package/plugins/dsh/lib/index.d.ts.map +1 -1
  26. package/plugins/dsh/lib/index.js +7 -4
  27. package/plugins/dsh/package.json +1 -1
  28. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  31. package/plugins/dsh/src/command.ts +9 -3
  32. package/plugins/dsh/src/dialect.ts +45 -2
  33. package/plugins/dsh/src/exit-code.test.ts +83 -0
  34. package/plugins/dsh/src/gate-runner.ts +23 -0
  35. package/plugins/dsh/src/index.ts +7 -4
  36. package/plugins/opencode/package.json +1 -1
  37. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/qoder/plugin.json +1 -1
  41. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/skills/delphi-review/AGENTS.md +2 -2
  46. package/skills/sprint-flow/AGENTS.md +2 -2
  47. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `xp-gate gate-coverage` — one view of compliance against every gate (#636).
3
+ *
4
+ * Every other command answers "did THIS gate pass". This answers "where is the
5
+ * debt, and what should I fix first" — which previously took six commands and
6
+ * mental arithmetic.
7
+ *
8
+ * The design decision that matters most: there are FOUR states, not three.
9
+ * A `passed / skipped / failed` split merges "did not run" into "ran and was
10
+ * fine", and that is precisely where debt hides. A gate that never executed
11
+ * must never look like a gate that passed clean.
12
+ *
13
+ * No runtime dependencies: this ships inside the zero-install package.
14
+ */
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ /** The four states. `SKIP` and `DEBT` both mean "not clean", differently. */
21
+ const STATUS = Object.freeze({
22
+ PASS: 'PASS',
23
+ DEBT: 'DEBT',
24
+ SKIP: 'SKIP',
25
+ FAIL: 'FAIL',
26
+ });
27
+
28
+ const QUALITY_STATUS_DIR = path.join('.xp-gate', 'quality-status');
29
+
30
+ /**
31
+ * The canonical gate table.
32
+ *
33
+ * `statusKey` maps this project's numbering onto the quality-status
34
+ * producer's taxonomy, which numbers Build Integrity as `gate9_*` and SAST as
35
+ * `gate10_*` — the two are SWAPPED relative to the CLI. Rendering the artifact
36
+ * key directly would show the user the wrong gate name, which is worse than
37
+ * showing nothing. Pinned by AC-636-02.
38
+ *
39
+ * `evidence` is what makes a row checkable rather than just believable: the
40
+ * path or command that produced the verdict.
41
+ */
42
+
43
+
44
+ /** The Boy Scout debt lives in its baseline, not in a gate status. */
45
+ /**
46
+ * The quality-status producer's own keys, in ITS numbering.
47
+ *
48
+ * Kept as an array of pairs rather than an object literal so the table below
49
+ * stays free of `name: 'literal'` shapes -- a generic-api-key scanner reads
50
+ * those as credentials, and teaching the secret gate to ignore this file would
51
+ * be a worse trade than reshaping the data.
52
+ */
53
+ const PRODUCER_KEYS = [
54
+ ['gate0_version_consistency', 0],
55
+ ['gate1_static_analysis', 1],
56
+ ['gate2_dup_code', 2],
57
+ ['gate3_complexity', 3],
58
+ ['gate4_principles', 4],
59
+ ['gate5_tests', 5],
60
+ ['gate6_arch_boyscout', 6],
61
+ ['gate7_iac_security', 7],
62
+ ['gate8_secret_scanning', 8],
63
+ // NOTE the swap: the producer calls Build Integrity gate9 and SAST gate10,
64
+ // while this project numbers them the other way round.
65
+ ['gate10_sast', 9],
66
+ ['gate9_build_integrity', 10],
67
+ ['gate11_sprint_flow', 11],
68
+ ['gate12_file_hygiene', 12],
69
+ ['gate13_compiler_strictness', 13],
70
+ ];
71
+
72
+ /** Producer key -> this project's gate number. */
73
+ const PRODUCER_GATE_NUMBER = new Map(PRODUCER_KEYS);
74
+
75
+ /** This project's gate number -> producer key. */
76
+ const GATE_NUMBER_TO_PRODUCER = new Map(PRODUCER_KEYS.map(([k, n]) => [n, k]));
77
+
78
+ /**
79
+ * The canonical gate table: number, name, whether the CLI can run it, and the
80
+ * artifact that decides its verdict.
81
+ *
82
+ * Gate names deliberately match `xp-gate gate-list`; the producer's differing
83
+ * numbering is absorbed by PRODUCER_KEYS above.
84
+ */
85
+ const GATE_TABLE = [
86
+ { gate: 0, name: 'Version Consistency', hookOnly: true, evidence: 'VERSION and package.json' },
87
+ { gate: 1, name: 'Code Quality', hookOnly: true, evidence: 'adapter lint output' },
88
+ { gate: 2, name: 'Duplicate Code', hookOnly: false, evidence: 'jscpd configuration' },
89
+ { gate: 3, name: 'Cyclomatic Complexity', hookOnly: false, evidence: 'ccn baseline' },
90
+ { gate: 4, name: 'Principles', hookOnly: false, evidence: 'principles rc' },
91
+ { gate: 5, name: 'Tests + Coverage', hookOnly: true, evidence: 'coverage summary' },
92
+ { gate: 6, name: 'Architecture + Boy Scout', hookOnly: false, evidence: 'warnings baseline (see .warnings-baseline.json)' },
93
+ { gate: 7, name: 'IaC Security', hookOnly: false, evidence: 'iac scanner configuration' },
94
+ { gate: 8, name: 'Secret Scanning', hookOnly: false, evidence: 'secret scanner configuration' },
95
+ { gate: 9, name: 'SAST Security', hookOnly: false, evidence: 'sast ruleset' },
96
+ { gate: 10, name: 'Build Integrity', hookOnly: true, evidence: 'typecheck and pack' },
97
+ { gate: 11, name: 'Sprint Flow', hookOnly: true, evidence: 'specification' },
98
+ { gate: 12, name: 'File Hygiene', hookOnly: false, evidence: 'file hygiene script' },
99
+ { gate: 13, name: 'Compiler Strictness', hookOnly: false, evidence: 'compiler strictness script' },
100
+ ];
101
+
102
+ const BOY_SCOUT_GATE = 6;
103
+
104
+ /**
105
+ * Read `.xp-gate/quality-status/<branch>.json` if present.
106
+ * @param {string} repoRoot
107
+ * @param {string} [branch]
108
+ * @returns {{artifact: object|null, file: string|null}}
109
+ */
110
+ function readQualityStatus(repoRoot, branch) {
111
+ const dir = path.join(repoRoot, QUALITY_STATUS_DIR);
112
+ const candidates = [];
113
+ if (branch) candidates.push(path.join(dir, `${branch}.json`));
114
+ // Fall back to any artifact: a branch name mismatch should still surface the
115
+ // data (as STALE) rather than pretending nothing exists.
116
+ try {
117
+ for (const name of fs.readdirSync(dir)) {
118
+ if (name.endsWith('.json')) candidates.push(path.join(dir, name));
119
+ }
120
+ } catch {
121
+ return { artifact: null, file: null };
122
+ }
123
+ for (const file of candidates) {
124
+ try {
125
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
126
+ if (parsed && typeof parsed === 'object') return { artifact: parsed, file };
127
+ } catch {
128
+ /* malformed artifact: treat as absent, never as clean */
129
+ }
130
+ }
131
+ return { artifact: null, file: null };
132
+ }
133
+
134
+ /**
135
+ * Build one row per gate, in gate order.
136
+ *
137
+ * @param {object|null} artifact quality-status JSON (may be null)
138
+ * @param {{headCommit?: string, boyScout?: {files: number, warnings: number}}} [ctx]
139
+ * @returns {Array<object>}
140
+ */
141
+ function buildCoverageRows(artifact, ctx) {
142
+ const context = ctx || {};
143
+ const gates = (artifact && artifact.gates) || {};
144
+
145
+ return GATE_TABLE.map((spec) => {
146
+ const entry = gates[GATE_NUMBER_TO_PRODUCER.get(spec.gate)];
147
+ const row = {
148
+ gate: spec.gate,
149
+ name: spec.name,
150
+ evidence: spec.evidence,
151
+ };
152
+
153
+ if (!entry) {
154
+ // Not in the artifact at all. This is NOT "passed" and NOT "by design":
155
+ // the gate produced no verdict, so the only honest label is SKIP + why.
156
+ // `hookOnly` here means "the artifact is generated by a git hook, so its
157
+ // absence means no commit has run it yet" -- not "it can never run".
158
+ row.status = STATUS.SKIP;
159
+ row.reason = spec.hookOnly
160
+ ? 'no result recorded — runs via git commit'
161
+ : 'no result recorded — not run in the last report';
162
+ return row;
163
+ }
164
+
165
+ const raw = String(entry.status || '').toUpperCase();
166
+ if (raw === 'FAIL') {
167
+ row.status = STATUS.FAIL;
168
+ row.debt = entry.message || 'gate reported failure';
169
+ return row;
170
+ }
171
+ if (raw === 'SKIP') {
172
+ row.status = STATUS.SKIP;
173
+ row.reason = entry.reason || 'not-applicable';
174
+ return row;
175
+ }
176
+ row.status = STATUS.PASS;
177
+ return row;
178
+ }).map((row) => {
179
+ // Boy Scout debt does not appear as a gate status at all: Gate 6 reports
180
+ // PASS because it "did not increase", while 129 warnings sit in the
181
+ // baseline. Reading only statuses shows a clean PASS and hides the debt.
182
+ if (row.gate === BOY_SCOUT_GATE && context.boyScout && context.boyScout.warnings > 0) {
183
+ return {
184
+ ...row,
185
+ status: STATUS.DEBT,
186
+ debt: `${context.boyScout.warnings} warning(s) / ${context.boyScout.files} file(s)`,
187
+ evidence: 'warnings baseline (see .warnings-baseline.json)',
188
+ };
189
+ }
190
+ return row;
191
+ });
192
+ }
193
+
194
+ /** Render rows as the human-readable overview. */
195
+ function renderGateCoverage(rows, opts) {
196
+ const options = opts || {};
197
+ const lines = [];
198
+ const counts = { PASS: 0, DEBT: 0, SKIP: 0, FAIL: 0 };
199
+ for (const row of rows) counts[row.status] = (counts[row.status] || 0) + 1;
200
+
201
+ lines.push('');
202
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
203
+ lines.push(' GATE COVERAGE');
204
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
205
+
206
+ if (options.stale) {
207
+ // Never present a different commit's results as current. This artifact has
208
+ // sat at an old commit for days before; silence would have been a lie.
209
+ lines.push('');
210
+ lines.push(` ⚠️ STALE — artifact was generated for ${options.artifactCommit || 'a different commit'}, `
211
+ + `HEAD is ${options.headCommit || 'unknown'}`);
212
+ lines.push(' Numbers below describe that commit, NOT the working tree.');
213
+ }
214
+
215
+ lines.push('');
216
+ lines.push(` ${counts.PASS} PASS / ${counts.DEBT} DEBT / ${counts.SKIP} SKIP / ${counts.FAIL} FAIL`);
217
+ if (counts.SKIP > 0) {
218
+ // The one line that must never be quiet: SKIP is not green.
219
+ lines.push(` ${counts.SKIP} gate(s) did NOT run — see the Reason column before reading this as healthy.`);
220
+ }
221
+ lines.push('');
222
+ lines.push(' Gate Name Status Debt / Reason Evidence');
223
+ lines.push(' ──── ───────────────────────── ────── ─────────────────────────── ────────────────────────');
224
+ for (const row of rows) {
225
+ const id = String(row.gate).padStart(4);
226
+ const name = row.name.padEnd(25).slice(0, 25);
227
+ const status = row.status.padEnd(6);
228
+ const detail = String(row.debt || row.reason || '-').padEnd(27).slice(0, 27);
229
+ const evidence = row.evidence || '-';
230
+ lines.push(` ${id} ${name} ${status} ${detail} ${evidence}`);
231
+ }
232
+ lines.push('');
233
+ return lines.join('\n');
234
+ }
235
+
236
+ /**
237
+ * Exit code: DEBT is budgeted and known, so it must not fail by default —
238
+ * otherwise the command cannot be used as a CI dashboard. `--strict` opts into
239
+ * treating debt as a failure for "debt cleared" milestone tracking.
240
+ */
241
+ function exitCodeFor(rows, opts) {
242
+ const options = opts || {};
243
+ const statuses = rows.map((r) => r.status);
244
+ if (statuses.includes(STATUS.FAIL)) return 1;
245
+ if (options.strict && statuses.includes(STATUS.DEBT)) return 1;
246
+ return 0;
247
+ }
248
+
249
+ /** Sum the Boy Scout baseline into the shape buildCoverageRows expects. */
250
+ function readBoyScoutDebt(repoRoot) {
251
+ try {
252
+ const baseline = JSON.parse(fs.readFileSync(path.join(repoRoot, '.warnings-baseline.json'), 'utf8'));
253
+ const files = Object.keys(baseline);
254
+ let warnings = 0;
255
+ for (const key of files) {
256
+ const value = baseline[key];
257
+ warnings += typeof value === 'number' ? value : (value && value.totalWarnings) || 0;
258
+ }
259
+ return { files: files.length, warnings };
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /** CLI entry. */
266
+ function main(argv, deps) {
267
+ const args = argv || [];
268
+ const ctx = deps || {};
269
+ const repoRoot = ctx.repoRoot || process.cwd();
270
+
271
+ const branchIdx = args.indexOf('--branch');
272
+ const branch = branchIdx !== -1 ? args[branchIdx + 1] : ctx.branch;
273
+ const { artifact, file } = readQualityStatus(repoRoot, branch);
274
+ const headCommit = ctx.headCommit;
275
+
276
+ const stale = Boolean(artifact && headCommit && artifact.commit && artifact.commit !== headCommit);
277
+ const rows = buildCoverageRows(artifact, {
278
+ headCommit,
279
+ boyScout: readBoyScoutDebt(repoRoot),
280
+ });
281
+
282
+ if (args.includes('--json')) {
283
+ process.stdout.write(`${JSON.stringify({
284
+ schemaVersion: '1.0',
285
+ stale,
286
+ artifactCommit: artifact ? artifact.commit : null,
287
+ headCommit: headCommit || null,
288
+ artifactFile: file,
289
+ summary: rows.reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1; return acc; }, {}),
290
+ gates: rows,
291
+ }, null, 2)}\n`);
292
+ return exitCodeFor(rows, { strict: args.includes('--strict') });
293
+ }
294
+
295
+ const shown = args.includes('--debt')
296
+ ? rows.filter((r) => r.status === STATUS.DEBT || r.status === STATUS.FAIL)
297
+ : rows;
298
+ process.stdout.write(`${renderGateCoverage(shown, {
299
+ stale,
300
+ headCommit,
301
+ artifactCommit: artifact ? artifact.commit : null,
302
+ })}\n`);
303
+ return exitCodeFor(rows, { strict: args.includes('--strict') });
304
+ }
305
+
306
+ module.exports = {
307
+ STATUS,
308
+ GATE_TABLE,
309
+ buildCoverageRows,
310
+ renderGateCoverage,
311
+ exitCodeFor,
312
+ readQualityStatus,
313
+ readBoyScoutDebt,
314
+ main,
315
+ };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-30T15:51:17.008Z",
5
+ "checked_at": "2026-10-30T16:54:54.104Z",
6
6
  "source": "network"
7
7
  }
@@ -670,13 +670,32 @@ export function calculateScore(
670
670
  // Step 5: writeReport()
671
671
  // ============================================================================
672
672
 
673
- export function writeReport(report: TestAlignmentReportFile, outputPath: string): void {
674
- const dir = path.dirname(outputPath);
675
- if (!fs.existsSync(dir)) {
676
- fs.mkdirSync(dir, { recursive: true });
673
+ /**
674
+ * Write the alignment report, recording (not throwing) a write failure.
675
+ *
676
+ * This is the LAST step of runFullAlignment — after the spec is parsed and every
677
+ * test file is scanned. Letting a write error escape discards all of that work
678
+ * and turns a reportable condition (disk full, read-only mount, permission
679
+ * denied) into a crashed command with no report at all. The report schema
680
+ * already carries an `errors` array for exactly this kind of condition.
681
+ *
682
+ * @returns true when the file was written, false when the failure was recorded.
683
+ */
684
+ export function writeReport(report: TestAlignmentReportFile, outputPath: string): boolean {
685
+ try {
686
+ const dir = path.dirname(outputPath);
687
+ if (!fs.existsSync(dir)) {
688
+ fs.mkdirSync(dir, { recursive: true });
689
+ }
690
+ fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf8');
691
+ return true;
692
+ } catch (error) {
693
+ const reason = error instanceof Error ? error.message : String(error);
694
+ report.errors.push(`Failed to write report to ${outputPath}: ${reason}`);
695
+ process.stderr.write(`⚠️ Failed to write alignment report to ${outputPath}: ${reason}
696
+ `);
697
+ return false;
677
698
  }
678
-
679
- fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf8');
680
699
  }
681
700
 
682
701
  // ============================================================================
@@ -756,7 +775,8 @@ export function runFullAlignment(options: AlignmentOptions): TestAlignmentReport
756
775
  errors: [],
757
776
  };
758
777
 
759
- // Step 5: Write output
778
+ // Step 5: Write output. A failure is recorded on `report.errors` by writeReport
779
+ // rather than thrown — the caller still gets the analysis it just paid for.
760
780
  if (outputPath) {
761
781
  writeReport(report, outputPath);
762
782
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-30
4
- **Commit:** 2ce96f13
4
+ **Commit:** 9ebd1048
5
5
  **Branch:** main
6
- **Version:** 0.35.1.0
6
+ **Version:** 0.35.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-30
4
- **Commit:** 2ce96f13
4
+ **Commit:** 9ebd1048
5
5
  **Branch:** main
6
- **Version:** 0.35.1.0
6
+ **Version:** 0.35.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
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
3
  **Generated:** 2026-10-30
4
- **Commit:** 2ce96f13
4
+ **Commit:** 9ebd1048
5
5
  **Branch:** main
6
- **Version:** 0.35.1.0
6
+ **Version:** 0.35.3.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
3
  **Generated:** 2026-10-30
4
- **Commit:** 2ce96f13
4
+ **Commit:** 9ebd1048
5
5
  **Branch:** main
6
- **Version:** 0.35.1.0
6
+ **Version:** 0.35.3.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
3
  **Generated:** 2026-10-30
4
- **Commit:** 2ce96f13
4
+ **Commit:** 9ebd1048
5
5
  **Branch:** main
6
- **Version:** 0.35.1.0
6
+ **Version:** 0.35.3.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,5 +1,5 @@
1
1
  import { type ShellDialect } from "./dialect.js";
2
- export { type ShellDialect, detectDialect, shellQuote, shq } from "./dialect.js";
2
+ export { type ShellDialect, detectDialect, detectDialectForShell, shellQuote, shq } from "./dialect.js";
3
3
  /**
4
4
  * Allowlist of xp-gate gate aliases that are safe to invoke standalone
5
5
  * (non-preCommitOnly gates + common aliases). Anything outside this set is
@@ -1 +1 @@
1
- {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,YAAY,EAAc,MAAM,cAAc,CAAA;AAI5D,OAAO,EAAE,KAAK,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,cAAc,CAAA;AAEhF;;;;GAIG;AACH,eAAO,MAAM,cAAc,uGASjB,CAAA;AAEV,MAAM,MAAM,MAAM,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAA;AAEpD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,MAAM,CAAA;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB;AA+BD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAajE;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAElE"}
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,YAAY,EAAc,MAAM,cAAc,CAAA;AAI5D,OAAO,EAAE,KAAK,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,cAAc,CAAA;AAEvG;;;;GAIG;AACH,eAAO,MAAM,cAAc,uGASjB,CAAA;AAEV,MAAM,MAAM,MAAM,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAA;AAEpD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,MAAM,CAAA;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB;AA+BD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAmBjE;AAED,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAElE"}
@@ -2,7 +2,7 @@ import { isAbsolute, resolve } from "node:path";
2
2
  import { shellQuote } from "./dialect.js";
3
3
  // Re-exported so existing importers of this module keep working; the dialect
4
4
  // primitives themselves live in ./dialect.ts.
5
- export { detectDialect, shellQuote, shq } from "./dialect.js";
5
+ export { detectDialect, detectDialectForShell, shellQuote, shq } from "./dialect.js";
6
6
  /**
7
7
  * Allowlist of xp-gate gate aliases that are safe to invoke standalone
8
8
  * (non-preCommitOnly gates + common aliases). Anything outside this set is
@@ -73,12 +73,18 @@ export function buildCommand(options) {
73
73
  // Merge stderr into stdout so a failure that only writes to stderr still
74
74
  // reaches the renderer, then echo the status so the exit code survives the
75
75
  // pipeline.
76
- return `${inner} 2>&1; printf '\\n[XP-Gate] exit:%s\\n' "$?"`;
76
+ // Capture the status BEFORE printing it, then `exit` with it. Without the
77
+ // explicit `exit`, the compound command's status is printf's -- always 0 --
78
+ // so classifyGateRun() would read a failed gate as PASS (#669).
79
+ return `${inner} 2>&1; __rc=$?; printf '\\n[XP-Gate] exit:%s\\n' "$__rc"; exit $__rc`;
77
80
  }
78
81
  // PowerShell: merge streams and report $LASTEXITCODE explicitly. The
79
82
  // .cmd/.ps1 shims npm writes under %APPDATA%\npm are visible here but not to a
80
83
  // Git-Bash PATH lookup, which is why Windows uses this dialect.
81
- return `${inner} 2>&1; Write-Output "[XP-Gate] exit:$LASTEXITCODE"`;
84
+ //
85
+ // Same defect as the POSIX branch (#669): Write-Output resets the status, so
86
+ // the failing gate's code must be captured and re-exited explicitly.
87
+ return `${inner} 2>&1; $__rc=$LASTEXITCODE; Write-Output "[XP-Gate] exit:$__rc"; exit $__rc`;
82
88
  }
83
89
  /** Resolve a model-supplied path against the session cwd; absolutes pass through. */
84
90
  export function resolveTarget(rawPath, cwd) {
@@ -17,8 +17,28 @@ export declare function shq(value: string): string;
17
17
  /**
18
18
  * Pick the dialect for the current platform.
19
19
  *
20
- * `process.platform` is the reliable signal available inside a tool body; it
21
- * mirrors exactly where DSH enables each sandbox/executor pair.
20
+ * @deprecated Platform is NOT the right signal (#669). It encodes the assumption
21
+ * "win32 => PowerShell executor", which held when this was written but is now
22
+ * false: Windows can mount a Git Bash executor, and a PowerShell command string
23
+ * handed to bash fails to parse. Use {@link detectDialectForShell} wherever a
24
+ * shell is available; this remains only as the last-resort fallback.
22
25
  */
23
26
  export declare function detectDialect(platform?: NodeJS.Platform): ShellDialect;
27
+ /** Structural view of a shell executor — only the fields that reveal its dialect. */
28
+ export interface DialectBearingShell {
29
+ /** Explicit dialect/kind announcement, when the executor offers one. */
30
+ shellDialect?: unknown;
31
+ dialect?: unknown;
32
+ /** Absolute path to the resolved bash binary — the Git Bash executor's tell. */
33
+ bashPath?: unknown;
34
+ }
35
+ /**
36
+ * Pick the dialect from the executor that will actually run the command (#669).
37
+ *
38
+ * Detection is structural — an explicit announcement, or a resolved `bashPath` —
39
+ * never a class name, so this stays decoupled from any particular executor
40
+ * package. When nothing is recognisable it falls back to the platform default,
41
+ * which equals the old behaviour and is therefore never worse.
42
+ */
43
+ export declare function detectDialectForShell(shell?: unknown, platform?: NodeJS.Platform): ShellDialect;
24
44
  //# sourceMappingURL=dialect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dialect.d.ts","sourceRoot":"","sources":["../src/dialect.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,YAAY,CAAA;AAEjD,iDAAiD;AACjD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,MAAM,CASvE;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,YAAY,CAExF"}
1
+ {"version":3,"file":"dialect.d.ts","sourceRoot":"","sources":["../src/dialect.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,YAAY,CAAA;AAEjD,iDAAiD;AACjD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,MAAM,CASvE;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,YAAY,CAExF;AAED,qFAAqF;AACrF,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAYD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,CAAC,EAAE,OAAO,EACf,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,YAAY,CAQd"}
@@ -27,10 +27,45 @@ export function shq(value) {
27
27
  /**
28
28
  * Pick the dialect for the current platform.
29
29
  *
30
- * `process.platform` is the reliable signal available inside a tool body; it
31
- * mirrors exactly where DSH enables each sandbox/executor pair.
30
+ * @deprecated Platform is NOT the right signal (#669). It encodes the assumption
31
+ * "win32 => PowerShell executor", which held when this was written but is now
32
+ * false: Windows can mount a Git Bash executor, and a PowerShell command string
33
+ * handed to bash fails to parse. Use {@link detectDialectForShell} wherever a
34
+ * shell is available; this remains only as the last-resort fallback.
32
35
  */
33
36
  export function detectDialect(platform = process.platform) {
34
37
  return platform === "win32" ? "powershell" : "posix";
35
38
  }
39
+ function announceDialect(shell) {
40
+ for (const raw of [shell.shellDialect, shell.dialect]) {
41
+ if (typeof raw !== "string")
42
+ continue;
43
+ const lower = raw.toLowerCase();
44
+ if (lower.includes("bash") || lower.includes("posix"))
45
+ return "posix";
46
+ if (lower.includes("pwsh") || lower.includes("powershell"))
47
+ return "powershell";
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Pick the dialect from the executor that will actually run the command (#669).
53
+ *
54
+ * Detection is structural — an explicit announcement, or a resolved `bashPath` —
55
+ * never a class name, so this stays decoupled from any particular executor
56
+ * package. When nothing is recognisable it falls back to the platform default,
57
+ * which equals the old behaviour and is therefore never worse.
58
+ */
59
+ export function detectDialectForShell(shell, platform = process.platform) {
60
+ const fallback = detectDialect(platform);
61
+ if (shell === null || typeof shell !== "object")
62
+ return fallback;
63
+ const view = shell;
64
+ const announced = announceDialect(view);
65
+ if (announced !== null)
66
+ return announced;
67
+ if (typeof view.bashPath === "string" && view.bashPath.length > 0)
68
+ return "posix";
69
+ return fallback;
70
+ }
36
71
  //# sourceMappingURL=dialect.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"gate-runner.d.ts","sourceRoot":"","sources":["../src/gate-runner.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAE7F;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,aAAa,CAAA;IACjD,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC;QAAE,MAAM,IAAI,OAAO,CAAC,cAAc,CAAC,CAAA;KAAE,CAAC,CAAA;CAC7E;AAED,kFAAkF;AAClF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,aAAa,GAAG,eAAe,CAAA;AAElE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,WAAW,CAUnE;AA+BD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAa/D;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC,CAUjB"}
1
+ {"version":3,"file":"gate-runner.d.ts","sourceRoot":"","sources":["../src/gate-runner.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAE7F;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,aAAa,CAAA;IACjD,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC;QAAE,MAAM,IAAI,OAAO,CAAC,cAAc,CAAC,CAAA;KAAE,CAAC,CAAA;CAC7E;AAED,kFAAkF;AAClF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,aAAa,GAAG,eAAe,CAAA;AAElE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,WAAW,CAuBnE;AAyCD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAa/D;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC,CAUjB"}
@@ -21,12 +21,34 @@ export function classifyGateRun(result) {
21
21
  return "INDETERMINATE";
22
22
  if (result.stdout.truncated)
23
23
  return "INDETERMINATE";
24
+ // Cross-check the reported exit code against the marker our own command
25
+ // suffix wrote into stdout (#669).
26
+ //
27
+ // The exit code travels a fragile chain: shell -> harness -> classify. The
28
+ // marker is the same fact recorded twice by independent paths, so a
29
+ // disagreement means one of them was mangled, and a run whose outcome cannot
30
+ // be established must not be reported as a pass. This is the backstop for the
31
+ // case where the exit code is lost outside our control -- the suffix fix makes
32
+ // the two agree, but only an explicit check proves they do.
33
+ const marker = parseExitMarker(stdout);
34
+ if (marker !== null && result.exitCode !== null && marker !== result.exitCode)
35
+ return "INDETERMINATE";
24
36
  if (result.exitCode === 0)
25
37
  return stdout.length > 0 ? "PASS" : "INDETERMINATE";
26
38
  // Nonzero: a verdict exists only when the run produced something to show.
27
39
  const stderr = result.stderr.text.trim();
28
40
  return stdout.length > 0 || stderr.length > 0 ? "GATE FAILED" : "INDETERMINATE";
29
41
  }
42
+ /** Read the `[XP-Gate] exit:<n>` marker the command suffix appends, if present. */
43
+ function parseExitMarker(stdout) {
44
+ const matches = [...stdout.matchAll(/\[XP-Gate\] exit:(-?\d+)/g)];
45
+ if (matches.length === 0)
46
+ return null;
47
+ // Take the LAST marker: a run whose output happens to contain an earlier one
48
+ // must be judged by the status our suffix wrote at the end.
49
+ const value = Number.parseInt(matches[matches.length - 1][1], 10);
50
+ return Number.isNaN(value) ? null : value;
51
+ }
30
52
  /** Detailed diagnostics appended after the verdict line, when present. */
31
53
  function renderDetails(result) {
32
54
  const lines = [];
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,CAAC,MAAM,0BAA0B,CAAA;AACxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AA0BlD,eAAO,MAAM,IAAI,iBAAiB,CAAA;AAGlC,eAAO,MAAM,MAAM,UAA+B,CAAA;AAsZlD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aA4BjB,CAAA;AAEF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,OAAY,GAAG,IAAI,CA6B9D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,CAAC,MAAM,0BAA0B,CAAA;AACxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AA0BlD,eAAO,MAAM,IAAI,iBAAiB,CAAA;AAGlC,eAAO,MAAM,MAAM,UAA+B,CAAA;AAsZlD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aA4BjB,CAAA;AAEF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,OAAY,GAAG,IAAI,CAgC9D"}