@boyingliu01/xp-gate 0.35.18 → 0.35.20
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/adapters/gate-3.sh +94 -9
- package/adapters/gate-4.sh +27 -32
- package/adapters/gate-8.sh +32 -3
- package/adapters/gate-9.sh +14 -2
- package/hooks/pre-commit +58 -12
- package/lib/__tests__/doctor-delphi.test.js +114 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
- package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
- package/lib/__tests__/sprint-status-rework.test.js +32 -7
- package/lib/__tests__/test-alignment-java-702.test.ts +182 -0
- package/lib/__tests__/test-alignment.test.ts +6 -2
- package/lib/__tests__/update-skill.test.js +77 -0
- package/lib/doctor-tui.js +23 -2
- package/lib/doctor.js +28 -7
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/sprint-status.js +91 -26
- package/lib/test-alignment.ts +152 -6
- package/lib/update-skill.js +59 -3
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- 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/lib/index.d.ts +24 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +37 -1
- 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/dsh/src/delphi-version-footer.test.ts +42 -0
- package/plugins/dsh/src/index.ts +38 -1
- package/plugins/opencode/package.json +1 -1
- 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/scripts/sync-package-content.js +32 -1
- 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
package/lib/sprint-status.js
CHANGED
|
@@ -221,32 +221,65 @@ function jsonMode(state) {
|
|
|
221
221
|
const FIX_RE = /^(fix(\(.+\))?:)|\b(fix|bugfix|hotfix|patch|修复)\b/i;
|
|
222
222
|
|
|
223
223
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
224
|
+
* Git env with every repo-redirecting variable stripped, so `cwd` is the only
|
|
225
|
+
* thing that decides which repository is inspected.
|
|
226
|
+
*/
|
|
227
|
+
function cleanGitEnv() {
|
|
228
|
+
const gitEnv = { ...process.env };
|
|
229
|
+
for (const name of [
|
|
230
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS',
|
|
231
|
+
'GIT_CONFIG_COUNT', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE',
|
|
232
|
+
'GIT_IMPLICIT_WORK_TREE', 'GIT_GRAFT_FILE', 'GIT_INDEX_FILE',
|
|
233
|
+
'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX',
|
|
234
|
+
'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR',
|
|
235
|
+
]) {
|
|
236
|
+
delete gitEnv[name];
|
|
237
|
+
}
|
|
238
|
+
return gitEnv;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Commit counts for a sprint's own time window: start → completion (#704).
|
|
243
|
+
*
|
|
244
|
+
* The old signature took only a lower bound, and the caller passed
|
|
245
|
+
* `completed_at` — by definition the moment the sprint ENDED. Every sprint
|
|
246
|
+
* commit happened before it, so `git log --since <completed_at>` returned an
|
|
247
|
+
* empty set and the rework rate was structurally 0. The interval that matters
|
|
248
|
+
* is "sprint start → sprint end", so both bounds are now explicit and neither
|
|
249
|
+
* can drift away from the other.
|
|
250
|
+
*
|
|
251
|
+
* Returns BOTH numbers from a single `git log` call: the rate is `fix / total`,
|
|
252
|
+
* and `total` was previously read from `state.metrics.total_sprint_commits`,
|
|
253
|
+
* which nothing ever wrote — it was always 0, then floored to 1 by
|
|
254
|
+
* `Math.max(total, 1)`, which is what turned "no data" into a confident 0%.
|
|
255
|
+
*
|
|
256
|
+
* `total === 0` is a meaningful answer (nothing to measure in the window) and is
|
|
257
|
+
* reported as such rather than being dressed up as 0% rework.
|
|
258
|
+
*
|
|
226
259
|
* @param {string} repoDir - Repository root
|
|
227
|
-
* @param {string} sinceDate - ISO 8601
|
|
228
|
-
* @
|
|
260
|
+
* @param {string|undefined} sinceDate - Sprint start (ISO 8601); omitted = unbounded
|
|
261
|
+
* @param {string|undefined} untilDate - Sprint completion (ISO 8601); omitted = unbounded
|
|
262
|
+
* @returns {{fix: number, total: number}} counts within the window
|
|
229
263
|
*/
|
|
230
|
-
function
|
|
264
|
+
function getWindowCommitCounts(repoDir, sinceDate, untilDate) {
|
|
265
|
+
const empty = { fix: 0, total: 0 };
|
|
266
|
+
if (!sinceDate) return empty;
|
|
231
267
|
try {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
{ cwd: repoDir, env: gitEnv, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
246
|
-
);
|
|
247
|
-
return out.split('\n').filter(l => l.trim() && FIX_RE.test(l.trim())).length;
|
|
268
|
+
const args = ['log', '--all'];
|
|
269
|
+
// --since/--until compare committer dates; missing or unparsable values are
|
|
270
|
+
// ignored by git, which would silently widen the window back to "everything".
|
|
271
|
+
// So only pass a bound that is actually a valid timestamp.
|
|
272
|
+
if (!Number.isNaN(new Date(sinceDate).getTime())) args.push('--since', sinceDate);
|
|
273
|
+
if (untilDate && !Number.isNaN(new Date(untilDate).getTime())) args.push('--until', untilDate);
|
|
274
|
+
args.push('--pretty=format:%s');
|
|
275
|
+
|
|
276
|
+
const out = execFileSync('git', args, {
|
|
277
|
+
cwd: repoDir, env: cleanGitEnv(), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
278
|
+
});
|
|
279
|
+
const subjects = out.split('\n').map(l => l.trim()).filter(Boolean);
|
|
280
|
+
return { fix: subjects.filter(s => FIX_RE.test(s)).length, total: subjects.length };
|
|
248
281
|
} catch {
|
|
249
|
-
return
|
|
282
|
+
return empty;
|
|
250
283
|
}
|
|
251
284
|
}
|
|
252
285
|
|
|
@@ -302,13 +335,37 @@ async function handleReworkCheck(searchDir, windowDays) {
|
|
|
302
335
|
console.log('─'.repeat(60));
|
|
303
336
|
|
|
304
337
|
let hasAlert = false;
|
|
338
|
+
let hasUnmeasurable = false;
|
|
305
339
|
for (const { id, state, file } of inWindow) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
// #704: the window is the sprint itself (start → completion), and both
|
|
341
|
+
// counts come from one `git log` over that window. Previously the total was
|
|
342
|
+
// read from a field nothing wrote, and the `--since` bound was the END of
|
|
343
|
+
// the sprint, so both numbers were structurally 0 and the rate could only
|
|
344
|
+
// ever be 0 — while the summary line still said "within acceptable rate".
|
|
345
|
+
const { fix: fixCommits, total: totalCommits } = getWindowCommitCounts(
|
|
346
|
+
searchDir,
|
|
347
|
+
state.started_at,
|
|
348
|
+
state.metrics.completed_at,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// No commits in the window means the rate is undefined, not zero. Reporting
|
|
352
|
+
// 0.0% here would rebuild the same false reassurance one level down, so the
|
|
353
|
+
// sprint is surfaced as unmeasurable and never counts as "within threshold".
|
|
354
|
+
if (totalCommits === 0) {
|
|
355
|
+
hasUnmeasurable = true;
|
|
356
|
+
console.log(` ${id}: 0 fix / 0 total commits — NOT MEASURABLE (no commits in sprint window)`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const rate = fixCommits / totalCommits;
|
|
309
361
|
|
|
310
|
-
//
|
|
362
|
+
// Persist what was actually measured. The previous code wrote the rate
|
|
363
|
+
// unconditionally, which meant a transposed 0 could overwrite a value a
|
|
364
|
+
// human had already corrected; now the denominator is persisted alongside
|
|
365
|
+
// it so the next run can be checked against its own evidence.
|
|
311
366
|
state.metrics.rework_rate = rate;
|
|
367
|
+
state.metrics.total_sprint_commits = totalCommits;
|
|
368
|
+
state.metrics.fix_sprint_commits = fixCommits;
|
|
312
369
|
try {
|
|
313
370
|
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
314
371
|
} catch { /* non-fatal */ }
|
|
@@ -324,6 +381,10 @@ async function handleReworkCheck(searchDir, windowDays) {
|
|
|
324
381
|
console.log('─'.repeat(60));
|
|
325
382
|
if (hasAlert) {
|
|
326
383
|
console.log('⚠️ Rework rate alert: one or more sprints exceed 30% threshold');
|
|
384
|
+
} else if (hasUnmeasurable) {
|
|
385
|
+
// "All sprints within acceptable rate" would be a claim about sprints that
|
|
386
|
+
// were never measured. Say which is which instead of picking the cheerful one.
|
|
387
|
+
console.log('⚠️ Some sprints could not be measured (no commits in window) — not certified as within threshold');
|
|
327
388
|
} else {
|
|
328
389
|
console.log('All sprints within acceptable rework rate (≤30%)');
|
|
329
390
|
}
|
|
@@ -493,4 +554,8 @@ module.exports = {
|
|
|
493
554
|
jsonMode,
|
|
494
555
|
isStale,
|
|
495
556
|
handleSprintStatus,
|
|
557
|
+
// Exported for #704: the rework-rate arithmetic was only reachable through
|
|
558
|
+
// the CLI, so the only way to test it was to shell out and parse console
|
|
559
|
+
// output. A function nobody can call directly is a function nobody can pin.
|
|
560
|
+
handleReworkCheck,
|
|
496
561
|
};
|
package/lib/test-alignment.ts
CHANGED
|
@@ -73,8 +73,42 @@ export interface TestMap {
|
|
|
73
73
|
tests: TestCase[];
|
|
74
74
|
totalTests: number;
|
|
75
75
|
totalAssertions: number;
|
|
76
|
+
/**
|
|
77
|
+
* Test files whose extension has no parser (#702).
|
|
78
|
+
*
|
|
79
|
+
* These used to be dropped on the floor: the file was read, its extension
|
|
80
|
+
* taken, and then no branch matched. A pure-Java project therefore reported
|
|
81
|
+
* `totalTests: 0` and computed its alignment score over an empty set, with
|
|
82
|
+
* nothing distinguishing "no tests" from "tests I could not read". Callers
|
|
83
|
+
* must surface this instead of treating the map as complete.
|
|
84
|
+
*/
|
|
85
|
+
unsupportedFiles: string[];
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Test-file extensions that this module can parse (#702).
|
|
90
|
+
*
|
|
91
|
+
* Listed explicitly rather than implied by the presence of an `if` branch,
|
|
92
|
+
* because that is exactly how `.java` disappeared: an if/else chain with a
|
|
93
|
+
* missing arm looks identical to a complete one at the call site. This list is
|
|
94
|
+
* the contract the branches are checked against.
|
|
95
|
+
*/
|
|
96
|
+
export const SUPPORTED_TEST_EXTENSIONS: readonly string[] = [
|
|
97
|
+
'.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.java',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Test-file extensions this module deliberately does NOT parse, but recognises
|
|
102
|
+
* as tests (#702).
|
|
103
|
+
*
|
|
104
|
+
* Being on this list means "report it", not "ignore it". The distinction
|
|
105
|
+
* matters: before, both "not a test file" and "a test file I cannot read"
|
|
106
|
+
* produced the same silent skip.
|
|
107
|
+
*/
|
|
108
|
+
export const UNSUPPORTED_TEST_EXTENSIONS: readonly string[] = [
|
|
109
|
+
'.kt', '.kts', '.cs', '.rb', '.rs', '.swift', '.php', '.scala',
|
|
110
|
+
];
|
|
111
|
+
|
|
78
112
|
export type IssueType =
|
|
79
113
|
| 'MISSING_REQUIREMENT_TEST'
|
|
80
114
|
| 'UNCOVERED_ACCEPTANCE_CRITERIA'
|
|
@@ -211,7 +245,13 @@ function extractAllTags(text: string, tag: string): string[] {
|
|
|
211
245
|
}
|
|
212
246
|
|
|
213
247
|
function countAssertions(content: string): number {
|
|
214
|
-
// Count common assertion patterns
|
|
248
|
+
// Count common assertion patterns.
|
|
249
|
+
//
|
|
250
|
+
// #702: the JUnit 5 family was absent, so a parsed Java test would have been
|
|
251
|
+
// recorded as zero-assertion — the same "detector that never fires" shape as
|
|
252
|
+
// the missing branch itself. A test with assertions that reports 0 is worse
|
|
253
|
+
// than no test at all: it drags the coverage dimension down without any hint
|
|
254
|
+
// that the number is unmeasured.
|
|
215
255
|
const patterns = [
|
|
216
256
|
/expect\(/g,
|
|
217
257
|
/assert\./g,
|
|
@@ -220,6 +260,12 @@ function countAssertions(content: string): number {
|
|
|
220
260
|
/expect\./g,
|
|
221
261
|
/toEqual\(/g,
|
|
222
262
|
/toBe\(/g,
|
|
263
|
+
// JUnit 5 / Jupiter assertions (Java, Kotlin). Matched by the assert-prefix
|
|
264
|
+
// family rather than an enumeration of every overload, so assertAll,
|
|
265
|
+
// assertThat, assertThrows, assertDoesNotThrow etc. are all counted.
|
|
266
|
+
/\bassert[A-Z]\w*\s*\(/g,
|
|
267
|
+
// AssertJ fluent style, common in the same test suites.
|
|
268
|
+
/\bassertThat\s*\(/g,
|
|
223
269
|
];
|
|
224
270
|
let count = 0;
|
|
225
271
|
for (const pattern of patterns) {
|
|
@@ -387,6 +433,23 @@ function extractGoTestBody(content: string, funcIndex: number): string {
|
|
|
387
433
|
return extractCallbackBody(content, braceIdx);
|
|
388
434
|
}
|
|
389
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Body of a Java test method, from its opening brace (#702).
|
|
438
|
+
*
|
|
439
|
+
* Assertions are counted against the per-test body, not the whole file, for the
|
|
440
|
+
* same reason as the Go/Python paths: a file-level count multiplies assertions
|
|
441
|
+
* by the number of tests in the file (#392-#1).
|
|
442
|
+
*
|
|
443
|
+
* Java has no multi-line lambda-less ambiguity here — a method body is the
|
|
444
|
+
* brace-delimited block that follows the parameter list — so the same brace
|
|
445
|
+
* scan used for Go is sufficient.
|
|
446
|
+
*/
|
|
447
|
+
function extractJavaTestBody(content: string, methodIndex: number): string {
|
|
448
|
+
const braceIdx = content.indexOf('{', methodIndex);
|
|
449
|
+
if (braceIdx === -1) return '';
|
|
450
|
+
return extractCallbackBody(content, braceIdx);
|
|
451
|
+
}
|
|
452
|
+
|
|
390
453
|
function pushTestWithCovers(
|
|
391
454
|
tests: TestCase[],
|
|
392
455
|
testPath: string,
|
|
@@ -423,6 +486,7 @@ function pushTestWithCovers(
|
|
|
423
486
|
export function parseTestFiles(testPaths: string[]): TestMap {
|
|
424
487
|
const tests: TestCase[] = [];
|
|
425
488
|
const totalAssertionsObj = { value: 0 };
|
|
489
|
+
const unsupportedFiles: string[] = [];
|
|
426
490
|
|
|
427
491
|
for (const testPath of testPaths) {
|
|
428
492
|
if (!fs.existsSync(testPath)) continue;
|
|
@@ -470,6 +534,48 @@ export function parseTestFiles(testPaths: string[]): TestMap {
|
|
|
470
534
|
const body = extractGoTestBody(content, funcStart);
|
|
471
535
|
pushTestWithCovers(tests, testPath, m[3]!, m[2]!, intentMatch?.[1]?.trim(), content, body, totalAssertionsObj);
|
|
472
536
|
}
|
|
537
|
+
} else if (ext === '.java') {
|
|
538
|
+
// #702: JUnit 5 annotations are matched against the Javadoc block that
|
|
539
|
+
// precedes the method, because that is where this project's @test/@intent
|
|
540
|
+
// contract lives.
|
|
541
|
+
//
|
|
542
|
+
// Two phases rather than one regex, because the Javadoc may sit above the
|
|
543
|
+
// class declaration (`class Foo { @Test void a() {...} }`) or directly
|
|
544
|
+
// above the method. Scanning forward from the end of each Javadoc for the
|
|
545
|
+
// first `@Test` and then for the method name handles both without the
|
|
546
|
+
// fragile "skip N tokens" shape that a single expression needs.
|
|
547
|
+
const javaDocPattern = /\/\*\*([\s\S]*?)\*\//g;
|
|
548
|
+
let docMatch: RegExpExecArray | null;
|
|
549
|
+
while ((docMatch = javaDocPattern.exec(content)) !== null) {
|
|
550
|
+
const docBlock = docMatch[1]!;
|
|
551
|
+
const reqMatch = docBlock.match(/@test\s+(REQ-[A-Z0-9]+-[A-Z0-9]+)/);
|
|
552
|
+
// Without a requirement tag this is not an annotated test in this
|
|
553
|
+
// project's sense; skip it rather than inventing a TestCase.
|
|
554
|
+
if (!reqMatch) continue;
|
|
555
|
+
|
|
556
|
+
const afterDoc = docMatch.index + docMatch[0].length;
|
|
557
|
+
const rest = content.slice(afterDoc);
|
|
558
|
+
const testAnnotation = rest.match(/@Test\b/);
|
|
559
|
+
if (!testAnnotation) continue;
|
|
560
|
+
|
|
561
|
+
// Method name = the identifier right before the first `(` after @Test.
|
|
562
|
+
const afterAnnotation = rest.slice(testAnnotation.index! + '@Test'.length);
|
|
563
|
+
const nameMatch = afterAnnotation.match(/\b(\w+)\s*\(/);
|
|
564
|
+
if (!nameMatch) continue;
|
|
565
|
+
|
|
566
|
+
const intentMatch = docBlock.match(/@intent\s+(.+)/);
|
|
567
|
+
const methodStart = afterDoc + testAnnotation.index! + afterAnnotation.indexOf(nameMatch[1]!);
|
|
568
|
+
const body = extractJavaTestBody(content, methodStart);
|
|
569
|
+
pushTestWithCovers(
|
|
570
|
+
tests, testPath, nameMatch[1]!, reqMatch[1]!, intentMatch?.[1]?.trim(),
|
|
571
|
+
content, body, totalAssertionsObj,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
} else if (UNSUPPORTED_TEST_EXTENSIONS.includes(ext)) {
|
|
575
|
+
// Recognised as a test file, but no parser exists. Recording it is the
|
|
576
|
+
// whole point: previously this arm did not exist, so the file vanished
|
|
577
|
+
// and the caller could not tell "no tests" from "tests I could not read".
|
|
578
|
+
unsupportedFiles.push(testPath);
|
|
473
579
|
}
|
|
474
580
|
}
|
|
475
581
|
|
|
@@ -477,6 +583,7 @@ export function parseTestFiles(testPaths: string[]): TestMap {
|
|
|
477
583
|
tests,
|
|
478
584
|
totalTests: tests.length,
|
|
479
585
|
totalAssertions: totalAssertionsObj.value,
|
|
586
|
+
unsupportedFiles,
|
|
480
587
|
};
|
|
481
588
|
}
|
|
482
589
|
|
|
@@ -702,6 +809,31 @@ export function writeReport(report: TestAlignmentReportFile, outputPath: string)
|
|
|
702
809
|
// Orchestrator: runFullAlignment()
|
|
703
810
|
// ============================================================================
|
|
704
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Whether a file name looks like a test file (#702).
|
|
814
|
+
*
|
|
815
|
+
* There were TWO silent gates, and fixing only the parser would have left the
|
|
816
|
+
* outer one closed: this walker previously matched only
|
|
817
|
+
* `\.(test|spec)\.(ts|tsx|js|jsx|py|go)$`. A JUnit file is conventionally
|
|
818
|
+
* `ProbeTest.java` — no `.test.` infix at all — so it was excluded before
|
|
819
|
+
* parseTestFiles ever saw it, and no amount of parser work could help.
|
|
820
|
+
*
|
|
821
|
+
* Two accepted shapes:
|
|
822
|
+
* - the JS/Python convention: `<name>.test.<ext>` / `<name>.spec.<ext>`
|
|
823
|
+
* - the Java/C#/Kotlin convention: `*Test.<ext>` / `*Tests.<ext>` / `*Spec.<ext>`
|
|
824
|
+
*
|
|
825
|
+
* Both are matched against the same extension lists as the parser, so a file
|
|
826
|
+
* can never be collected but unparseable, or parseable but never collected.
|
|
827
|
+
*/
|
|
828
|
+
function isTestFileName(name: string): boolean {
|
|
829
|
+
const ext = path.extname(name);
|
|
830
|
+
const knownExt = SUPPORTED_TEST_EXTENSIONS.includes(ext) || UNSUPPORTED_TEST_EXTENSIONS.includes(ext);
|
|
831
|
+
if (!knownExt) return false;
|
|
832
|
+
if (/\.(test|spec)\.[^.]+$/.test(name)) return true;
|
|
833
|
+
// Java convention: the class name carries the marker, not the file name infix.
|
|
834
|
+
return /(?:Test|Tests|Spec)\.[^.]+$/.test(name);
|
|
835
|
+
}
|
|
836
|
+
|
|
705
837
|
export interface AlignmentOptions {
|
|
706
838
|
specPath: string;
|
|
707
839
|
testDirs?: string[];
|
|
@@ -743,7 +875,7 @@ export function runFullAlignment(options: AlignmentOptions): TestAlignmentReport
|
|
|
743
875
|
const fullPath = path.join(d, entry.name);
|
|
744
876
|
if (entry.isDirectory() && entry.name !== 'node_modules') {
|
|
745
877
|
walkDir(fullPath);
|
|
746
|
-
} else if (entry.isFile() &&
|
|
878
|
+
} else if (entry.isFile() && isTestFileName(entry.name)) {
|
|
747
879
|
testFiles.push(fullPath);
|
|
748
880
|
}
|
|
749
881
|
}
|
|
@@ -759,9 +891,23 @@ export function runFullAlignment(options: AlignmentOptions): TestAlignmentReport
|
|
|
759
891
|
const alignmentReport = verifyAlignment(spec, testMap);
|
|
760
892
|
const score = calculateScore(alignmentReport.issues, alignmentReport.coverage, testMap);
|
|
761
893
|
|
|
894
|
+
// #702: files that were recognised as tests but could not be parsed must not
|
|
895
|
+
// leave the report looking complete. Without this, `totalTests: 0` from a
|
|
896
|
+
// pure-Java project is indistinguishable from "this project has no tests",
|
|
897
|
+
// and the score is computed over an empty set while the status reads "ran".
|
|
898
|
+
const unsupportedErrors = testMap.unsupportedFiles.map(
|
|
899
|
+
file => `Test file not parsed (no parser for ${path.extname(file)}): ${file}`,
|
|
900
|
+
);
|
|
901
|
+
// A project whose ONLY test files are unparsed is the worst case: the report
|
|
902
|
+
// would otherwise be a confident verdict about nothing at all.
|
|
903
|
+
const noParsedTestsAtAll = testMap.totalTests === 0 && testMap.unsupportedFiles.length > 0;
|
|
904
|
+
// Unparsed files make the score untrustworthy even when it happens to be
|
|
905
|
+
// high, so they block PASS rather than merely being annotated.
|
|
906
|
+
const canPass = score >= 80 && testMap.unsupportedFiles.length === 0;
|
|
907
|
+
|
|
762
908
|
const report: TestAlignmentReportFile = {
|
|
763
|
-
alignment_status:
|
|
764
|
-
phase:
|
|
909
|
+
alignment_status: canPass ? 'PASS' : 'FAIL',
|
|
910
|
+
phase: canPass ? 2 : 1,
|
|
765
911
|
score,
|
|
766
912
|
head_commit: headCommit || 'unknown',
|
|
767
913
|
spec_hash: specHash,
|
|
@@ -771,8 +917,8 @@ export function runFullAlignment(options: AlignmentOptions): TestAlignmentReport
|
|
|
771
917
|
spec_requirement: issue.requirementId || 'unknown',
|
|
772
918
|
gap: issue.message,
|
|
773
919
|
})),
|
|
774
|
-
anti_pattern_detected:
|
|
775
|
-
errors:
|
|
920
|
+
anti_pattern_detected: noParsedTestsAtAll,
|
|
921
|
+
errors: unsupportedErrors,
|
|
776
922
|
};
|
|
777
923
|
|
|
778
924
|
// Step 5: Write output. A failure is recorded on `report.errors` by writeReport
|
package/lib/update-skill.js
CHANGED
|
@@ -7,6 +7,57 @@ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
|
7
7
|
|
|
8
8
|
const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The skill names that are actually deployed on this machine.
|
|
12
|
+
*
|
|
13
|
+
* `xp-gate init` / `setup-global` copy skills onto disk but never write
|
|
14
|
+
* `config.installedSkills`, so the config registry is empty on a fully
|
|
15
|
+
* deployed machine. Iterating it made `update-skill --all` print
|
|
16
|
+
* "Updating all skills..." and exit 0 having touched nothing (#682) -- the
|
|
17
|
+
* same defect #635 fixed for `doctor`, with the same fix: ask the filesystem.
|
|
18
|
+
*
|
|
19
|
+
* The explicit registry is still unioned in, so a skill that is merely
|
|
20
|
+
* registered (or was installed on a platform this scan cannot see) keeps
|
|
21
|
+
* working for callers that rely on it.
|
|
22
|
+
*
|
|
23
|
+
* @returns {string[]} de-duplicated skill names, in a stable order
|
|
24
|
+
*/
|
|
25
|
+
function installedSkillNames() {
|
|
26
|
+
const names = new Set();
|
|
27
|
+
const config = getConfig();
|
|
28
|
+
for (const name of Object.keys(config.installedSkills || {})) names.add(name);
|
|
29
|
+
|
|
30
|
+
let platforms = [];
|
|
31
|
+
try {
|
|
32
|
+
platforms = Object.keys(require('./detect-deps.js').PLATFORM_PROFILES);
|
|
33
|
+
} catch {
|
|
34
|
+
// detect-deps unavailable: fall back to the registry only rather than
|
|
35
|
+
// reporting "nothing installed" for a machine that has skills on disk.
|
|
36
|
+
platforms = [];
|
|
37
|
+
}
|
|
38
|
+
const detect = require('./detect-deps.js');
|
|
39
|
+
for (const platform of platforms) {
|
|
40
|
+
let dir;
|
|
41
|
+
try {
|
|
42
|
+
dir = detect.getSkillsDirs(platform)[0];
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
54
|
+
// A skill is `<dir>/<name>/SKILL.md` — the shape install-skill writes.
|
|
55
|
+
if (fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))) names.add(entry.name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...names].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
10
61
|
function handleCheckMode() {
|
|
11
62
|
console.log('Checking for updates...');
|
|
12
63
|
const config = getConfig();
|
|
@@ -19,11 +70,16 @@ function handleCheckMode() {
|
|
|
19
70
|
}
|
|
20
71
|
|
|
21
72
|
async function handleAllMode(verbose) {
|
|
22
|
-
const
|
|
23
|
-
const skills = config.installedSkills || {};
|
|
73
|
+
const names = installedSkillNames();
|
|
24
74
|
console.log('Updating all skills...');
|
|
75
|
+
// #635/#682: an empty run must say so. Exiting 0 after "Updating all skills..."
|
|
76
|
+
// with no further output reads as success, and was how this stayed broken.
|
|
77
|
+
if (names.length === 0) {
|
|
78
|
+
console.log('No skills installed — nothing to update.');
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
25
81
|
let hasErrors = false;
|
|
26
|
-
for (const skillName of
|
|
82
|
+
for (const skillName of names) {
|
|
27
83
|
try {
|
|
28
84
|
await updateSingleSkill(skillName, verbose);
|
|
29
85
|
} catch (err) {
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-02
|
|
4
|
+
**Commit:** 60545e49
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.20.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.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-02
|
|
4
|
+
**Commit:** 60545e49
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.20.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": "xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.20",
|
|
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-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-02
|
|
4
|
+
**Commit:** 60545e49
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.20.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-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-02
|
|
4
|
+
**Commit:** 60545e49
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.20.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-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-02
|
|
4
|
+
**Commit:** 60545e49
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.20.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -33,6 +33,30 @@ export declare const inject: string[];
|
|
|
33
33
|
* let it through, and a false negative would reject a perfectly valid reference.
|
|
34
34
|
*/
|
|
35
35
|
export declare function findPlaceholderName(value: string): string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The running plugin's version, read from its own package.json (#709).
|
|
38
|
+
*
|
|
39
|
+
* Delphi's tool result used to carry no version at all, which made "the fix did
|
|
40
|
+
* not take effect" indistinguishable from "the fix was never made". That cost a
|
|
41
|
+
* real investigation: four design reviews returned verdicts without a single
|
|
42
|
+
* line of expert reasoning, the design was rewritten twice on the assumption it
|
|
43
|
+
* must be wrong, and the same reviews reached 100% consensus on round two once
|
|
44
|
+
* the reasoning was readable. Nothing was wrong with the design.
|
|
45
|
+
*
|
|
46
|
+
* Read rather than injected so dev runs and packed installs report the same
|
|
47
|
+
* thing, and so a stale load is visible as an old number instead of silence.
|
|
48
|
+
* Falls back to "unknown" rather than throwing: a version banner must never be
|
|
49
|
+
* the reason a review fails to start.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pluginVersion(): string;
|
|
52
|
+
/**
|
|
53
|
+
* The provider/seats banner that closes every Delphi tool result (#709).
|
|
54
|
+
*
|
|
55
|
+
* Extracted from the inline template so the version claim has exactly one
|
|
56
|
+
* definition — and so it can be asserted against package.json instead of being
|
|
57
|
+
* pinned to a literal that silently starts lying at the next release.
|
|
58
|
+
*/
|
|
59
|
+
export declare function formatPluginFooter(provider: string, described: string): string;
|
|
36
60
|
/**
|
|
37
61
|
* Runtime schema for the keys above.
|
|
38
62
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,OAAO,CAAC,MAAM,0BAA0B,CAAA;AACxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AA0BlD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,OAAO,CAAC,EAAE,OAAO,MAAM;;;GAA6C,CAAA;AAElG,eAAO,MAAM,IAAI,iBAAiB,CAAA;AAGlC,eAAO,MAAM,MAAM,UAA+B,CAAA;AAkQlD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKrE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAQtC;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE9E;AA8ID;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aA4BjB,CAAA;AAEF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,OAAY,GAAG,IAAI,CAgC9D"}
|
package/plugins/dsh/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
@@ -274,6 +275,41 @@ export function findPlaceholderName(value) {
|
|
|
274
275
|
const envStyle = /\{env:([A-Za-z_][A-Za-z0-9_]*)\}/.exec(value);
|
|
275
276
|
return envStyle ? envStyle[1] : undefined;
|
|
276
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* The running plugin's version, read from its own package.json (#709).
|
|
280
|
+
*
|
|
281
|
+
* Delphi's tool result used to carry no version at all, which made "the fix did
|
|
282
|
+
* not take effect" indistinguishable from "the fix was never made". That cost a
|
|
283
|
+
* real investigation: four design reviews returned verdicts without a single
|
|
284
|
+
* line of expert reasoning, the design was rewritten twice on the assumption it
|
|
285
|
+
* must be wrong, and the same reviews reached 100% consensus on round two once
|
|
286
|
+
* the reasoning was readable. Nothing was wrong with the design.
|
|
287
|
+
*
|
|
288
|
+
* Read rather than injected so dev runs and packed installs report the same
|
|
289
|
+
* thing, and so a stale load is visible as an old number instead of silence.
|
|
290
|
+
* Falls back to "unknown" rather than throwing: a version banner must never be
|
|
291
|
+
* the reason a review fails to start.
|
|
292
|
+
*/
|
|
293
|
+
export function pluginVersion() {
|
|
294
|
+
try {
|
|
295
|
+
const require_ = createRequire(import.meta.url);
|
|
296
|
+
const pkg = require_("../package.json");
|
|
297
|
+
return typeof pkg.version === "string" && pkg.version !== "" ? pkg.version : "unknown";
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return "unknown";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The provider/seats banner that closes every Delphi tool result (#709).
|
|
305
|
+
*
|
|
306
|
+
* Extracted from the inline template so the version claim has exactly one
|
|
307
|
+
* definition — and so it can be asserted against package.json instead of being
|
|
308
|
+
* pinned to a literal that silently starts lying at the next release.
|
|
309
|
+
*/
|
|
310
|
+
export function formatPluginFooter(provider, described) {
|
|
311
|
+
return `Provider: ${provider} | plugin: ${pluginVersion()}\nSeats: ${described}`;
|
|
312
|
+
}
|
|
277
313
|
async function runDelphiReview(subject, request) {
|
|
278
314
|
const { addressed, cwd, resolveApiKey, signal, writeEvidence } = request;
|
|
279
315
|
try {
|
|
@@ -282,7 +318,7 @@ async function runDelphiReview(subject, request) {
|
|
|
282
318
|
const suffix = writeEvidence && cfg !== undefined
|
|
283
319
|
? await writeWalkthroughEvidence(cwd, cfg, outcome)
|
|
284
320
|
: "";
|
|
285
|
-
return `${formatOutcome(outcome)}\n\
|
|
321
|
+
return `${formatOutcome(outcome)}\n\n${formatPluginFooter(provider, described)}${suffix}`;
|
|
286
322
|
}
|
|
287
323
|
catch (e) {
|
|
288
324
|
// Config problems are the common failure; report them as guidance
|
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.20",
|
|
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",
|