@boyingliu01/xp-gate 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-integrity/__tests__/gate-10.test.ts +230 -0
- package/build-integrity/__tests__/import-resolver.test.ts +409 -0
- package/build-integrity/__tests__/orchestrator.test.ts +289 -0
- package/build-integrity/gate-10.ts +641 -0
- package/build-integrity/types.ts +51 -0
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +3 -2
- 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/opencode/__tests__/tui-plugin.test.ts +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/opencode/tui-plugin.ts +0 -19
- 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/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,230 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { runTscCheck, runPackCheck } from '../gate-10';
|
|
6
|
+
|
|
7
|
+
// Increase test timeout for subprocess calls (tsc, npm pack)
|
|
8
|
+
vi.setConfig({ testTimeout: 30000 });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @test Gate 10 runTscCheck
|
|
12
|
+
* @intent Verify tsc --noEmit check behavior for build integrity gate
|
|
13
|
+
* @covers runTscCheck
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
describe('runTscCheck', () => {
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gate10-test-'));
|
|
21
|
+
// Symlink node_modules from project root so tsc is available
|
|
22
|
+
const projectNodeModules = path.join(process.cwd(), 'node_modules');
|
|
23
|
+
const testNodeModules = path.join(tmpDir, 'node_modules');
|
|
24
|
+
try {
|
|
25
|
+
await fs.symlink(projectNodeModules, testNodeModules, 'dir');
|
|
26
|
+
} catch {
|
|
27
|
+
// Ignore if symlink fails (e.g., already exists)
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns skip when no tsconfig.json exists', async () => {
|
|
36
|
+
const result = await runTscCheck(tmpDir, 30000);
|
|
37
|
+
expect(result.status).toBe('skip');
|
|
38
|
+
expect(result.message).toContain('tsconfig.json');
|
|
39
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns skip when tsc is not available on PATH', async () => {
|
|
43
|
+
// Create tsconfig.json but use a PATH without tsc
|
|
44
|
+
await fs.writeFile(
|
|
45
|
+
path.join(tmpDir, 'tsconfig.json'),
|
|
46
|
+
JSON.stringify({ compilerOptions: { strict: true } })
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Override PATH to exclude node_modules/.bin
|
|
50
|
+
const originalPath = process.env.PATH;
|
|
51
|
+
process.env.PATH = '/usr/bin:/bin';
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await runTscCheck(tmpDir, 30000);
|
|
55
|
+
expect(result.status).toBe('skip');
|
|
56
|
+
expect(result.message).toContain('tsc');
|
|
57
|
+
} finally {
|
|
58
|
+
process.env.PATH = originalPath;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns pass when tsc exits 0', async () => {
|
|
63
|
+
// Create a minimal valid TypeScript project
|
|
64
|
+
await fs.writeFile(
|
|
65
|
+
path.join(tmpDir, 'tsconfig.json'),
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
target: 'ES2020',
|
|
69
|
+
module: 'commonjs',
|
|
70
|
+
strict: true,
|
|
71
|
+
noEmit: true,
|
|
72
|
+
},
|
|
73
|
+
include: ['*.ts'],
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await fs.writeFile(
|
|
78
|
+
path.join(tmpDir, 'valid.ts'),
|
|
79
|
+
'const x: number = 42;\nconsole.log(x);\n'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const result = await runTscCheck(tmpDir, 60000);
|
|
83
|
+
expect(result.status).toBe('pass');
|
|
84
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns fail when tsc exits non-zero', async () => {
|
|
88
|
+
// Create a TypeScript project with type errors
|
|
89
|
+
await fs.writeFile(
|
|
90
|
+
path.join(tmpDir, 'tsconfig.json'),
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
compilerOptions: {
|
|
93
|
+
target: 'ES2020',
|
|
94
|
+
module: 'commonjs',
|
|
95
|
+
strict: true,
|
|
96
|
+
noEmit: true,
|
|
97
|
+
},
|
|
98
|
+
include: ['*.ts'],
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await fs.writeFile(
|
|
103
|
+
path.join(tmpDir, 'invalid.ts'),
|
|
104
|
+
'const x: number = "string";\n'
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const result = await runTscCheck(tmpDir, 60000);
|
|
108
|
+
expect(result.status).toBe('fail');
|
|
109
|
+
expect(result.message).toMatch(/error|Error/);
|
|
110
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns skip when tsc times out', async () => {
|
|
114
|
+
// Create a large TypeScript project that will timeout
|
|
115
|
+
await fs.writeFile(
|
|
116
|
+
path.join(tmpDir, 'tsconfig.json'),
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
compilerOptions: {
|
|
119
|
+
target: 'ES2020',
|
|
120
|
+
module: 'commonjs',
|
|
121
|
+
strict: true,
|
|
122
|
+
noEmit: true,
|
|
123
|
+
},
|
|
124
|
+
include: ['*.ts'],
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Create many files to slow down tsc
|
|
129
|
+
for (let i = 0; i < 100; i++) {
|
|
130
|
+
const content = `
|
|
131
|
+
export const value${i} = ${i};
|
|
132
|
+
export function fn${i}(): number { return ${i}; }
|
|
133
|
+
export interface Interface${i} { prop: number; }
|
|
134
|
+
`;
|
|
135
|
+
await fs.writeFile(path.join(tmpDir, `file${i}.ts`), content);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Very short timeout to force timeout
|
|
139
|
+
const result = await runTscCheck(tmpDir, 500);
|
|
140
|
+
expect(result.status).toBe('skip');
|
|
141
|
+
expect(result.message).toMatch(/timeout|Timeout|TIMEOUT|timed out/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @test Gate 10 runPackCheck
|
|
147
|
+
* @intent Verify npm pack --dry-run check behavior for build integrity gate
|
|
148
|
+
* @covers runPackCheck
|
|
149
|
+
*/
|
|
150
|
+
describe('runPackCheck', () => {
|
|
151
|
+
let tmpDir: string;
|
|
152
|
+
|
|
153
|
+
beforeEach(async () => {
|
|
154
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gate10-pack-'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
afterEach(async () => {
|
|
158
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns skip when no package.json exists', async () => {
|
|
162
|
+
const result = await runPackCheck(tmpDir, 30000);
|
|
163
|
+
expect(result.status).toBe('skip');
|
|
164
|
+
expect(result.message).toContain('package.json');
|
|
165
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns skip when package.json has no files field', async () => {
|
|
169
|
+
await fs.writeFile(
|
|
170
|
+
path.join(tmpDir, 'package.json'),
|
|
171
|
+
JSON.stringify({ name: 'test-pkg', version: '1.0.0' })
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const result = await runPackCheck(tmpDir, 30000);
|
|
175
|
+
expect(result.status).toBe('skip');
|
|
176
|
+
expect(result.message).toContain('files');
|
|
177
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns pass when npm pack produces non-empty file list', async () => {
|
|
181
|
+
await fs.writeFile(
|
|
182
|
+
path.join(tmpDir, 'package.json'),
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
name: 'test-pkg',
|
|
185
|
+
version: '1.0.0',
|
|
186
|
+
files: ['index.js'],
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
await fs.writeFile(path.join(tmpDir, 'index.js'), 'module.exports = {};\n');
|
|
190
|
+
|
|
191
|
+
const result = await runPackCheck(tmpDir, 60000);
|
|
192
|
+
expect(result.status).toBe('pass');
|
|
193
|
+
expect(result.message).toMatch(/\d+ file/);
|
|
194
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('returns fail when npm pack exits non-zero', async () => {
|
|
198
|
+
await fs.writeFile(
|
|
199
|
+
path.join(tmpDir, 'package.json'),
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
name: 'test-pkg',
|
|
202
|
+
version: '1.0.0',
|
|
203
|
+
files: ['index.js'],
|
|
204
|
+
scripts: { prepack: 'exit 1' },
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
await fs.writeFile(path.join(tmpDir, 'index.js'), 'module.exports = {};\n');
|
|
208
|
+
|
|
209
|
+
const result = await runPackCheck(tmpDir, 60000);
|
|
210
|
+
expect(result.status).toBe('fail');
|
|
211
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('returns skip when npm pack times out', async () => {
|
|
215
|
+
await fs.writeFile(
|
|
216
|
+
path.join(tmpDir, 'package.json'),
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
name: 'test-pkg',
|
|
219
|
+
version: '1.0.0',
|
|
220
|
+
files: ['index.js'],
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
await fs.writeFile(path.join(tmpDir, 'index.js'), 'module.exports = {};\n');
|
|
224
|
+
|
|
225
|
+
const result = await runPackCheck(tmpDir, 1);
|
|
226
|
+
expect(result.status).toBe('skip');
|
|
227
|
+
expect(result.message).toMatch(/timeout|Timeout|TIMEOUT/);
|
|
228
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test Gate 10 import resolver — extractImports, resolveImportPath, runImportCheck
|
|
3
|
+
* @intent Verify that relative imports are extracted, resolved, and checked against
|
|
4
|
+
* the project boundary. Catches the original bug where tui-plugin.ts imported
|
|
5
|
+
* ../../src/... which exists in the repo but not in the published npm package.
|
|
6
|
+
* @covers extractImports, resolveImportPath, runImportCheck
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { extractImports, resolveImportPath, runImportCheck } from '../gate-10';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ─── extractImports ────────────────────────────────────────────────────────────
|
|
17
|
+
*/
|
|
18
|
+
describe('extractImports', () => {
|
|
19
|
+
it('extracts named import with single quotes', () => {
|
|
20
|
+
const content = `import { foo } from './bar';`;
|
|
21
|
+
const result = extractImports(content);
|
|
22
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('extracts default import', () => {
|
|
26
|
+
const content = `import foo from './bar';`;
|
|
27
|
+
const result = extractImports(content);
|
|
28
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('extracts namespace import', () => {
|
|
32
|
+
const content = `import * as foo from './bar';`;
|
|
33
|
+
const result = extractImports(content);
|
|
34
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('extracts require() call', () => {
|
|
38
|
+
const content = `const foo = require('./bar');`;
|
|
39
|
+
const result = extractImports(content);
|
|
40
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('extracts dynamic import()', () => {
|
|
44
|
+
const content = `const foo = import('./bar');`;
|
|
45
|
+
const result = extractImports(content);
|
|
46
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('extracts named export from', () => {
|
|
50
|
+
const content = `export { foo } from './bar';`;
|
|
51
|
+
const result = extractImports(content);
|
|
52
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('extracts star export from', () => {
|
|
56
|
+
const content = `export * from './bar';`;
|
|
57
|
+
const result = extractImports(content);
|
|
58
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('extracts multiple imports from multiple lines', () => {
|
|
62
|
+
const content = [
|
|
63
|
+
`import { a } from './a';`,
|
|
64
|
+
`import b from './b';`,
|
|
65
|
+
`const c = require('./c');`,
|
|
66
|
+
`export * from './d';`,
|
|
67
|
+
].join('\n');
|
|
68
|
+
const result = extractImports(content);
|
|
69
|
+
expect(result).toEqual([
|
|
70
|
+
{ path: './a', line: 1 },
|
|
71
|
+
{ path: './b', line: 2 },
|
|
72
|
+
{ path: './c', line: 3 },
|
|
73
|
+
{ path: './d', line: 4 },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('skips bare npm package imports', () => {
|
|
78
|
+
const content = [
|
|
79
|
+
`import { foo } from 'lodash';`,
|
|
80
|
+
`import path from 'path';`,
|
|
81
|
+
`import { bar } from './local';`,
|
|
82
|
+
].join('\n');
|
|
83
|
+
const result = extractImports(content);
|
|
84
|
+
expect(result).toEqual([{ path: './local', line: 3 }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('skips node: protocol imports', () => {
|
|
88
|
+
const content = [
|
|
89
|
+
`import fs from 'node:fs';`,
|
|
90
|
+
`import { readFile } from 'node:fs/promises';`,
|
|
91
|
+
`import { bar } from './local';`,
|
|
92
|
+
].join('\n');
|
|
93
|
+
const result = extractImports(content);
|
|
94
|
+
expect(result).toEqual([{ path: './local', line: 3 }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles double-quoted import paths', () => {
|
|
98
|
+
const content = `import { foo } from "./bar";`;
|
|
99
|
+
const result = extractImports(content);
|
|
100
|
+
expect(result).toEqual([{ path: './bar', line: 1 }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles parent-relative imports', () => {
|
|
104
|
+
const content = `import { foo } from '../parent/bar';`;
|
|
105
|
+
const result = extractImports(content);
|
|
106
|
+
expect(result).toEqual([{ path: '../parent/bar', line: 1 }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('handles deeply nested relative imports', () => {
|
|
110
|
+
const content = `import { foo } from '../../../deep/path';`;
|
|
111
|
+
const result = extractImports(content);
|
|
112
|
+
expect(result).toEqual([{ path: '../../../deep/path', line: 1 }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns empty array for content with no imports', () => {
|
|
116
|
+
const content = `const x = 42;\nconsole.log(x);`;
|
|
117
|
+
const result = extractImports(content);
|
|
118
|
+
expect(result).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles import with .js extension', () => {
|
|
122
|
+
const content = `import { foo } from './bar.js';`;
|
|
123
|
+
const result = extractImports(content);
|
|
124
|
+
expect(result).toEqual([{ path: './bar.js', line: 1 }]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('handles side-effect import', () => {
|
|
128
|
+
const content = `import './side-effect';`;
|
|
129
|
+
const result = extractImports(content);
|
|
130
|
+
expect(result).toEqual([{ path: './side-effect', line: 1 }]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* ─── resolveImportPath ─────────────────────────────────────────────────────────
|
|
136
|
+
*/
|
|
137
|
+
describe('resolveImportPath', () => {
|
|
138
|
+
let tmpDir: string;
|
|
139
|
+
|
|
140
|
+
beforeEach(async () => {
|
|
141
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'import-resolver-test-'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(async () => {
|
|
145
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns null for bare npm package imports', () => {
|
|
149
|
+
const result = resolveImportPath('lodash', '/some/file.ts');
|
|
150
|
+
expect(result).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns null for node: protocol imports', () => {
|
|
154
|
+
const result = resolveImportPath('node:fs', '/some/file.ts');
|
|
155
|
+
expect(result).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('resolves relative import to existing .ts file', async () => {
|
|
159
|
+
const targetFile = path.join(tmpDir, 'bar.ts');
|
|
160
|
+
await fs.writeFile(targetFile, 'export const x = 1;');
|
|
161
|
+
|
|
162
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
163
|
+
const result = resolveImportPath('./bar', fromFile);
|
|
164
|
+
expect(result).toBe(targetFile);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('resolves relative import to existing .tsx file', async () => {
|
|
168
|
+
const targetFile = path.join(tmpDir, 'component.tsx');
|
|
169
|
+
await fs.writeFile(targetFile, 'export const X = () => null;');
|
|
170
|
+
|
|
171
|
+
const fromFile = path.join(tmpDir, 'app.ts');
|
|
172
|
+
const result = resolveImportPath('./component', fromFile);
|
|
173
|
+
expect(result).toBe(targetFile);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('resolves relative import to existing .js file', async () => {
|
|
177
|
+
const targetFile = path.join(tmpDir, 'util.js');
|
|
178
|
+
await fs.writeFile(targetFile, 'module.exports = {};');
|
|
179
|
+
|
|
180
|
+
const fromFile = path.join(tmpDir, 'main.ts');
|
|
181
|
+
const result = resolveImportPath('./util', fromFile);
|
|
182
|
+
expect(result).toBe(targetFile);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('resolves relative import with explicit .js extension to .ts file', async () => {
|
|
186
|
+
// TypeScript allows `import './bar.js'` to resolve to `bar.ts`
|
|
187
|
+
const targetFile = path.join(tmpDir, 'bar.ts');
|
|
188
|
+
await fs.writeFile(targetFile, 'export const x = 1;');
|
|
189
|
+
|
|
190
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
191
|
+
const result = resolveImportPath('./bar.js', fromFile);
|
|
192
|
+
expect(result).toBe(targetFile);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('resolves relative import to index.ts in directory', async () => {
|
|
196
|
+
const subDir = path.join(tmpDir, 'mydir');
|
|
197
|
+
await fs.mkdir(subDir);
|
|
198
|
+
const indexFile = path.join(subDir, 'index.ts');
|
|
199
|
+
await fs.writeFile(indexFile, 'export const x = 1;');
|
|
200
|
+
|
|
201
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
202
|
+
const result = resolveImportPath('./mydir', fromFile);
|
|
203
|
+
expect(result).toBe(indexFile);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('resolves relative import to index.js in directory', async () => {
|
|
207
|
+
const subDir = path.join(tmpDir, 'mydir');
|
|
208
|
+
await fs.mkdir(subDir);
|
|
209
|
+
const indexFile = path.join(subDir, 'index.js');
|
|
210
|
+
await fs.writeFile(indexFile, 'module.exports = {};');
|
|
211
|
+
|
|
212
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
213
|
+
const result = resolveImportPath('./mydir', fromFile);
|
|
214
|
+
expect(result).toBe(indexFile);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('resolves parent-relative import', async () => {
|
|
218
|
+
const subDir = path.join(tmpDir, 'sub');
|
|
219
|
+
await fs.mkdir(subDir);
|
|
220
|
+
const targetFile = path.join(tmpDir, 'bar.ts');
|
|
221
|
+
await fs.writeFile(targetFile, 'export const x = 1;');
|
|
222
|
+
|
|
223
|
+
const fromFile = path.join(subDir, 'foo.ts');
|
|
224
|
+
const result = resolveImportPath('../bar', fromFile);
|
|
225
|
+
expect(result).toBe(targetFile);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('returns null when target file does not exist', () => {
|
|
229
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
230
|
+
const result = resolveImportPath('./nonexistent', fromFile);
|
|
231
|
+
expect(result).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('resolves import with .mjs extension', async () => {
|
|
235
|
+
const targetFile = path.join(tmpDir, 'mod.mjs');
|
|
236
|
+
await fs.writeFile(targetFile, 'export const x = 1;');
|
|
237
|
+
|
|
238
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
239
|
+
const result = resolveImportPath('./mod.mjs', fromFile);
|
|
240
|
+
expect(result).toBe(targetFile);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('resolves import with .cjs extension', async () => {
|
|
244
|
+
const targetFile = path.join(tmpDir, 'mod.cjs');
|
|
245
|
+
await fs.writeFile(targetFile, 'module.exports = {};');
|
|
246
|
+
|
|
247
|
+
const fromFile = path.join(tmpDir, 'foo.ts');
|
|
248
|
+
const result = resolveImportPath('./mod.cjs', fromFile);
|
|
249
|
+
expect(result).toBe(targetFile);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('resolves import with .jsx extension', async () => {
|
|
253
|
+
const targetFile = path.join(tmpDir, 'comp.jsx');
|
|
254
|
+
await fs.writeFile(targetFile, 'export default function() { return null; }');
|
|
255
|
+
|
|
256
|
+
const fromFile = path.join(tmpDir, 'app.ts');
|
|
257
|
+
const result = resolveImportPath('./comp', fromFile);
|
|
258
|
+
expect(result).toBe(targetFile);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* ─── runImportCheck ────────────────────────────────────────────────────────────
|
|
264
|
+
*/
|
|
265
|
+
describe('runImportCheck', () => {
|
|
266
|
+
let tmpDir: string;
|
|
267
|
+
|
|
268
|
+
beforeEach(async () => {
|
|
269
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'import-check-test-'));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
afterEach(async () => {
|
|
273
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('returns pass when all relative imports resolve within project', async () => {
|
|
277
|
+
// Create project structure
|
|
278
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
279
|
+
await fs.mkdir(srcDir);
|
|
280
|
+
|
|
281
|
+
const barFile = path.join(srcDir, 'bar.ts');
|
|
282
|
+
await fs.writeFile(barFile, 'export const x = 1;');
|
|
283
|
+
|
|
284
|
+
const fooFile = path.join(srcDir, 'foo.ts');
|
|
285
|
+
await fs.writeFile(fooFile, `import { x } from './bar';\nconsole.log(x);\n`);
|
|
286
|
+
|
|
287
|
+
const result = await runImportCheck([fooFile], tmpDir, 30000);
|
|
288
|
+
expect(result.status).toBe('pass');
|
|
289
|
+
expect(result.violations).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns fail when import resolves outside project root (original bug)', async () => {
|
|
293
|
+
// Simulate the original bug: a deeply nested file imports a path that
|
|
294
|
+
// resolves outside the project root. From pkg/dist/sub/, the import
|
|
295
|
+
// ../../../../outside escapes tmpDir (the project root).
|
|
296
|
+
const nestedDir = path.join(tmpDir, 'pkg', 'dist', 'sub');
|
|
297
|
+
await fs.mkdir(nestedDir, { recursive: true });
|
|
298
|
+
|
|
299
|
+
const badFile = path.join(nestedDir, 'file.ts');
|
|
300
|
+
await fs.writeFile(
|
|
301
|
+
badFile,
|
|
302
|
+
`import { x } from '../../../../outside';\n`
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const result = await runImportCheck([badFile], tmpDir, 30000);
|
|
306
|
+
expect(result.status).toBe('fail');
|
|
307
|
+
expect(result.violations.length).toBeGreaterThan(0);
|
|
308
|
+
expect(result.violations[0].reason).toMatch(/escapes|outside|boundary/i);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('returns fail when imported file does not exist', async () => {
|
|
312
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
313
|
+
await fs.mkdir(srcDir);
|
|
314
|
+
|
|
315
|
+
const fooFile = path.join(srcDir, 'foo.ts');
|
|
316
|
+
await fs.writeFile(fooFile, `import { x } from './nonexistent';\n`);
|
|
317
|
+
|
|
318
|
+
const result = await runImportCheck([fooFile], tmpDir, 30000);
|
|
319
|
+
expect(result.status).toBe('fail');
|
|
320
|
+
expect(result.violations.length).toBeGreaterThan(0);
|
|
321
|
+
expect(result.violations[0].reason).toMatch(/not found|does not exist|missing/i);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('returns pass when only bare npm imports are used', async () => {
|
|
325
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
326
|
+
await fs.mkdir(srcDir);
|
|
327
|
+
|
|
328
|
+
const fooFile = path.join(srcDir, 'foo.ts');
|
|
329
|
+
await fs.writeFile(
|
|
330
|
+
fooFile,
|
|
331
|
+
`import path from 'path';\nimport fs from 'node:fs';\nimport _ from 'lodash';\n`
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const result = await runImportCheck([fooFile], tmpDir, 30000);
|
|
335
|
+
expect(result.status).toBe('pass');
|
|
336
|
+
expect(result.violations).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('returns pass when no changed files are provided', async () => {
|
|
340
|
+
const result = await runImportCheck([], tmpDir, 30000);
|
|
341
|
+
expect(result.status).toBe('pass');
|
|
342
|
+
expect(result.violations).toEqual([]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('checks multiple changed files', async () => {
|
|
346
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
347
|
+
await fs.mkdir(srcDir);
|
|
348
|
+
|
|
349
|
+
const barFile = path.join(srcDir, 'bar.ts');
|
|
350
|
+
await fs.writeFile(barFile, 'export const x = 1;');
|
|
351
|
+
|
|
352
|
+
const goodFile = path.join(srcDir, 'good.ts');
|
|
353
|
+
await fs.writeFile(goodFile, `import { x } from './bar';\n`);
|
|
354
|
+
|
|
355
|
+
const badFile = path.join(srcDir, 'bad.ts');
|
|
356
|
+
await fs.writeFile(badFile, `import { y } from './missing';\n`);
|
|
357
|
+
|
|
358
|
+
const result = await runImportCheck([goodFile, badFile], tmpDir, 30000);
|
|
359
|
+
expect(result.status).toBe('fail');
|
|
360
|
+
expect(result.violations.length).toBe(1);
|
|
361
|
+
expect(result.violations[0].file).toBe(badFile);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('skips files that do not exist on disk', async () => {
|
|
365
|
+
const result = await runImportCheck(['/nonexistent/file.ts'], tmpDir, 30000);
|
|
366
|
+
expect(result.status).toBe('pass');
|
|
367
|
+
expect(result.violations).toEqual([]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('reports correct line number for violation', async () => {
|
|
371
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
372
|
+
await fs.mkdir(srcDir);
|
|
373
|
+
|
|
374
|
+
const fooFile = path.join(srcDir, 'foo.ts');
|
|
375
|
+
await fs.writeFile(
|
|
376
|
+
fooFile,
|
|
377
|
+
[`const x = 1;`, `const y = 2;`, `import { z } from './missing';`, `console.log(x, y);`].join('\n')
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const result = await runImportCheck([fooFile], tmpDir, 30000);
|
|
381
|
+
expect(result.status).toBe('fail');
|
|
382
|
+
expect(result.violations[0].line).toBe(3);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('handles import with explicit .js extension resolving to .ts', async () => {
|
|
386
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
387
|
+
await fs.mkdir(srcDir);
|
|
388
|
+
|
|
389
|
+
const barFile = path.join(srcDir, 'bar.ts');
|
|
390
|
+
await fs.writeFile(barFile, 'export const x = 1;');
|
|
391
|
+
|
|
392
|
+
const fooFile = path.join(srcDir, 'foo.ts');
|
|
393
|
+
await fs.writeFile(fooFile, `import { x } from './bar.js';\n`);
|
|
394
|
+
|
|
395
|
+
const result = await runImportCheck([fooFile], tmpDir, 30000);
|
|
396
|
+
expect(result.status).toBe('pass');
|
|
397
|
+
expect(result.violations).toEqual([]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('includes durationMs in result', async () => {
|
|
401
|
+
const result = await runImportCheck([], tmpDir, 30000);
|
|
402
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('includes message in result', async () => {
|
|
406
|
+
const result = await runImportCheck([], tmpDir, 30000);
|
|
407
|
+
expect(typeof result.message).toBe('string');
|
|
408
|
+
});
|
|
409
|
+
});
|