@boyingliu01/xp-gate 0.27.5 → 0.28.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/adapters/gate-9.sh +13 -2
- package/build-integrity/gate-10.ts +290 -263
- package/gate-10.sh +25 -6
- package/gate-12-file-hygiene.sh +35 -1
- package/gate-3.sh +78 -2
- package/gate-9.sh +13 -2
- package/hooks/gate-10.sh +25 -6
- package/hooks/gate-12-file-hygiene.sh +35 -1
- package/hooks/gate-3.sh +78 -2
- package/hooks/gate-9.sh +13 -2
- package/hooks/lib/audit-sanitize.sh +51 -0
- package/hooks/lib/ccn-ratchet.sh +183 -0
- package/hooks/lib/docs-only.sh +49 -0
- package/hooks/pre-commit +74 -15
- package/hooks/pre-push +19 -4
- package/lib/gate-audit.ts +9 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +27 -6
- 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/principles/__tests__/many-exports-reexport.test.ts +40 -0
- package/principles/adapters/typescript.ts +61 -30
- package/principles/boy-scout.ts +22 -17
- package/scripts/delphi-external-review.cjs +195 -132
- 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
|
@@ -45,136 +45,155 @@ function isSupportedModuleFile(file: string): boolean {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
49
|
-
* Runs `tsc --noEmit --incremental` to verify the project compiles without errors.
|
|
48
|
+
* True when `filePath` exists and is readable - the tsc usability test.
|
|
50
49
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* Declared as a const arrow on purpose. lizard 1.21.3 mis-nests a
|
|
51
|
+
* `function name(...): T {` declaration that carries a return type annotation
|
|
52
|
+
* when it appears before another JSDoc block: the parse loses the boundary and
|
|
53
|
+
* every following function is absorbed into this function's span (measured on
|
|
54
|
+
* this file - the CCN of five functions collapsed into one row). An arrow
|
|
55
|
+
* assignment keeps the scope flat and lizard reports each function separately.
|
|
54
56
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
timeoutMs: number
|
|
58
|
-
): Promise<CheckResult> {
|
|
59
|
-
const startTime = Date.now();
|
|
60
|
-
|
|
61
|
-
// Check if tsconfig.json exists
|
|
62
|
-
const tsconfigPath = path.join(projectRoot, 'tsconfig.json');
|
|
57
|
+
const isReadableFile = (filePath: string): boolean => {
|
|
58
|
+
let readable = true;
|
|
63
59
|
try {
|
|
64
|
-
|
|
60
|
+
fsNative.accessSync(filePath);
|
|
65
61
|
} catch {
|
|
66
|
-
|
|
67
|
-
status: 'skip',
|
|
68
|
-
message: 'No tsconfig.json found in project root',
|
|
69
|
-
durationMs: Date.now() - startTime,
|
|
70
|
-
};
|
|
62
|
+
readable = false;
|
|
71
63
|
}
|
|
64
|
+
return readable;
|
|
65
|
+
};
|
|
72
66
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the tsc entry point. Three strategies, most specific first: the
|
|
69
|
+
* project's own node_modules, then the cwd's, then Node's resolver. Returns
|
|
70
|
+
* the tsc SCRIPT path (run it through process.execPath) or null, in which case
|
|
71
|
+
* the caller falls back to the npx shim.
|
|
72
|
+
*/
|
|
73
|
+
const findTscBinary = (projectRoot: string): string | null => {
|
|
74
|
+
const candidates = [
|
|
75
|
+
path.join(projectRoot, 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
76
|
+
path.join(process.cwd(), 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
77
|
+
];
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
if (isReadableFile(candidate)) return candidate;
|
|
80
|
+
}
|
|
81
|
+
let resolved: string | undefined;
|
|
82
|
+
try {
|
|
83
|
+
resolved = require.resolve('typescript/bin/tsc', { paths: [projectRoot, process.cwd()] });
|
|
84
|
+
} catch {
|
|
85
|
+
return null; // Not resolvable at all -> caller uses the npx shim.
|
|
86
|
+
}
|
|
87
|
+
// Resolvable but not readable also means the caller must use the npx shim.
|
|
88
|
+
return isReadableFile(resolved) ? resolved : null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function tscProbeInvocation(projectRoot: string, resolvedTsc: string | null) {
|
|
92
|
+
return resolvedTsc
|
|
93
|
+
? { cmd: process.execPath, args: [resolvedTsc, '--version'], opts: { cwd: projectRoot, timeout: 10000 } }
|
|
94
|
+
: {
|
|
95
|
+
cmd: 'npx',
|
|
96
|
+
args: ['tsc', '--version'],
|
|
97
|
+
opts: { cwd: projectRoot, timeout: 10000, shell: true, env: { ...process.env, PATH: process.env.PATH } },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
function tscCompileInvocation(projectRoot: string, resolvedTsc: string | null, timeoutMs: number) {
|
|
102
|
+
return {
|
|
103
|
+
cmd: resolvedTsc ? process.execPath : 'npx',
|
|
104
|
+
args: resolvedTsc
|
|
105
|
+
? [resolvedTsc, '--noEmit', '--incremental']
|
|
106
|
+
: ['tsc', '--noEmit', '--incremental'],
|
|
107
|
+
opts: {
|
|
108
|
+
cwd: projectRoot,
|
|
109
|
+
timeout: timeoutMs,
|
|
110
|
+
shell: !resolvedTsc,
|
|
111
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
82
115
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
interface ExecFailure {
|
|
117
|
+
code?: number | null;
|
|
118
|
+
signal?: string;
|
|
119
|
+
stdout?: string;
|
|
120
|
+
stderr?: string;
|
|
121
|
+
killed?: boolean;
|
|
122
|
+
message?: string;
|
|
123
|
+
}
|
|
88
124
|
|
|
89
|
-
|
|
90
|
-
|
|
125
|
+
function isTimeoutFailure(err: ExecFailure): boolean {
|
|
126
|
+
return Boolean(err.killed || err.signal === 'SIGTERM' || err.code === null);
|
|
127
|
+
}
|
|
91
128
|
|
|
92
|
-
|
|
129
|
+
function compileFailureMessage(err: ExecFailure): string {
|
|
130
|
+
const output = (err.stdout || '') + (err.stderr || '');
|
|
131
|
+
const truncatedOutput = output.slice(0, 2000);
|
|
132
|
+
const errorMatches = output.match(/error TS\d+:/g);
|
|
133
|
+
const errorCount = errorMatches ? errorMatches.length : 0;
|
|
134
|
+
return errorCount > 0
|
|
135
|
+
? `TypeScript compilation failed with ${errorCount} error(s):\n${truncatedOutput}`
|
|
136
|
+
: `TypeScript compilation failed:\n${truncatedOutput}`;
|
|
137
|
+
}
|
|
93
138
|
|
|
94
|
-
|
|
139
|
+
/** Reasons tsc cannot run at all; null means it is usable. */
|
|
140
|
+
const tscSkipReason = async (projectRoot: string): Promise<string | null> => {
|
|
141
|
+
const tsconfigPath = path.join(projectRoot, 'tsconfig.json');
|
|
95
142
|
try {
|
|
96
|
-
|
|
97
|
-
await execFileAsync(process.execPath, [resolvedTsc, '--version'], {
|
|
98
|
-
cwd: projectRoot,
|
|
99
|
-
timeout: 10000,
|
|
100
|
-
});
|
|
101
|
-
} else {
|
|
102
|
-
await execFileAsync('npx', ['tsc', '--version'], {
|
|
103
|
-
cwd: projectRoot,
|
|
104
|
-
timeout: 10000,
|
|
105
|
-
shell: true,
|
|
106
|
-
env: { ...process.env, PATH: process.env.PATH },
|
|
107
|
-
});
|
|
108
|
-
}
|
|
143
|
+
await fs.access(tsconfigPath);
|
|
109
144
|
} catch {
|
|
110
|
-
return
|
|
111
|
-
status: 'skip',
|
|
112
|
-
message: 'tsc is not available on PATH',
|
|
113
|
-
durationMs: Date.now() - startTime,
|
|
114
|
-
};
|
|
145
|
+
return 'No tsconfig.json found in project root';
|
|
115
146
|
}
|
|
116
|
-
|
|
117
|
-
// Run tsc --noEmit --incremental
|
|
147
|
+
const resolvedTsc = findTscBinary(projectRoot);
|
|
118
148
|
try {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
cwd: projectRoot,
|
|
128
|
-
timeout: timeoutMs,
|
|
129
|
-
shell: !resolvedTsc,
|
|
130
|
-
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
131
|
-
}
|
|
132
|
-
);
|
|
149
|
+
const probe = tscProbeInvocation(projectRoot, resolvedTsc);
|
|
150
|
+
await execFileAsync(probe.cmd, probe.args, probe.opts);
|
|
151
|
+
} catch {
|
|
152
|
+
return 'tsc is not available on PATH';
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
133
156
|
|
|
157
|
+
/** Run the compile itself and map its failure modes onto gate statuses. */
|
|
158
|
+
const runTscCompile = async (projectRoot: string, timeoutMs: number): Promise<CheckResult> => {
|
|
159
|
+
const startTime = Date.now();
|
|
160
|
+
const resolvedTsc = findTscBinary(projectRoot);
|
|
161
|
+
try {
|
|
162
|
+
const compile = tscCompileInvocation(projectRoot, resolvedTsc, timeoutMs);
|
|
163
|
+
const { stdout, stderr } = await execFileAsync(compile.cmd, compile.args, compile.opts);
|
|
134
164
|
const output = stdout + stderr;
|
|
135
|
-
|
|
136
165
|
return {
|
|
137
166
|
status: 'pass',
|
|
138
167
|
message: output.trim() || 'TypeScript compilation successful',
|
|
139
168
|
durationMs: Date.now() - startTime,
|
|
140
169
|
};
|
|
141
170
|
} catch (error: unknown) {
|
|
142
|
-
const err = error as
|
|
143
|
-
|
|
144
|
-
signal?: string;
|
|
145
|
-
stdout?: string;
|
|
146
|
-
stderr?: string;
|
|
147
|
-
killed?: boolean;
|
|
148
|
-
message?: string;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// Timeout → SKIP
|
|
152
|
-
if (err.killed || err.signal === 'SIGTERM' || err.code === null) {
|
|
171
|
+
const err = error as ExecFailure;
|
|
172
|
+
if (isTimeoutFailure(err)) {
|
|
153
173
|
return {
|
|
154
174
|
status: 'skip',
|
|
155
175
|
message: `tsc timed out after ${timeoutMs}ms`,
|
|
156
176
|
durationMs: Date.now() - startTime,
|
|
157
177
|
};
|
|
158
178
|
}
|
|
159
|
-
|
|
160
|
-
// Non-zero exit → FAIL
|
|
161
|
-
const output = (err.stdout || '') + (err.stderr || '');
|
|
162
|
-
const truncatedOutput = output.slice(0, 2000);
|
|
163
|
-
|
|
164
|
-
// Count errors in output
|
|
165
|
-
const errorMatches = output.match(/error TS\d+:/g);
|
|
166
|
-
const errorCount = errorMatches ? errorMatches.length : 0;
|
|
167
|
-
|
|
168
|
-
const message = errorCount > 0
|
|
169
|
-
? `TypeScript compilation failed with ${errorCount} error(s):\n${truncatedOutput}`
|
|
170
|
-
: `TypeScript compilation failed:\n${truncatedOutput}`;
|
|
171
|
-
|
|
172
179
|
return {
|
|
173
180
|
status: 'fail',
|
|
174
|
-
message,
|
|
181
|
+
message: compileFailureMessage(err),
|
|
175
182
|
durationMs: Date.now() - startTime,
|
|
176
183
|
};
|
|
177
184
|
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export async function runTscCheck(
|
|
188
|
+
projectRoot: string,
|
|
189
|
+
timeoutMs: number
|
|
190
|
+
): Promise<CheckResult> {
|
|
191
|
+
const startTime = Date.now();
|
|
192
|
+
const skipReason = await tscSkipReason(projectRoot);
|
|
193
|
+
if (skipReason !== null) {
|
|
194
|
+
return { status: 'skip', message: skipReason, durationMs: Date.now() - startTime };
|
|
195
|
+
}
|
|
196
|
+
return runTscCompile(projectRoot, timeoutMs);
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
/**
|
|
@@ -194,49 +213,51 @@ export async function runTscCheck(
|
|
|
194
213
|
export function extractImports(content: string): Array<{ path: string; line: number }> {
|
|
195
214
|
const results: Array<{ path: string; line: number }> = [];
|
|
196
215
|
const lines = content.split('\n');
|
|
216
|
+
const push = (path: string, line: number) => { results.push({ path, line }); };
|
|
197
217
|
|
|
198
218
|
const staticImportExportRe =
|
|
199
219
|
/(?:import|export)\s+(?:(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]|['"]([^'"]+)['"])/g;
|
|
200
220
|
const requireRe = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
201
221
|
const dynamicImportRe = /(?<!\w)import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
202
222
|
|
|
223
|
+
// LINE-major order is the contract (import-resolver.test pins it): one pass
|
|
224
|
+
// over the lines, three patterns per line in static → require → dynamic
|
|
225
|
+
// order — exactly the inline version's nesting. Do not reorder into
|
|
226
|
+
// pattern-major passes; a line containing both a require and an import
|
|
227
|
+
// would then swap positions.
|
|
203
228
|
for (let i = 0; i < lines.length; i++) {
|
|
204
229
|
const line = lines[i];
|
|
205
|
-
const lineNum = i + 1;
|
|
206
|
-
|
|
207
230
|
// Skip comment lines (// or leading * in JSDoc blocks)
|
|
208
231
|
const trimmed = line.trim();
|
|
209
232
|
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
210
233
|
continue;
|
|
211
234
|
}
|
|
235
|
+
scanLine(line, i + 1, staticImportExportRe, (m) => m[1] ?? m[2], push);
|
|
236
|
+
scanLine(line, i + 1, requireRe, (m) => m[1], push);
|
|
237
|
+
scanLine(line, i + 1, dynamicImportRe, (m) => m[1], push);
|
|
238
|
+
}
|
|
212
239
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
while ((match = staticRe.exec(line)) !== null) {
|
|
216
|
-
const importPath = match[1] ?? match[2];
|
|
217
|
-
if (importPath && isRelativeImport(importPath)) {
|
|
218
|
-
results.push({ path: importPath, line: lineNum });
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const reqRe = new RegExp(requireRe.source, 'g');
|
|
223
|
-
while ((match = reqRe.exec(line)) !== null) {
|
|
224
|
-
const importPath = match[1];
|
|
225
|
-
if (importPath && isRelativeImport(importPath)) {
|
|
226
|
-
results.push({ path: importPath, line: lineNum });
|
|
227
|
-
}
|
|
228
|
-
}
|
|
240
|
+
return results;
|
|
241
|
+
}
|
|
229
242
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
243
|
+
/** Apply one pattern to a single line; every relative hit is pushed with its line number. */
|
|
244
|
+
function scanLine(
|
|
245
|
+
line: string,
|
|
246
|
+
lineNum: number,
|
|
247
|
+
re: RegExp,
|
|
248
|
+
pathOf: (m: RegExpExecArray) => string | undefined,
|
|
249
|
+
push: (path: string, line: number) => void,
|
|
250
|
+
): void {
|
|
251
|
+
// Fresh lastIndex per call: a shared /g regex would leak state across
|
|
252
|
+
// lines and silently drop matches at buffer boundaries.
|
|
253
|
+
const lineRe = new RegExp(re.source, 'g');
|
|
254
|
+
let match: RegExpExecArray | null;
|
|
255
|
+
while ((match = lineRe.exec(line)) !== null) {
|
|
256
|
+
const importPath = pathOf(match);
|
|
257
|
+
if (importPath && isRelativeImport(importPath)) {
|
|
258
|
+
push(importPath, lineNum);
|
|
236
259
|
}
|
|
237
260
|
}
|
|
238
|
-
|
|
239
|
-
return results;
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
function isRelativeImport(importPath: string): boolean {
|
|
@@ -297,83 +318,79 @@ export function resolveImportPath(importPath: string, fromFile: string): string
|
|
|
297
318
|
* - A relative import resolves to a path outside projectRoot
|
|
298
319
|
* - A relative import resolves to a file that doesn't exist on disk
|
|
299
320
|
*/
|
|
321
|
+
// Windows paths are case-insensitive but the two sides of the boundary
|
|
322
|
+
// comparison get their case from different sources: path.resolve of a
|
|
323
|
+
// repo-relative file inherits process.cwd()'s drive-letter case (often
|
|
324
|
+
// lowercase), while projectRoot comes from `git rev-parse --show-toplevel`
|
|
325
|
+
// (uppercase drive). A case-sensitive startsWith then judged EVERY import
|
|
326
|
+
// as escaping the boundary — pre-push Gate 10 blocked any TS-bearing push
|
|
327
|
+
// from a shell whose drive-letter case disagreed with git's (#508).
|
|
328
|
+
const normalizeForBoundary = (p: string): string => {
|
|
329
|
+
const resolved = path.resolve(p);
|
|
330
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const escapesProjectBoundary = (target: string, boundaryRoot: string): boolean =>
|
|
334
|
+
!target.startsWith(boundaryRoot + path.sep) && target !== boundaryRoot;
|
|
335
|
+
|
|
336
|
+
/** Violation for one import of one file, or null when it resolves cleanly. */
|
|
337
|
+
const importViolation = (
|
|
338
|
+
file: string,
|
|
339
|
+
imp: { path: string; line: number },
|
|
340
|
+
boundaryRoot: string
|
|
341
|
+
): ImportViolation | null => {
|
|
342
|
+
const rawResolved = path.resolve(path.dirname(file), imp.path);
|
|
343
|
+
if (escapesProjectBoundary(normalizeForBoundary(rawResolved), boundaryRoot)) {
|
|
344
|
+
return {
|
|
345
|
+
file,
|
|
346
|
+
line: imp.line,
|
|
347
|
+
importPath: imp.path,
|
|
348
|
+
resolvedPath: rawResolved,
|
|
349
|
+
reason: 'Import path escapes package boundary (resolves outside project root)',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (resolveImportPath(imp.path, file) === null) {
|
|
353
|
+
return {
|
|
354
|
+
file,
|
|
355
|
+
line: imp.line,
|
|
356
|
+
importPath: imp.path,
|
|
357
|
+
resolvedPath: rawResolved,
|
|
358
|
+
reason: 'Import target does not exist on disk',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Violations for one file, or [] when the file is skipped (unsupported
|
|
366
|
+
* extension, test fixture, or unreadable — a missing file cannot have imports).
|
|
367
|
+
*/
|
|
368
|
+
const fileImportViolations = async (file: string, boundaryRoot: string): Promise<ImportViolation[]> => {
|
|
369
|
+
// Skip test files — they contain intentionally broken imports for unit testing.
|
|
370
|
+
// The published npm package does not include __tests__ directories.
|
|
371
|
+
if (!isSupportedModuleFile(file) || file.includes('__tests__')) return [];
|
|
372
|
+
if (!isReadableFile(file)) return [];
|
|
373
|
+
let content: string;
|
|
374
|
+
try {
|
|
375
|
+
content = await fs.readFile(file, 'utf-8');
|
|
376
|
+
} catch {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
return extractImports(content)
|
|
380
|
+
.map((imp) => importViolation(file, imp, boundaryRoot))
|
|
381
|
+
.filter((v): v is ImportViolation => v !== null);
|
|
382
|
+
};
|
|
383
|
+
|
|
300
384
|
export async function runImportCheck(
|
|
301
385
|
changedFiles: string[],
|
|
302
386
|
projectRoot: string,
|
|
303
387
|
_timeoutMs: number
|
|
304
388
|
): Promise<ImportCheckResult> {
|
|
305
389
|
const startTime = Date.now();
|
|
390
|
+
const boundaryRoot = normalizeForBoundary(path.resolve(projectRoot));
|
|
306
391
|
const violations: ImportViolation[] = [];
|
|
307
|
-
|
|
308
|
-
const fsSync = fsNative;
|
|
309
|
-
|
|
310
|
-
const normalizedRoot = path.resolve(projectRoot);
|
|
311
|
-
|
|
312
392
|
for (const file of changedFiles) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// Skip test files — they contain intentionally broken imports for unit testing.
|
|
316
|
-
// The published npm package does not include __tests__ directories.
|
|
317
|
-
if (file.includes('__tests__')) continue;
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
fsSync.accessSync(file);
|
|
321
|
-
} catch {
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
let content: string;
|
|
326
|
-
try {
|
|
327
|
-
content = await fs.readFile(file, 'utf-8');
|
|
328
|
-
} catch {
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Windows paths are case-insensitive but the two sides of the boundary
|
|
333
|
-
// comparison get their case from different sources: path.resolve of a
|
|
334
|
-
// repo-relative file inherits process.cwd()'s drive-letter case (often
|
|
335
|
-
// lowercase), while projectRoot comes from `git rev-parse --show-toplevel`
|
|
336
|
-
// (uppercase drive). A case-sensitive startsWith then judged EVERY import
|
|
337
|
-
// as escaping the boundary — pre-push Gate 10 blocked any TS-bearing push
|
|
338
|
-
// from a shell whose drive-letter case disagreed with git's (#508).
|
|
339
|
-
const normalizeForBoundary = (p: string): string => {
|
|
340
|
-
const resolved = path.resolve(p);
|
|
341
|
-
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
342
|
-
};
|
|
343
|
-
const boundaryRoot = normalizeForBoundary(normalizedRoot);
|
|
344
|
-
|
|
345
|
-
const imports = extractImports(content);
|
|
346
|
-
|
|
347
|
-
for (const imp of imports) {
|
|
348
|
-
const rawResolved = path.resolve(path.dirname(file), imp.path);
|
|
349
|
-
const boundaryTarget = normalizeForBoundary(rawResolved);
|
|
350
|
-
|
|
351
|
-
const escapesBoundary =
|
|
352
|
-
!boundaryTarget.startsWith(boundaryRoot + path.sep) && boundaryTarget !== boundaryRoot;
|
|
353
|
-
|
|
354
|
-
if (escapesBoundary) {
|
|
355
|
-
violations.push({
|
|
356
|
-
file,
|
|
357
|
-
line: imp.line,
|
|
358
|
-
importPath: imp.path,
|
|
359
|
-
resolvedPath: rawResolved,
|
|
360
|
-
reason: `Import path escapes package boundary (resolves outside project root)`,
|
|
361
|
-
});
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const resolvedPath = resolveImportPath(imp.path, file);
|
|
366
|
-
|
|
367
|
-
if (resolvedPath === null) {
|
|
368
|
-
violations.push({
|
|
369
|
-
file,
|
|
370
|
-
line: imp.line,
|
|
371
|
-
importPath: imp.path,
|
|
372
|
-
resolvedPath: rawResolved,
|
|
373
|
-
reason: `Import target does not exist on disk`,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
393
|
+
violations.push(...(await fileImportViolations(file, boundaryRoot)));
|
|
377
394
|
}
|
|
378
395
|
|
|
379
396
|
const durationMs = Date.now() - startTime;
|
|
@@ -395,31 +412,80 @@ export async function runImportCheck(
|
|
|
395
412
|
};
|
|
396
413
|
}
|
|
397
414
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
402
|
-
|
|
415
|
+
/**
|
|
416
|
+
* npm pack must ship at least one file besides package.json. Split out of
|
|
417
|
+
* runPackCheck because the check reads as three separate verdicts (no output,
|
|
418
|
+
* empty list, manifest-only) and each one has its own operator-facing message.
|
|
419
|
+
*/
|
|
420
|
+
function inspectPackedFiles(
|
|
421
|
+
packages: Array<{ files?: Array<{ path: string }> }> | null
|
|
422
|
+
): { ok: true; count: number } | { ok: false; message: string } {
|
|
423
|
+
if (!packages || packages.length === 0) {
|
|
424
|
+
return { ok: false, message: 'npm pack produced no package output' };
|
|
425
|
+
}
|
|
426
|
+
const files = packages[0].files || [];
|
|
427
|
+
if (files.length === 0) {
|
|
428
|
+
return { ok: false, message: 'npm pack produced empty file list' };
|
|
429
|
+
}
|
|
430
|
+
// Check if only package.json is included (no actual content files)
|
|
431
|
+
const hasContentFiles = files.some((f) => f.path !== 'package.json');
|
|
432
|
+
if (!hasContentFiles) {
|
|
433
|
+
return { ok: false, message: 'npm pack produced no files — only package.json included' };
|
|
434
|
+
}
|
|
435
|
+
return { ok: true, count: files.length };
|
|
436
|
+
}
|
|
403
437
|
|
|
438
|
+
/**
|
|
439
|
+
* npm pack is only meaningful when package.json declares a `files` allowlist —
|
|
440
|
+
* without one, npm ships everything and the "which files would publish" check
|
|
441
|
+
* has no answer to give. Returns the reason to SKIP, or null to proceed.
|
|
442
|
+
*/
|
|
443
|
+
async function packPrecondition(projectRoot: string): Promise<string | null> {
|
|
404
444
|
const pkgJsonPath = path.join(projectRoot, 'package.json');
|
|
405
445
|
try {
|
|
406
446
|
await fs.access(pkgJsonPath);
|
|
407
447
|
} catch {
|
|
408
|
-
return
|
|
409
|
-
status: 'skip',
|
|
410
|
-
message: 'No package.json found in project root',
|
|
411
|
-
durationMs: Date.now() - startTime,
|
|
412
|
-
};
|
|
448
|
+
return 'No package.json found in project root';
|
|
413
449
|
}
|
|
414
|
-
|
|
415
450
|
const pkgJsonRaw = await fs.readFile(pkgJsonPath, 'utf-8');
|
|
416
451
|
const pkgJson = JSON.parse(pkgJsonRaw) as { files?: unknown };
|
|
417
452
|
if (!pkgJson.files || !Array.isArray(pkgJson.files)) {
|
|
418
|
-
return
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
453
|
+
return 'package.json has no files field — skipping pack check';
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
interface PackFailure {
|
|
459
|
+
killed?: boolean;
|
|
460
|
+
signal?: string;
|
|
461
|
+
code?: number | null;
|
|
462
|
+
stderr?: string;
|
|
463
|
+
stdout?: string;
|
|
464
|
+
message?: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const MAX_PACK_DETAIL = 2000;
|
|
468
|
+
|
|
469
|
+
/** Map an npm pack rejection onto the gate's SKIP/FAIL contract. */
|
|
470
|
+
const packFailureResult = (error: unknown, timeoutMs: number): CheckResult => {
|
|
471
|
+
const err = error as PackFailure;
|
|
472
|
+
const durationMs = 0;
|
|
473
|
+
if (err.killed || err.signal === 'SIGTERM') {
|
|
474
|
+
return { status: 'skip', message: `npm pack timeout after ${timeoutMs}ms`, durationMs };
|
|
475
|
+
}
|
|
476
|
+
const detail = (err.stderr || err.stdout || err.message || '').slice(0, MAX_PACK_DETAIL);
|
|
477
|
+
return { status: 'fail', message: `npm pack failed: ${detail}`, durationMs };
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export async function runPackCheck(
|
|
481
|
+
projectRoot: string,
|
|
482
|
+
timeoutMs: number
|
|
483
|
+
): Promise<CheckResult> {
|
|
484
|
+
const startTime = Date.now();
|
|
485
|
+
|
|
486
|
+
const skipReason = await packPrecondition(projectRoot);
|
|
487
|
+
if (skipReason) {
|
|
488
|
+
return { status: 'skip', message: skipReason, durationMs: Date.now() - startTime };
|
|
423
489
|
}
|
|
424
490
|
|
|
425
491
|
try {
|
|
@@ -430,63 +496,18 @@ export async function runPackCheck(
|
|
|
430
496
|
maxBuffer: 10 * 1024 * 1024,
|
|
431
497
|
});
|
|
432
498
|
|
|
433
|
-
const
|
|
434
|
-
if (!
|
|
435
|
-
return {
|
|
436
|
-
status: 'fail',
|
|
437
|
-
message: 'npm pack produced no package output',
|
|
438
|
-
durationMs: Date.now() - startTime,
|
|
439
|
-
};
|
|
499
|
+
const verdict = inspectPackedFiles(JSON.parse(stdout) as Array<{ files?: Array<{ path: string }> }>);
|
|
500
|
+
if (!verdict.ok) {
|
|
501
|
+
return { status: 'fail', message: verdict.message, durationMs: Date.now() - startTime };
|
|
440
502
|
}
|
|
441
|
-
|
|
442
|
-
const files = packages[0].files || [];
|
|
443
|
-
if (files.length === 0) {
|
|
444
|
-
return {
|
|
445
|
-
status: 'fail',
|
|
446
|
-
message: 'npm pack produced empty file list',
|
|
447
|
-
durationMs: Date.now() - startTime,
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Check if only package.json is included (no actual content files)
|
|
452
|
-
const hasContentFiles = files.some((f) => f.path !== 'package.json');
|
|
453
|
-
if (!hasContentFiles) {
|
|
454
|
-
return {
|
|
455
|
-
status: 'fail',
|
|
456
|
-
message: 'npm pack produced no files — only package.json included',
|
|
457
|
-
durationMs: Date.now() - startTime,
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
|
|
461
503
|
return {
|
|
462
504
|
status: 'pass',
|
|
463
|
-
message: `npm pack: ${
|
|
505
|
+
message: `npm pack: ${verdict.count} file(s) included`,
|
|
464
506
|
durationMs: Date.now() - startTime,
|
|
465
507
|
};
|
|
466
508
|
} catch (error: unknown) {
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
signal?: string;
|
|
470
|
-
code?: number | null;
|
|
471
|
-
stderr?: string;
|
|
472
|
-
stdout?: string;
|
|
473
|
-
message?: string;
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
if (err.killed || err.signal === 'SIGTERM') {
|
|
477
|
-
return {
|
|
478
|
-
status: 'skip',
|
|
479
|
-
message: `npm pack timeout after ${timeoutMs}ms`,
|
|
480
|
-
durationMs: Date.now() - startTime,
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const detail = (err.stderr || err.stdout || err.message || '').slice(0, 2000);
|
|
485
|
-
return {
|
|
486
|
-
status: 'fail',
|
|
487
|
-
message: `npm pack failed: ${detail}`,
|
|
488
|
-
durationMs: Date.now() - startTime,
|
|
489
|
-
};
|
|
509
|
+
const result = packFailureResult(error, timeoutMs);
|
|
510
|
+
return { ...result, durationMs: Date.now() - startTime };
|
|
490
511
|
}
|
|
491
512
|
}
|
|
492
513
|
|
|
@@ -568,6 +589,16 @@ export interface Gate10Args {
|
|
|
568
589
|
*
|
|
569
590
|
* @returns Parsed args object with changedFiles, projectRoot, timeoutMs, and help flag
|
|
570
591
|
*/
|
|
592
|
+
/**
|
|
593
|
+
* Lizard-and-ratchet-visible shape of the gate's CLI. Split from
|
|
594
|
+
* parseGate10Args so the flag loop stays a flat scan.
|
|
595
|
+
*/
|
|
596
|
+
function splitChangedFiles(changedFilesStr: string): string[] {
|
|
597
|
+
return changedFilesStr
|
|
598
|
+
? changedFilesStr.split(',').map((f) => f.trim()).filter(Boolean)
|
|
599
|
+
: [];
|
|
600
|
+
}
|
|
601
|
+
|
|
571
602
|
export function parseGate10Args(args: string[]): Gate10Args {
|
|
572
603
|
let changedFilesStr = '';
|
|
573
604
|
let projectRoot = process.cwd();
|
|
@@ -587,11 +618,7 @@ export function parseGate10Args(args: string[]): Gate10Args {
|
|
|
587
618
|
}
|
|
588
619
|
}
|
|
589
620
|
|
|
590
|
-
|
|
591
|
-
? changedFilesStr.split(',').map((f) => f.trim()).filter(Boolean)
|
|
592
|
-
: [];
|
|
593
|
-
|
|
594
|
-
return { changedFiles, projectRoot, timeoutMs, help };
|
|
621
|
+
return { changedFiles: splitChangedFiles(changedFilesStr), projectRoot, timeoutMs, help };
|
|
595
622
|
}
|
|
596
623
|
|
|
597
624
|
// ─── CLI Result Formatting ─────────────────────────────────────────────────────
|