@boyingliu01/xp-gate 0.35.23 → 0.35.24
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/adapter-common.sh +24 -35
- package/adapters/go.sh +6 -11
- package/adapters/python.sh +6 -11
- package/hooks/adapter-common.sh +24 -35
- package/lib/__tests__/doctor-delphi.test.js +3 -3
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
- package/lib/__tests__/install-skill.test.js +107 -20
- package/lib/__tests__/uninstall-global-residue.test.js +230 -9
- package/lib/__tests__/update-skill.test.js +179 -55
- package/lib/install-skill.js +18 -3
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/uninstall.js +47 -10
- package/lib/update-skill.js +96 -26
- 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/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/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/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/adapter-common.sh
CHANGED
|
@@ -104,41 +104,30 @@ detect_project_lang() {
|
|
|
104
104
|
fi
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"static_analysis") run_static_analysis ;;
|
|
132
|
-
"lint") run_lint ;;
|
|
133
|
-
"tests") run_tests ;;
|
|
134
|
-
"coverage") run_coverage ;;
|
|
135
|
-
*) return 1 ;;
|
|
136
|
-
esac
|
|
137
|
-
else
|
|
138
|
-
echo "No adapter found for language: $lang (searched: ~/.config/xp-gate/adapters/, githooks/adapters/)"
|
|
139
|
-
return 1
|
|
140
|
-
fi
|
|
141
|
-
}
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# REMOVED in #688: route_to_adapter()
|
|
109
|
+
#
|
|
110
|
+
# It was a dispatcher with zero production callers. pre-commit sources an
|
|
111
|
+
# adapter and calls `run_static_analysis` / `run_lint` / `run_tests` /
|
|
112
|
+
# `run_coverage` directly; nothing ever went through this function, so its
|
|
113
|
+
# `case "$action"` enumeration was dead too -- including the "add an
|
|
114
|
+
# `architecture` arm and it'll work" assumption in #688, which would have been
|
|
115
|
+
# a no-op.
|
|
116
|
+
#
|
|
117
|
+
# Its two never-invoked dependants are gone with it: `run_architecture()` in
|
|
118
|
+
# adapters/python.sh and adapters/go.sh. Gate 6 already implements both checks
|
|
119
|
+
# INLINE in pre-commit, and does it better than the adapter versions did:
|
|
120
|
+
# * Python: detects 4 config carriers (.import-linter.yml /
|
|
121
|
+
# import_linter_config.yml / .importlinter / pyproject [tool.importlinter]),
|
|
122
|
+
# where the adapter checked 2, and BLOCKs when lint-imports is missing
|
|
123
|
+
# instead of `require_tool ... || return 0` silently passing.
|
|
124
|
+
# * Go: reads the checker's own exit status via PIPESTATUS[0] (#630), where a
|
|
125
|
+
# `arch-go check | head` pipeline would have reported head's status.
|
|
126
|
+
# Wiring the adapter version back in would therefore have been a regression,
|
|
127
|
+
# so the dead code was deleted instead of connected.
|
|
128
|
+
#
|
|
129
|
+
# tests: githooks/__tests__/issue-688-dead-dispatcher-removed.test.bats
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
142
131
|
|
|
143
132
|
check_if_tool_available() {
|
|
144
133
|
local tool_name="$1"
|
package/adapters/go.sh
CHANGED
|
@@ -43,17 +43,12 @@ run_lint() {
|
|
|
43
43
|
return $?
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
run_architecture()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
echo "Running Go architecture checks (arch-go)..."
|
|
54
|
-
arch-go check
|
|
55
|
-
return $?
|
|
56
|
-
}
|
|
46
|
+
# REMOVED in #688: run_architecture()
|
|
47
|
+
# Never invoked -- its only would-be caller, route_to_adapter(), had no callers
|
|
48
|
+
# either. Gate 6 implements arch-go in pre-commit (grep "arch-go"), and reads
|
|
49
|
+
# the checker's own exit status via PIPESTATUS[0] (#630) instead of letting a
|
|
50
|
+
# `| head` pipeline report head's. See githooks/adapter-common.sh for the full
|
|
51
|
+
# rationale.
|
|
57
52
|
|
|
58
53
|
run_tests() {
|
|
59
54
|
require_tool go "Go SDK" || return 1
|
package/adapters/python.sh
CHANGED
|
@@ -180,17 +180,12 @@ run_lint() {
|
|
|
180
180
|
fi
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
run_architecture()
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
echo "Running Python architecture checks (import-linter)..."
|
|
191
|
-
lint-imports
|
|
192
|
-
return $?
|
|
193
|
-
}
|
|
183
|
+
# REMOVED in #688: run_architecture()
|
|
184
|
+
# Never invoked -- its only would-be caller, route_to_adapter(), had no callers
|
|
185
|
+
# either. Gate 6 implements import-linter in pre-commit (grep "import-linter"),
|
|
186
|
+
# and more thoroughly: it detects 4 config carriers instead of the 2 checked
|
|
187
|
+
# here, and BLOCKs on a missing lint-imports rather than `|| return 0` passing.
|
|
188
|
+
# See githooks/adapter-common.sh for the full rationale.
|
|
194
189
|
|
|
195
190
|
run_tests() {
|
|
196
191
|
require_tool pytest "pytest" || return 1
|
package/hooks/adapter-common.sh
CHANGED
|
@@ -104,41 +104,30 @@ detect_project_lang() {
|
|
|
104
104
|
fi
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"static_analysis") run_static_analysis ;;
|
|
132
|
-
"lint") run_lint ;;
|
|
133
|
-
"tests") run_tests ;;
|
|
134
|
-
"coverage") run_coverage ;;
|
|
135
|
-
*) return 1 ;;
|
|
136
|
-
esac
|
|
137
|
-
else
|
|
138
|
-
echo "No adapter found for language: $lang (searched: ~/.config/xp-gate/adapters/, githooks/adapters/)"
|
|
139
|
-
return 1
|
|
140
|
-
fi
|
|
141
|
-
}
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# REMOVED in #688: route_to_adapter()
|
|
109
|
+
#
|
|
110
|
+
# It was a dispatcher with zero production callers. pre-commit sources an
|
|
111
|
+
# adapter and calls `run_static_analysis` / `run_lint` / `run_tests` /
|
|
112
|
+
# `run_coverage` directly; nothing ever went through this function, so its
|
|
113
|
+
# `case "$action"` enumeration was dead too -- including the "add an
|
|
114
|
+
# `architecture` arm and it'll work" assumption in #688, which would have been
|
|
115
|
+
# a no-op.
|
|
116
|
+
#
|
|
117
|
+
# Its two never-invoked dependants are gone with it: `run_architecture()` in
|
|
118
|
+
# adapters/python.sh and adapters/go.sh. Gate 6 already implements both checks
|
|
119
|
+
# INLINE in pre-commit, and does it better than the adapter versions did:
|
|
120
|
+
# * Python: detects 4 config carriers (.import-linter.yml /
|
|
121
|
+
# import_linter_config.yml / .importlinter / pyproject [tool.importlinter]),
|
|
122
|
+
# where the adapter checked 2, and BLOCKs when lint-imports is missing
|
|
123
|
+
# instead of `require_tool ... || return 0` silently passing.
|
|
124
|
+
# * Go: reads the checker's own exit status via PIPESTATUS[0] (#630), where a
|
|
125
|
+
# `arch-go check | head` pipeline would have reported head's status.
|
|
126
|
+
# Wiring the adapter version back in would therefore have been a regression,
|
|
127
|
+
# so the dead code was deleted instead of connected.
|
|
128
|
+
#
|
|
129
|
+
# tests: githooks/__tests__/issue-688-dead-dispatcher-removed.test.bats
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
142
131
|
|
|
143
132
|
check_if_tool_available() {
|
|
144
133
|
local tool_name="$1"
|
|
@@ -403,7 +403,7 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
403
403
|
return { logs, restore: () => spy.mockRestore() };
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
-
it('AC-
|
|
406
|
+
it('AC-721-01: a valid global config with no project copy PASSes instead of failing', async () => {
|
|
407
407
|
const globalPath = writeGlobalConfig();
|
|
408
408
|
const project = path.join(tmpHome, 'a-project-with-no-config');
|
|
409
409
|
fs.mkdirSync(project, { recursive: true });
|
|
@@ -417,7 +417,7 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
417
417
|
expect(cfgLine).toMatch(/global/i);
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
-
it('AC-
|
|
420
|
+
it('AC-721-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
|
|
421
421
|
const project = path.join(tmpHome, 'bare-project');
|
|
422
422
|
fs.mkdirSync(project, { recursive: true });
|
|
423
423
|
const { logs, restore } = captureLogs();
|
|
@@ -436,7 +436,7 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
436
436
|
expect(cfgLine).toMatch(/not found/);
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
-
it('AC-
|
|
439
|
+
it('AC-721-03: a project config still wins over the global one', async () => {
|
|
440
440
|
const globalPath = writeGlobalConfig();
|
|
441
441
|
const project = path.join(tmpHome, 'project-with-own-config');
|
|
442
442
|
fs.mkdirSync(project, { recursive: true });
|
|
@@ -336,7 +336,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
|
|
|
336
336
|
return { issues, check: checks.find((c) => c.name === `Skill: ${name}`) };
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
it('AC-
|
|
339
|
+
it('AC-718-01: same version but a missing chapter is WARN, not "Up to date"', () => {
|
|
340
340
|
const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
|
|
341
341
|
// Keep the frontmatter (version included), drop a large slice of the body.
|
|
342
342
|
const idx = bundled.indexOf('\n---', 4);
|
|
@@ -352,7 +352,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
|
|
|
352
352
|
expect(issues).toBe(1);
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
-
it('AC-
|
|
355
|
+
it('AC-718-02: same version and same content is still "Up to date" (no false alarm)', () => {
|
|
356
356
|
const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
|
|
357
357
|
installSkill('delphi-review', bundled);
|
|
358
358
|
const { issues, check } = runFreshness('delphi-review');
|
|
@@ -360,7 +360,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
|
|
|
360
360
|
expect(issues).toBe(0);
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
-
it('AC-
|
|
363
|
+
it('AC-718-03: line endings alone still do not count as a difference', () => {
|
|
364
364
|
const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
|
|
365
365
|
installSkill('delphi-review', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
|
|
366
366
|
const { issues, check } = runFreshness('delphi-review');
|
|
@@ -17,37 +17,60 @@ function listDirs(root) {
|
|
|
17
17
|
.sort();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Shared fixture for the blocks that need a throwaway HOME and a clean module
|
|
22
|
+
* cache.
|
|
23
|
+
*
|
|
24
|
+
* install-skill reads caches HOME at module load, so every test has to reset
|
|
25
|
+
* four modules and silence three console channels. Two copies of that boilerplate
|
|
26
|
+
* tripped the clone detector, and the two copies had already drifted (one
|
|
27
|
+
* afterEach waited for a pending setImmediate, the other did not) -- which is
|
|
28
|
+
* exactly how a teardown race starts. The stricter teardown wins here.
|
|
29
|
+
*/
|
|
30
|
+
function makeHomeFixture(prefix) {
|
|
31
|
+
const handle = { tmpHome: null, originalHome: null };
|
|
32
|
+
const MODULES = ['../install-skill', '../detect-deps', '../shared-paths', '../rollback'];
|
|
33
|
+
|
|
34
|
+
handle.dropModuleCache = () => {
|
|
35
|
+
for (const mod of MODULES) delete require.cache[require.resolve(mod)];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
handle.beforeEach = () => {
|
|
39
|
+
handle.originalHome = process.env.HOME;
|
|
40
|
+
handle.tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
41
|
+
process.env.HOME = handle.tmpHome;
|
|
27
42
|
vi.resetModules();
|
|
28
|
-
|
|
29
|
-
delete require.cache[require.resolve(mod)];
|
|
30
|
-
}
|
|
43
|
+
handle.dropModuleCache();
|
|
31
44
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
32
45
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
33
46
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
34
|
-
}
|
|
47
|
+
};
|
|
35
48
|
|
|
36
|
-
afterEach
|
|
49
|
+
handle.afterEach = async () => {
|
|
37
50
|
await new Promise((resolve) => setImmediate(resolve));
|
|
38
|
-
process.env.HOME = originalHome;
|
|
51
|
+
process.env.HOME = handle.originalHome;
|
|
39
52
|
vi.restoreAllMocks();
|
|
40
|
-
if (fs.existsSync(tmpHome)) {
|
|
41
|
-
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
53
|
+
if (fs.existsSync(handle.tmpHome)) {
|
|
54
|
+
fs.rmSync(handle.tmpHome, { recursive: true, force: true });
|
|
42
55
|
}
|
|
43
|
-
|
|
56
|
+
handle.dropModuleCache();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return handle;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('install-skill', () => {
|
|
63
|
+
const fx = makeHomeFixture('xpgate-in-');
|
|
64
|
+
|
|
65
|
+
beforeEach(fx.beforeEach);
|
|
66
|
+
afterEach(fx.afterEach);
|
|
44
67
|
|
|
45
68
|
function skillsDir() {
|
|
46
|
-
return path.join(tmpHome, '.config', 'opencode', 'skills');
|
|
69
|
+
return path.join(fx.tmpHome, '.config', 'opencode', 'skills');
|
|
47
70
|
}
|
|
48
71
|
|
|
49
72
|
function configDir() {
|
|
50
|
-
return path.join(tmpHome, '.config', 'xp-gate');
|
|
73
|
+
return path.join(fx.tmpHome, '.config', 'xp-gate');
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
function setupValidDeps() {
|
|
@@ -161,8 +184,13 @@ describe('install-skill', () => {
|
|
|
161
184
|
expect(fs.existsSync(configFile)).toBe(true);
|
|
162
185
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
163
186
|
expect(config.installedSkills).toHaveProperty('delphi-review');
|
|
164
|
-
// Read version dynamically
|
|
165
|
-
|
|
187
|
+
// Read the version dynamically so a bump never breaks this test. The source
|
|
188
|
+
// is package.json, not the repo's VERSION: that file is a release stamp
|
|
189
|
+
// ('0.35.23.0'), is not shipped in the npm package, and resolving it from
|
|
190
|
+
// __dirname only works inside a checkout (#713).
|
|
191
|
+
const expectedVersion = JSON.parse(
|
|
192
|
+
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
|
|
193
|
+
).version;
|
|
166
194
|
expect(config.installedSkills['delphi-review'].version).toBe(expectedVersion);
|
|
167
195
|
expect(config.installedSkills['delphi-review'].installedAt).toMatch(/^\d{4}-/);
|
|
168
196
|
});
|
|
@@ -246,3 +274,62 @@ describe('install-skill', () => {
|
|
|
246
274
|
expect(result).toBe(0);
|
|
247
275
|
});
|
|
248
276
|
});
|
|
277
|
+
|
|
278
|
+
// ── #713: the "unknown skill" hint must not print a fake version ─────────────
|
|
279
|
+
// getCliVersion() resolved `../../../VERSION` relative to __dirname. In an
|
|
280
|
+
// installed package __dirname is
|
|
281
|
+
// `node_modules/@boyingliu01/xp-gate/lib`, so three levels up lands in
|
|
282
|
+
// `node_modules/` -- and the package does not ship a VERSION file at all. The
|
|
283
|
+
// catch therefore fed `0.0.0` to every caller that prints it, so a user seeing
|
|
284
|
+
//
|
|
285
|
+
// xp-gate 0.0.0 bundles: ...
|
|
286
|
+
//
|
|
287
|
+
// reasonably concluded their install was broken. The version has to come from
|
|
288
|
+
// the package.json the CLI is actually running out of.
|
|
289
|
+
describe('install-skill #713 CLI version reporting', () => {
|
|
290
|
+
const fx = makeHomeFixture('xpgate-ver-');
|
|
291
|
+
|
|
292
|
+
beforeEach(fx.beforeEach);
|
|
293
|
+
afterEach(fx.afterEach);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @test REQ-713 Honest version reporting
|
|
297
|
+
* @intent An unknown-skill error must name the real package version, never 0.0.0
|
|
298
|
+
* @covers AC-713-04
|
|
299
|
+
*
|
|
300
|
+
* The repo checkout masks this bug: `<repo>/VERSION` exists, so resolving
|
|
301
|
+
* `../../../VERSION` from `src/npm-package/lib/` lands on it and looks correct.
|
|
302
|
+
* An installed package has no such luck -- npm's `files` list does not include
|
|
303
|
+
* VERSION, and three levels up from `node_modules/@boyingliu01/xp-gate/lib` is
|
|
304
|
+
* `node_modules/`. So the assertion that matters is the one that holds in the
|
|
305
|
+
* shipped layout, which is measured here by demanding the value AGREE with
|
|
306
|
+
* package.json rather than with whatever VERSION happens to sit above the tree.
|
|
307
|
+
*/
|
|
308
|
+
it('AC-713-04: unknown-skill error reports the package version, not 0.0.0 and not repo VERSION', async () => {
|
|
309
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
310
|
+
|
|
311
|
+
// install-skill destructures checkDeps at require time, so a spy on the
|
|
312
|
+
// module object cannot intercept it. Inject a stub into the require cache
|
|
313
|
+
// BEFORE install-skill loads -- the deps gate runs ahead of the name check,
|
|
314
|
+
// and this test measures version reporting, not dependency probing.
|
|
315
|
+
const detectPath = require.resolve('../detect-deps.js');
|
|
316
|
+
require.cache[detectPath] = {
|
|
317
|
+
id: detectPath,
|
|
318
|
+
filename: detectPath,
|
|
319
|
+
loaded: true,
|
|
320
|
+
exports: {
|
|
321
|
+
...require('../detect-deps.js'),
|
|
322
|
+
checkDeps: async () => ({ ok: true }),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const { installSkill } = require('../install-skill');
|
|
327
|
+
const code = await installSkill('definitely-not-a-bundled-skill', { force: true });
|
|
328
|
+
expect(code).toBe(1);
|
|
329
|
+
|
|
330
|
+
const said = console.error.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
331
|
+
expect(said).toMatch(/Unknown skill/);
|
|
332
|
+
expect(said).not.toContain('0.0.0');
|
|
333
|
+
expect(said).toContain(`xp-gate ${pkg.version} bundles:`);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -44,6 +44,21 @@ function makeHome() {
|
|
|
44
44
|
return home;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
|
|
49
|
+
* time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
|
|
50
|
+
* Shared by every describe block here: three copies of this loop tripped the
|
|
51
|
+
* clone detector, and they had to stay byte-identical to be correct.
|
|
52
|
+
*/
|
|
53
|
+
function freshRequire(relPath) {
|
|
54
|
+
for (const key of Object.keys(require.cache)) {
|
|
55
|
+
if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
|
|
56
|
+
delete require.cache[key];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return require(relPath);
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/**
|
|
48
63
|
* Reproduce the on-disk shape `xp-gate setup-global` leaves behind, including the
|
|
49
64
|
* parts that were measured as residue in #663.
|
|
@@ -84,15 +99,6 @@ describe('uninstall --global plans the whole installation, not just hooks (#663)
|
|
|
84
99
|
* Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
|
|
85
100
|
* time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
|
|
86
101
|
*/
|
|
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
102
|
it('AC-670-37: modules, node_modules and the manifest files are all in scope', () => {
|
|
97
103
|
const { buildPlan } = freshRequire('../uninstall');
|
|
98
104
|
expect(typeof buildPlan).toBe('function');
|
|
@@ -158,3 +164,218 @@ describe('uninstall --global plans the whole installation, not just hooks (#663)
|
|
|
158
164
|
expect(names).not.toContain('edited-skill');
|
|
159
165
|
});
|
|
160
166
|
});
|
|
167
|
+
|
|
168
|
+
// -- #714: the residue must be DELETED, not merely scheduled --------------------
|
|
169
|
+
// #663 taught `buildPlan()` to list modules/, node_modules/, package.json,
|
|
170
|
+
// package-lock.json and xp-gate.json, so AC-670-37 passed -- but it only ever
|
|
171
|
+
// asserted those paths appear in the plan. On Windows the run still printed
|
|
172
|
+
//
|
|
173
|
+
// Warning: ~/.config/xp-gate/xp-gate.json does not contain xp-gate signature - skipping
|
|
174
|
+
//
|
|
175
|
+
// for all three files (the two directories were deleted, because a `dir` step
|
|
176
|
+
// recurses without an ownership check). The plan entries carried neither
|
|
177
|
+
// `signature` nor `manifestKey`, so removeFile() reached
|
|
178
|
+
// `content.includes(undefined)`, which is always false. Worst case: the
|
|
179
|
+
// uninstaller had just written `"mode": "uninstalled"` into xp-gate.json and
|
|
180
|
+
// then reported that file as "not xp-gate's".
|
|
181
|
+
//
|
|
182
|
+
// The lesson these tests encode: a residue test that only inspects the PLAN
|
|
183
|
+
// cannot see a broken DELETE. Every assertion below runs the real uninstall.
|
|
184
|
+
describe('uninstall --global actually removes the residue (#714)', () => {
|
|
185
|
+
let home;
|
|
186
|
+
const savedEnv = {};
|
|
187
|
+
const realArgv = process.argv;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
home = makeHome();
|
|
191
|
+
savedEnv.HOME = process.env.HOME;
|
|
192
|
+
savedEnv.USERPROFILE = process.env.USERPROFILE;
|
|
193
|
+
process.env.HOME = home;
|
|
194
|
+
process.env.USERPROFILE = home;
|
|
195
|
+
process.argv = ['node', 'xp-gate', 'uninstall', '--global', '--force'];
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
process.argv = realArgv;
|
|
200
|
+
process.env.HOME = savedEnv.HOME;
|
|
201
|
+
process.env.USERPROFILE = savedEnv.USERPROFILE;
|
|
202
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
function configDir() {
|
|
206
|
+
return path.join(home, '.config', 'xp-gate');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Seed a REAL setup-global layout: the three manifest-ish files carry the
|
|
211
|
+
* content `setup-global` writes, so a signature check has to succeed or fail
|
|
212
|
+
* on realistic input rather than on `{}`.
|
|
213
|
+
*/
|
|
214
|
+
function seedRealisticInstall() {
|
|
215
|
+
for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
|
|
216
|
+
fs.mkdirSync(path.join(configDir(), rel), { recursive: true });
|
|
217
|
+
fs.writeFileSync(path.join(configDir(), rel, 'index.js'), 'module.exports = {};\n');
|
|
218
|
+
}
|
|
219
|
+
fs.writeFileSync(
|
|
220
|
+
path.join(configDir(), 'package.json'),
|
|
221
|
+
JSON.stringify({ dependencies: { 'js-yaml': '^5.2.2', zod: '^4.4.3' } }, null, 2)
|
|
222
|
+
);
|
|
223
|
+
fs.writeFileSync(
|
|
224
|
+
path.join(configDir(), 'package-lock.json'),
|
|
225
|
+
JSON.stringify({ name: 'xp-gate-global', lockfileVersion: 3, packages: {} }, null, 2)
|
|
226
|
+
);
|
|
227
|
+
// `mode: 'global'` is what uninstall() reads to decide there is anything to
|
|
228
|
+
// do; without it the run exits "No xp-gate installation found" and the test
|
|
229
|
+
// would pass while touching nothing.
|
|
230
|
+
fs.writeFileSync(
|
|
231
|
+
path.join(configDir(), 'xp-gate.json'),
|
|
232
|
+
JSON.stringify(
|
|
233
|
+
{
|
|
234
|
+
lastInit: new Date().toISOString(),
|
|
235
|
+
mode: 'global',
|
|
236
|
+
manifest: { files: {} },
|
|
237
|
+
},
|
|
238
|
+
null,
|
|
239
|
+
2
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
fs.writeFileSync(path.join(configDir(), '.delphi-config.json'), '{ "active_profile": "default" }\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @test REQ-717 Uninstall completeness
|
|
247
|
+
* @intent A file step in the residue plan must be deleted, not skipped for lack of a signature
|
|
248
|
+
* @covers AC-717-01
|
|
249
|
+
*/
|
|
250
|
+
it('AC-717-01: package.json, package-lock.json and xp-gate.json are gone after uninstall', async () => {
|
|
251
|
+
seedRealisticInstall();
|
|
252
|
+
const { uninstall } = freshRequire('../uninstall');
|
|
253
|
+
await uninstall(['--global', '--force']);
|
|
254
|
+
|
|
255
|
+
for (const file of ['package.json', 'package-lock.json']) {
|
|
256
|
+
expect(
|
|
257
|
+
fs.existsSync(path.join(configDir(), file)),
|
|
258
|
+
`${file} survived uninstall - it is xp-gate's own file, not user data`
|
|
259
|
+
).toBe(false);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// xp-gate.json is deliberately retained: the state machine rewrites it with
|
|
263
|
+
// `mode: "uninstalled"` so a second `uninstall` is a clean no-op (AC-09).
|
|
264
|
+
// What #714 broke was not the retention but the CLAIM about it -- the run
|
|
265
|
+
// called this file "not xp-gate's" while rewriting it. AC-717-04 pins that.
|
|
266
|
+
const manifestFile = path.join(configDir(), 'xp-gate.json');
|
|
267
|
+
expect(fs.existsSync(manifestFile)).toBe(true);
|
|
268
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
269
|
+
expect(manifest.mode).toBe('uninstalled');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @test REQ-717 Uninstall completeness
|
|
274
|
+
* @intent The two residue directories must go too, so a later setup-global cannot reuse them
|
|
275
|
+
* @covers AC-717-02
|
|
276
|
+
*/
|
|
277
|
+
it('AC-717-02: modules/ and node_modules/ are gone after uninstall', async () => {
|
|
278
|
+
seedRealisticInstall();
|
|
279
|
+
const { uninstall } = freshRequire('../uninstall');
|
|
280
|
+
await uninstall(['--global', '--force']);
|
|
281
|
+
|
|
282
|
+
expect(fs.existsSync(path.join(configDir(), 'modules'))).toBe(false);
|
|
283
|
+
expect(fs.existsSync(path.join(configDir(), 'node_modules'))).toBe(false);
|
|
284
|
+
expect(fs.existsSync(path.join(configDir(), 'adapters'))).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @test REQ-717 Uninstall completeness
|
|
289
|
+
* @intent User data must still survive the now-working deletion path
|
|
290
|
+
* @covers AC-717-03
|
|
291
|
+
*/
|
|
292
|
+
it('AC-717-03: .delphi-config.json survives the completed uninstall', async () => {
|
|
293
|
+
seedRealisticInstall();
|
|
294
|
+
const { uninstall } = freshRequire('../uninstall');
|
|
295
|
+
await uninstall(['--global', '--force']);
|
|
296
|
+
|
|
297
|
+
expect(fs.existsSync(path.join(configDir(), '.delphi-config.json'))).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @test REQ-717 Uninstall completeness
|
|
302
|
+
* @intent The uninstall must not report success while leaving its own residue behind
|
|
303
|
+
* @covers AC-717-04
|
|
304
|
+
*/
|
|
305
|
+
it('AC-717-04: uninstall does not warn that its own residue files are foreign', async () => {
|
|
306
|
+
seedRealisticInstall();
|
|
307
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
308
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
309
|
+
try {
|
|
310
|
+
const { uninstall } = freshRequire('../uninstall');
|
|
311
|
+
await uninstall(['--global', '--force']);
|
|
312
|
+
|
|
313
|
+
const said = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
314
|
+
for (const file of ['package.json', 'package-lock.json', 'xp-gate.json']) {
|
|
315
|
+
expect(
|
|
316
|
+
said.includes(`${file} does not contain xp-gate signature`),
|
|
317
|
+
`uninstall disowned ${file}: ${said}`
|
|
318
|
+
).toBe(false);
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
warnSpy.mockRestore();
|
|
322
|
+
logSpy.mockRestore();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('uninstall hooks directory ownership (#714)', () => {
|
|
328
|
+
let home;
|
|
329
|
+
const savedEnv = {};
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
home = makeHome();
|
|
333
|
+
savedEnv.HOME = process.env.HOME;
|
|
334
|
+
savedEnv.USERPROFILE = process.env.USERPROFILE;
|
|
335
|
+
process.env.HOME = home;
|
|
336
|
+
process.env.USERPROFILE = home;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
process.env.HOME = savedEnv.HOME;
|
|
341
|
+
process.env.USERPROFILE = savedEnv.USERPROFILE;
|
|
342
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @test REQ-717 Hook ownership
|
|
347
|
+
* @intent post-commit is installed by copyHooks(), so it must count as xp-gate's own
|
|
348
|
+
* @covers AC-717-05
|
|
349
|
+
*/
|
|
350
|
+
it('AC-717-05: post-commit is classified as xp-gate-owned, not foreign', () => {
|
|
351
|
+
const { planHooksDirRemoval } = freshRequire('../uninstall');
|
|
352
|
+
const hooksDir = path.join(process.env.HOME, '.config', 'xp-gate', 'hooks');
|
|
353
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
354
|
+
// copyHooks() installs exactly these; post-commit was the one it forgot.
|
|
355
|
+
for (const f of ['pre-commit', 'pre-push', 'post-merge', 'post-commit', 'hook-module-resolution.sh']) {
|
|
356
|
+
fs.writeFileSync(path.join(hooksDir, f), '#!/bin/sh\n');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const result = planHooksDirRemoval(hooksDir);
|
|
360
|
+
const foreignNames = (result.foreign || []).map((f) => f.name);
|
|
361
|
+
expect(foreignNames).not.toContain('post-commit');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @test REQ-717 Hook ownership
|
|
366
|
+
* @intent A genuinely foreign hook (git-lfs) must still be protected
|
|
367
|
+
* @covers AC-717-06
|
|
368
|
+
*/
|
|
369
|
+
it('AC-717-06: a third-party hook is still classified as foreign and kept', () => {
|
|
370
|
+
const { planHooksDirRemoval } = freshRequire('../uninstall');
|
|
371
|
+
const hooksDir = path.join(process.env.HOME, '.config', 'xp-gate', 'hooks');
|
|
372
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
373
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n');
|
|
374
|
+
fs.writeFileSync(path.join(hooksDir, 'post-checkout'), '#!/bin/sh\n# git-lfs\n');
|
|
375
|
+
|
|
376
|
+
const result = planHooksDirRemoval(hooksDir);
|
|
377
|
+
const foreignNames = (result.foreign || []).map((f) => f.name);
|
|
378
|
+
expect(foreignNames).toContain('post-checkout');
|
|
379
|
+
expect(result.action).toBe('remove-owned-only');
|
|
380
|
+
});
|
|
381
|
+
});
|