@boyingliu01/xp-gate 0.35.22 → 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/kotlin.sh +53 -8
- package/adapters/python.sh +6 -11
- package/gate-13-compiler-strictness.sh +15 -1
- package/hooks/adapter-common.sh +24 -35
- package/hooks/gate-13-compiler-strictness.sh +15 -1
- package/hooks/pre-commit +137 -9
- 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/scripts/delphi-external-review.cjs +68 -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
|
@@ -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
|
+
});
|