@boyingliu01/xp-gate 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gate-13-compiler-strictness.sh +129 -4
- package/gate-3.sh +46 -2
- package/gate-4.sh +28 -4
- package/hooks/gate-13-compiler-strictness.sh +129 -4
- package/hooks/gate-3.sh +46 -2
- package/hooks/gate-4.sh +28 -4
- package/hooks/lib/ccn-ratchet.sh +109 -0
- package/lib/__tests__/doctor-timeout-partial.test.js +79 -0
- package/lib/__tests__/foreign-hooks-global.test.js +125 -0
- package/lib/__tests__/uninstall-global-residue.test.js +160 -0
- package/lib/doctor.js +130 -3
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/uninstall.js +225 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/principles/rules/solid/dip.ts +94 -14
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-629
|
|
3
|
+
* @intent `xp-gate doctor` is non-deterministic on Windows. Against an identical,
|
|
4
|
+
* freshly-installed, fully healthy installation it printed
|
|
5
|
+
* "All checks passed" on one run and "Timed out after 10s — some checks
|
|
6
|
+
* incomplete" on the next two. The timeout is a deliberate safety net
|
|
7
|
+
* (#348, "43s on Windows"); the defect is what happens when it fires.
|
|
8
|
+
*
|
|
9
|
+
* `Promise.race` discarded the entire `diagnoseAsync()` result and
|
|
10
|
+
* substituted a single placeholder check, so everything that had already
|
|
11
|
+
* finished was thrown away. Consequence: doctor can report LESS
|
|
12
|
+
* information the LONGER it runs — the opposite of what a diagnostic
|
|
13
|
+
* should do — and a WARN from the timeout made the skills section vanish
|
|
14
|
+
* entirely, so tooling that scrapes `Skill:.*Up to date` read 0/12.
|
|
15
|
+
*
|
|
16
|
+
* The fix: reach a verdict at the deadline (never hang — #348 stays fixed)
|
|
17
|
+
* but keep whatever finished. A diagnostic that says "these 9 checks
|
|
18
|
+
* passed, 2 did not report in time" is strictly more useful than one that
|
|
19
|
+
* says "timed out".
|
|
20
|
+
*
|
|
21
|
+
* The tests below pin the merge semantics directly rather than waiting on
|
|
22
|
+
* a real 10s timer: the behaviour that matters is "partial results are
|
|
23
|
+
* preserved and reported as partial", not the clock.
|
|
24
|
+
* @covers AC-670-43, AC-670-44, AC-670-45
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// describe/it/expect are vitest globals in this repo's config; requiring them from
|
|
29
|
+
// 'vitest' fails because this file is CommonJS. (Hooks are not needed here.)
|
|
30
|
+
|
|
31
|
+
/** A completed check, shaped like diagnoseAsync()'s output. */
|
|
32
|
+
function check(name, status) {
|
|
33
|
+
return { name, status, detail: `${name} detail` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('doctor keeps completed checks when the global timeout fires (#629)', () => {
|
|
37
|
+
it('AC-670-43: completed checks are merged with the timeout notice, not replaced', () => {
|
|
38
|
+
const { mergeTimedOutDiagnosis } = require('../doctor');
|
|
39
|
+
expect(typeof mergeTimedOutDiagnosis).toBe('function');
|
|
40
|
+
|
|
41
|
+
const completed = {
|
|
42
|
+
checks: [check('Hooks', 'PASS'), check('Adapters', 'PASS'), check('Skills', 'WARN')],
|
|
43
|
+
issues: 1,
|
|
44
|
+
};
|
|
45
|
+
const merged = mergeTimedOutDiagnosis(completed, ['Gate env', 'OpenCode TUI']);
|
|
46
|
+
|
|
47
|
+
// Nothing that finished may be dropped -- that is the whole defect.
|
|
48
|
+
for (const name of ['Hooks', 'Adapters', 'Skills']) {
|
|
49
|
+
expect(merged.checks.map((c) => c.name)).toContain(name);
|
|
50
|
+
}
|
|
51
|
+
// The deadline itself is still reported, and still not a pass.
|
|
52
|
+
const notice = merged.checks.find((c) => c.name === 'Diagnosis');
|
|
53
|
+
expect(notice).toBeDefined();
|
|
54
|
+
expect(notice.status).toBe('WARN');
|
|
55
|
+
// Named individually, so the reader knows which surface is unknown.
|
|
56
|
+
expect(notice.detail).toContain('Gate env');
|
|
57
|
+
expect(notice.detail).toContain('OpenCode TUI');
|
|
58
|
+
expect(merged.timedOut).toBe(true);
|
|
59
|
+
// A partial run is never clean.
|
|
60
|
+
expect(merged.issues).toBeGreaterThanOrEqual(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('AC-670-44: an empty partial result still yields the honest timeout notice', () => {
|
|
64
|
+
const { mergeTimedOutDiagnosis } = require('../doctor');
|
|
65
|
+
const merged = mergeTimedOutDiagnosis({ checks: [], issues: 0 }, ['Gate env']);
|
|
66
|
+
expect(merged.checks).toHaveLength(1);
|
|
67
|
+
expect(merged.checks[0].name).toBe('Diagnosis');
|
|
68
|
+
expect(merged.timedOut).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('AC-670-45: nothing that already reported is ever lost, and a real issue count survives', () => {
|
|
72
|
+
const { mergeTimedOutDiagnosis } = require('../doctor');
|
|
73
|
+
const completed = { checks: [check('Hooks', 'FAIL'), check('Templates', 'PASS')], issues: 4 };
|
|
74
|
+
const merged = mergeTimedOutDiagnosis(completed, []);
|
|
75
|
+
// Pre-existing issues are not reset by the timeout.
|
|
76
|
+
expect(merged.issues).toBeGreaterThanOrEqual(4);
|
|
77
|
+
expect(merged.checks.map((c) => c.name)).toEqual(expect.arrayContaining(['Hooks', 'Templates']));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-664
|
|
3
|
+
* @intent `setup-global` points `core.hooksPath` at `~/.config/xp-gate/hooks`, and
|
|
4
|
+
* that setting is GLOBAL — it governs every repository on the machine. So
|
|
5
|
+
* any third-party tool that writes hooks into `core.hooksPath` writes into
|
|
6
|
+
* a directory xp-gate owns. `git lfs install` does exactly this, and the
|
|
7
|
+
* measured result was four hooks xp-gate never installs appearing there:
|
|
8
|
+
*
|
|
9
|
+
* hook-module-resolution.sh lib/ post-checkout post-commit
|
|
10
|
+
* post-merge pre-commit pre-push
|
|
11
|
+
*
|
|
12
|
+
* Two failures followed:
|
|
13
|
+
*
|
|
14
|
+
* 1. `doctor` only checked pre-commit / pre-push / post-merge. The extra
|
|
15
|
+
* files were neither reported nor explained, so the user had no way to
|
|
16
|
+
* learn that a global install had acquired foreign hooks.
|
|
17
|
+
* 2. `uninstall` did `rmSync(GLOBAL_HOOKS_DIR, {recursive: true})` — the
|
|
18
|
+
* whole tree, including the lfs hooks someone else put there. The
|
|
19
|
+
* file already had `verifyFileOwnership()`; the `type: 'dir'` branch
|
|
20
|
+
* simply did not use it.
|
|
21
|
+
*
|
|
22
|
+
* Deleting another tool's files is the more serious of the two: the user
|
|
23
|
+
* loses a working git-lfs setup and has no idea which command did it. So
|
|
24
|
+
* uninstall must classify before it removes, and SAY what it found.
|
|
25
|
+
* @covers AC-670-40, AC-670-41, AC-670-42
|
|
26
|
+
*/
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
/** Hooks xp-gate itself installs; anything else in the dir is foreign. */
|
|
34
|
+
const XP_GATE_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
|
|
35
|
+
|
|
36
|
+
function freshRequire(relPath) {
|
|
37
|
+
for (const key of Object.keys(require.cache)) {
|
|
38
|
+
if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
|
|
39
|
+
delete require.cache[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return require(relPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A hooks directory shaped like the measured post-`git lfs install` state. */
|
|
46
|
+
function makeHooksDir() {
|
|
47
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-'));
|
|
48
|
+
for (const hook of XP_GATE_HOOKS) {
|
|
49
|
+
fs.writeFileSync(path.join(dir, hook), '#!/bin/sh\n# xp-gate\n');
|
|
50
|
+
}
|
|
51
|
+
// Foreign: two hooks git-lfs installs, plus its support file.
|
|
52
|
+
fs.writeFileSync(path.join(dir, 'post-checkout'), '#!/bin/sh\ngit lfs post-checkout "$@"\n');
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'post-commit'), '#!/bin/sh\ngit lfs post-commit "$@"\n');
|
|
54
|
+
fs.writeFileSync(path.join(dir, 'hook-module-resolution.sh'), '#!/bin/sh\n');
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('foreign hooks in the global hooksPath (#664)', () => {
|
|
59
|
+
it('AC-670-40: the classifier names xp-gate hooks and flags everything else', () => {
|
|
60
|
+
const { classifyHooksDir } = freshRequire('../doctor');
|
|
61
|
+
expect(typeof classifyHooksDir).toBe('function');
|
|
62
|
+
|
|
63
|
+
const dir = makeHooksDir();
|
|
64
|
+
try {
|
|
65
|
+
const result = classifyHooksDir(dir);
|
|
66
|
+
expect(result.owned.sort()).toEqual([...XP_GATE_HOOKS].sort());
|
|
67
|
+
// Every foreign entry is reported, not silently dropped. `foreign` holds
|
|
68
|
+
// { name, source } records, so compare names.
|
|
69
|
+
const foreignNames = result.foreign.map((f) => f.name);
|
|
70
|
+
expect(foreignNames).toContain('post-checkout');
|
|
71
|
+
expect(foreignNames).toContain('post-commit');
|
|
72
|
+
// lib/ is xp-gate's own shared-library directory, not foreign.
|
|
73
|
+
expect(foreignNames).not.toContain('lib');
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('AC-670-41: git-lfs hooks are identified by source, unknown files stay generic', () => {
|
|
80
|
+
const { classifyHooksDir } = freshRequire('../doctor');
|
|
81
|
+
const dir = makeHooksDir();
|
|
82
|
+
try {
|
|
83
|
+
const result = classifyHooksDir(dir);
|
|
84
|
+
// Naming the likely source is what turns a confusing listing into an
|
|
85
|
+
// actionable message: "git lfs" tells the user which tool to look at.
|
|
86
|
+
const lfs = result.foreign.filter((f) => f.source === 'git-lfs').map((f) => f.name);
|
|
87
|
+
expect(lfs).toContain('post-checkout');
|
|
88
|
+
expect(lfs).toContain('post-commit');
|
|
89
|
+
|
|
90
|
+
// A file with no recognizable signature must still be reported, but without
|
|
91
|
+
// guessing a source it cannot prove.
|
|
92
|
+
fs.writeFileSync(path.join(dir, 'post-rebase'), '#!/bin/sh\necho mine\n');
|
|
93
|
+
const again = classifyHooksDir(dir);
|
|
94
|
+
const unknown = again.foreign.find((f) => f.name === 'post-rebase');
|
|
95
|
+
expect(unknown).toBeDefined();
|
|
96
|
+
expect(unknown.source).not.toBe('git-lfs');
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('AC-670-42: uninstall refuses to delete a hooks dir holding foreign files', () => {
|
|
103
|
+
const { planHooksDirRemoval } = freshRequire('../uninstall');
|
|
104
|
+
expect(typeof planHooksDirRemoval).toBe('function');
|
|
105
|
+
|
|
106
|
+
const dir = makeHooksDir();
|
|
107
|
+
try {
|
|
108
|
+
const plan = planHooksDirRemoval(dir);
|
|
109
|
+
// Recursive removal would take git-lfs's files with it, so it must not be
|
|
110
|
+
// the chosen action while foreign content is present.
|
|
111
|
+
expect(plan.action).not.toBe('remove-all');
|
|
112
|
+
expect(plan.foreign.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
// A directory holding ONLY xp-gate files is safe to remove wholesale.
|
|
115
|
+
const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-clean-'));
|
|
116
|
+
for (const hook of XP_GATE_HOOKS) {
|
|
117
|
+
fs.writeFileSync(path.join(clean, hook), '#!/bin/sh\n# xp-gate\n');
|
|
118
|
+
}
|
|
119
|
+
expect(planHooksDirRemoval(clean).action).toBe('remove-all');
|
|
120
|
+
fs.rmSync(clean, { recursive: true, force: true });
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-663
|
|
3
|
+
* @intent `xp-gate uninstall` presents itself as the inverse of `init`, but it only
|
|
4
|
+
* removed `hooks/` + `adapters/` + `core.hooksPath`. After
|
|
5
|
+
* `setup-global` then `uninstall --global --force`, the tool printed
|
|
6
|
+
* "Uninstall complete" while leaving behind ~1000 files:
|
|
7
|
+
*
|
|
8
|
+
* ~/.config/xp-gate/modules/ principles/ mutation/ mock-policy/
|
|
9
|
+
* ~/.config/xp-gate/node_modules/ js-yaml / zod / argparse ...
|
|
10
|
+
* ~/.config/xp-gate/package.json
|
|
11
|
+
* ~/.config/xp-gate/package-lock.json
|
|
12
|
+
* ~/.config/xp-gate/xp-gate.json manifest + installedSkills record
|
|
13
|
+
* ~/.xp-gate/version-cache.json
|
|
14
|
+
*
|
|
15
|
+
* And skills were never in scope at all: 12 skills stayed in
|
|
16
|
+
* ~/.qoder/skills/ and ~/.config/opencode/skills/, which the next `init`
|
|
17
|
+
* then treated as already-installed.
|
|
18
|
+
*
|
|
19
|
+
* The residue is not cosmetic. A leftover `node_modules/` makes the NEXT
|
|
20
|
+
* `setup-global` skip its install ("module runtime deps → ... node_modules"
|
|
21
|
+
* reuses whatever is there), so a stale dependency tree survives an
|
|
22
|
+
* upgrade; and a leftover `xp-gate.json` carries the previous version's
|
|
23
|
+
* `manifest` and `lastInit`, which `doctor` then reports as the CURRENT
|
|
24
|
+
* installation.
|
|
25
|
+
*
|
|
26
|
+
* Two rules the fix must respect, both from the issue:
|
|
27
|
+
* - a user config (`.delphi-config.json`) is user DATA, not residue --
|
|
28
|
+
* keep it, but SAY SO instead of leaving it silently;
|
|
29
|
+
* - never delete a skill directory the user has edited. Only remove one
|
|
30
|
+
* whose content still matches the bundled copy.
|
|
31
|
+
* @covers AC-670-37, AC-670-38, AC-670-39
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
// describe/it/expect/beforeEach/afterEach are vitest globals in this repo's config;
|
|
36
|
+
// requiring them from 'vitest' fails because these tests are CommonJS.
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const os = require('os');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
/** Build a throwaway HOME so the plan is computed against a controlled tree. */
|
|
42
|
+
function makeHome() {
|
|
43
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-uninst-'));
|
|
44
|
+
return home;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reproduce the on-disk shape `xp-gate setup-global` leaves behind, including the
|
|
49
|
+
* parts that were measured as residue in #663.
|
|
50
|
+
*/
|
|
51
|
+
function seedGlobalInstall() {
|
|
52
|
+
const configDir = path.join(process.env.HOME, '.config', 'xp-gate');
|
|
53
|
+
for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
|
|
54
|
+
fs.mkdirSync(path.join(configDir, rel), { recursive: true });
|
|
55
|
+
fs.writeFileSync(path.join(configDir, rel, 'index.js'), 'module.exports = {};\n');
|
|
56
|
+
}
|
|
57
|
+
for (const file of ['package.json', 'package-lock.json', 'xp-gate.json']) {
|
|
58
|
+
fs.writeFileSync(path.join(configDir, file), '{}\n');
|
|
59
|
+
}
|
|
60
|
+
// User data: must survive, and must be reported.
|
|
61
|
+
fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{}\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
describe('uninstall --global plans the whole installation, not just hooks (#663)', () => {
|
|
66
|
+
let home;
|
|
67
|
+
const savedEnv = {};
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
home = makeHome();
|
|
71
|
+
savedEnv.HOME = process.env.HOME;
|
|
72
|
+
savedEnv.USERPROFILE = process.env.USERPROFILE;
|
|
73
|
+
process.env.HOME = home;
|
|
74
|
+
process.env.USERPROFILE = home;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
process.env.HOME = savedEnv.HOME;
|
|
79
|
+
process.env.USERPROFILE = savedEnv.USERPROFILE;
|
|
80
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
|
|
85
|
+
* time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
|
|
86
|
+
*/
|
|
87
|
+
function freshRequire(relPath) {
|
|
88
|
+
for (const key of Object.keys(require.cache)) {
|
|
89
|
+
if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
|
|
90
|
+
delete require.cache[key];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return require(relPath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it('AC-670-37: modules, node_modules and the manifest files are all in scope', () => {
|
|
97
|
+
const { buildPlan } = freshRequire('../uninstall');
|
|
98
|
+
expect(typeof buildPlan).toBe('function');
|
|
99
|
+
|
|
100
|
+
// buildPlan only schedules what actually exists, so the fixture has to look
|
|
101
|
+
// like a real `setup-global` install -- otherwise this asserts nothing.
|
|
102
|
+
seedGlobalInstall();
|
|
103
|
+
|
|
104
|
+
const plan = buildPlan('global');
|
|
105
|
+
const paths = plan.filter((step) => step.path).map((step) => step.path.split(path.sep).join('/'));
|
|
106
|
+
|
|
107
|
+
// Each of these was measured residue. A plan that omits any of them reports
|
|
108
|
+
// "complete" over an installation that is still there.
|
|
109
|
+
for (const required of ['modules', 'node_modules', 'package.json', 'package-lock.json', 'xp-gate.json']) {
|
|
110
|
+
expect(
|
|
111
|
+
paths.some((p) => p.endsWith(required)),
|
|
112
|
+
`${required} is missing from the uninstall plan (measured residue, #663)`,
|
|
113
|
+
).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('AC-670-38: user config is retained and reported, never silently kept', () => {
|
|
118
|
+
const { buildPlan } = freshRequire('../uninstall');
|
|
119
|
+
seedGlobalInstall();
|
|
120
|
+
const plan = buildPlan('global');
|
|
121
|
+
// Deletion steps only: a RETAIN step names the file precisely because it is
|
|
122
|
+
// being kept, so including it here would assert the opposite of the intent.
|
|
123
|
+
const deletedPaths = plan
|
|
124
|
+
.filter((step) => step.path && step.retain !== true && step.type !== 'retain')
|
|
125
|
+
.map((step) => step.path.split(path.sep).join('/'));
|
|
126
|
+
|
|
127
|
+
// `.delphi-config.json` holds the user's expert/model choice -- it is data, so
|
|
128
|
+
// it must appear in no deletion step. (The --purge branch is a separate,
|
|
129
|
+
// explicitly destructive path and is not what this plan describes.)
|
|
130
|
+
expect(deletedPaths.some((p) => p.endsWith('.delphi-config.json'))).toBe(false);
|
|
131
|
+
// Not deleting it is only half the fix: the summary has to name it.
|
|
132
|
+
const retained = plan.filter((step) => step.retain === true);
|
|
133
|
+
expect(retained.length, 'no step declares a retention, so the summary cannot report one').toBeGreaterThan(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('AC-670-39: an edited skill directory is never scheduled for deletion', () => {
|
|
137
|
+
const { planSkillRemovals } = freshRequire('../uninstall');
|
|
138
|
+
expect(typeof planSkillRemovals).toBe('function');
|
|
139
|
+
|
|
140
|
+
const bundled = path.join(home, 'bundle', 'skills');
|
|
141
|
+
const installedRoot = path.join(home, '.qoder', 'skills');
|
|
142
|
+
for (const [dir, body] of [
|
|
143
|
+
[path.join(bundled, 'clean-skill'), 'bundled body\n'],
|
|
144
|
+
[path.join(installedRoot, 'clean-skill'), 'bundled body\n'],
|
|
145
|
+
[path.join(bundled, 'edited-skill'), 'bundled body\n'],
|
|
146
|
+
[path.join(installedRoot, 'edited-skill'), 'bundled body\n// user edit\n'],
|
|
147
|
+
]) {
|
|
148
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), body);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const removals = planSkillRemovals(['clean-skill', 'edited-skill'], installedRoot, bundled);
|
|
153
|
+
const names = removals.map((r) => path.basename(r.path));
|
|
154
|
+
|
|
155
|
+
// Untouched -> remove. This is the residue the issue is about.
|
|
156
|
+
expect(names).toContain('clean-skill');
|
|
157
|
+
// Edited -> keep. Deleting a user's modifications is worse than residue.
|
|
158
|
+
expect(names).not.toContain('edited-skill');
|
|
159
|
+
});
|
|
160
|
+
});
|
package/lib/doctor.js
CHANGED
|
@@ -80,6 +80,38 @@ const EXEC_TIMEOUT_MS = 1500;
|
|
|
80
80
|
*/
|
|
81
81
|
const GLOBAL_DIAGNOSIS_TIMEOUT_MS = 10000;
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Merge a timed-out partial diagnosis into one honest verdict (#629).
|
|
85
|
+
*
|
|
86
|
+
* `doctor`'s global timeout (#348) exists so the command never hangs, but the
|
|
87
|
+
* original handler threw away the entire `diagnoseAsync()` result and substituted
|
|
88
|
+
* a single placeholder check. Against an identical healthy install that made the
|
|
89
|
+
* SAME command print "All checks passed" or "Timed out after 10s" depending on
|
|
90
|
+
* machine load, and a timed-out run reported 0 installed skills — doctor giving
|
|
91
|
+
* LESS information the LONGER it runs is the opposite of a diagnostic.
|
|
92
|
+
*
|
|
93
|
+
* Keeps every completed check, adds ONE Diagnosis notice naming what did not
|
|
94
|
+
* report, and never lets a partial run read as clean (#495).
|
|
95
|
+
*
|
|
96
|
+
* @param {{checks?: Array, issues?: number}} partial checks finished before the deadline
|
|
97
|
+
* @param {string[]} pending names of surfaces that did not report in time
|
|
98
|
+
*/
|
|
99
|
+
function mergeTimedOutDiagnosis(partial, pending) {
|
|
100
|
+
const completed = (partial && Array.isArray(partial.checks)) ? partial.checks : [];
|
|
101
|
+
const inherited = (partial && Number.isFinite(partial.issues)) ? partial.issues : 0;
|
|
102
|
+
const pendingNames = Array.isArray(pending) ? pending.filter(Boolean) : [];
|
|
103
|
+
const detail = pendingNames.length > 0
|
|
104
|
+
? `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — did not report in time: ${pendingNames.join(', ')}`
|
|
105
|
+
: `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — some checks incomplete`;
|
|
106
|
+
return {
|
|
107
|
+
checks: [...completed, { name: 'Diagnosis', status: 'WARN', detail }],
|
|
108
|
+
// A partial run is never clean, and a real issue count is never reset.
|
|
109
|
+
issues: Math.max(inherited, 1),
|
|
110
|
+
timedOut: true,
|
|
111
|
+
completedCheckCount: completed.length,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/**
|
|
84
116
|
* Fixed attribution text for the WorkBuddy-desktop sandbox restriction (#525).
|
|
85
117
|
*
|
|
@@ -416,6 +448,75 @@ function pathsEquivalent(a, b) {
|
|
|
416
448
|
*/
|
|
417
449
|
const DRIFT_COMPARED_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
|
|
418
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Classify the contents of an installed hooks directory (#664).
|
|
453
|
+
*
|
|
454
|
+
* `setup-global` sets `core.hooksPath` GLOBALLY, so this directory governs every
|
|
455
|
+
* repository on the machine — and any tool that installs hooks by asking git where
|
|
456
|
+
* hooks live writes into it. `git lfs install` is the one that actually does this;
|
|
457
|
+
* the measured result was four entries xp-gate never installs appearing beside its
|
|
458
|
+
* own hooks. Nothing reported them, so the user had no way to learn their global
|
|
459
|
+
* install had acquired foreign hooks.
|
|
460
|
+
*
|
|
461
|
+
* Returns:
|
|
462
|
+
* owned — hook names xp-gate installs (the drift-compared set)
|
|
463
|
+
* support — xp-gate's own non-hook support entries (lib/, generated scripts)
|
|
464
|
+
* foreign — [{ name, source }] for everything else; `source` is 'git-lfs' when
|
|
465
|
+
* the file's content proves it, otherwise null. Guessing a source we
|
|
466
|
+
* cannot prove would send the user to the wrong tool.
|
|
467
|
+
*/
|
|
468
|
+
function classifyHooksDir(hooksDir) {
|
|
469
|
+
const result = { owned: [], support: [], foreign: [] };
|
|
470
|
+
let entries;
|
|
471
|
+
try {
|
|
472
|
+
entries = fs.readdirSync(hooksDir, { withFileTypes: true });
|
|
473
|
+
} catch {
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
const name = entry.name;
|
|
479
|
+
if (entry.isDirectory()) {
|
|
480
|
+
// lib/ is xp-gate's shared-library directory; any other subdirectory belongs
|
|
481
|
+
// to somebody else and is reported as foreign for the same reason a file is.
|
|
482
|
+
if (name === 'lib') {
|
|
483
|
+
result.support.push(name);
|
|
484
|
+
} else {
|
|
485
|
+
result.foreign.push({ name, source: null });
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (DRIFT_COMPARED_HOOKS.includes(name)) {
|
|
490
|
+
result.owned.push(name);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (name.endsWith('.sh')) {
|
|
494
|
+
// Generated support scripts shipped beside the hooks (#505).
|
|
495
|
+
result.support.push(name);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
result.foreign.push({ name, source: gitLfsHookSource(path.join(hooksDir, name)) });
|
|
499
|
+
}
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 'git-lfs' when the file is a git-lfs hook, else null.
|
|
505
|
+
*
|
|
506
|
+
* Detected from content rather than filename: git-lfs installs several hook names
|
|
507
|
+
* and which ones depends on its version, so a name list would silently miss newer
|
|
508
|
+
* ones. The `git lfs <hook>` invocation is the signature.
|
|
509
|
+
*/
|
|
510
|
+
function gitLfsHookSource(filePath) {
|
|
511
|
+
let body;
|
|
512
|
+
try {
|
|
513
|
+
body = fs.readFileSync(filePath, 'utf8');
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return /\bgit\s+lfs\b/.test(body) ? 'git-lfs' : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
419
520
|
/**
|
|
420
521
|
* Non-hook files that must sit beside the installed hooks.
|
|
421
522
|
*
|
|
@@ -1536,9 +1637,17 @@ async function checkAdapters(checks, mode, gitDirPromise) {
|
|
|
1536
1637
|
* D) Env + CLI tools — fully independent, run IN PARALLEL
|
|
1537
1638
|
* E) TUI + Skills — independent, run IN PARALLEL
|
|
1538
1639
|
*/
|
|
1539
|
-
async function diagnoseAsync(refreshEnv = false) {
|
|
1640
|
+
async function diagnoseAsync(refreshEnv = false, progress = null) {
|
|
1641
|
+
// #629: `progress` is a live view of the checks completed so far. The global
|
|
1642
|
+
// timeout used to discard everything diagnoseAsync had already produced; with a
|
|
1643
|
+
// shared sink, a deadline can report "these finished, those did not" instead of
|
|
1644
|
+
// replacing the whole diagnosis with a placeholder.
|
|
1540
1645
|
const checks = [];
|
|
1541
1646
|
let issues = 0;
|
|
1647
|
+
if (progress) {
|
|
1648
|
+
progress.checks = checks;
|
|
1649
|
+
progress.getIssues = () => issues;
|
|
1650
|
+
}
|
|
1542
1651
|
|
|
1543
1652
|
// Spawn capability probe FIRST (sync, no config dependency) — #525: it must
|
|
1544
1653
|
// report even when the config early-exits below, and its WARN (environmental,
|
|
@@ -2800,10 +2909,26 @@ async function doctor(args) {
|
|
|
2800
2909
|
|
|
2801
2910
|
// Global timeout wrapper — ensures doctor completes within GLOBAL_DIAGNOSIS_TIMEOUT_MS
|
|
2802
2911
|
// even if network/subprocess checks stack up (fix #348: 43s on Windows).
|
|
2803
|
-
|
|
2912
|
+
// #629: give diagnoseAsync a live sink so a deadline can keep what finished.
|
|
2913
|
+
// Without it, `Promise.race` discarded the entire diagnosis -- so the SAME
|
|
2914
|
+
// healthy install reported different results run to run, and a timed-out run
|
|
2915
|
+
// lost the skills section entirely (scrapers read 0/12 installed skills).
|
|
2916
|
+
const progress = { checks: [], getIssues: () => 0 };
|
|
2917
|
+
const diagnosisPromise = diagnoseAsync(refreshEnv, progress);
|
|
2804
2918
|
let diagnosisTimer = null;
|
|
2805
2919
|
const timeoutPromise = new Promise((resolve) => {
|
|
2806
|
-
diagnosisTimer = setTimeout(() =>
|
|
2920
|
+
diagnosisTimer = setTimeout(() => {
|
|
2921
|
+
const completed = Array.isArray(progress.checks) ? progress.checks : [];
|
|
2922
|
+
const reported = new Set(completed.map((c) => c && c.name));
|
|
2923
|
+
// Name the surfaces that did NOT report, so the reader knows exactly which
|
|
2924
|
+
// conclusions are missing rather than just "something timed out".
|
|
2925
|
+
const pending = ['Hooks', 'Adapters', 'Skills', 'Gate env', 'OpenCode TUI']
|
|
2926
|
+
.filter((name) => !reported.has(name));
|
|
2927
|
+
resolve(mergeTimedOutDiagnosis(
|
|
2928
|
+
{ checks: completed, issues: progress.getIssues() },
|
|
2929
|
+
pending,
|
|
2930
|
+
));
|
|
2931
|
+
}, GLOBAL_DIAGNOSIS_TIMEOUT_MS);
|
|
2807
2932
|
});
|
|
2808
2933
|
const { checks, issues: diagnosedIssues, timedOut } = await Promise.race([
|
|
2809
2934
|
diagnosisPromise.then(r => ({ ...r, timedOut: false })),
|
|
@@ -3280,6 +3405,8 @@ module.exports = {
|
|
|
3280
3405
|
readTuiJson,
|
|
3281
3406
|
formatDoctorJson,
|
|
3282
3407
|
diagnoseHookDrift,
|
|
3408
|
+
mergeTimedOutDiagnosis,
|
|
3409
|
+
classifyHooksDir,
|
|
3283
3410
|
syncGlobalHooksFromRepo,
|
|
3284
3411
|
diagnoseModuleDrift,
|
|
3285
3412
|
diagnoseAuditLedger,
|