@boyingliu01/xp-gate 0.27.6 → 0.28.1

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.
Files changed (104) hide show
  1. package/adapter-common.sh +0 -0
  2. package/adapters/cpp.sh +0 -0
  3. package/adapters/dart.sh +0 -0
  4. package/adapters/flutter.sh +0 -0
  5. package/adapters/gate-9.sh +13 -2
  6. package/adapters/go.sh +0 -0
  7. package/adapters/iac.sh +0 -0
  8. package/adapters/java.sh +0 -0
  9. package/adapters/kotlin.sh +0 -0
  10. package/adapters/objectivec.sh +0 -0
  11. package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
  12. package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
  13. package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
  14. package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
  15. package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
  16. package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
  17. package/adapters/powershell.sh +0 -0
  18. package/adapters/python.sh +0 -0
  19. package/adapters/shell.sh +0 -0
  20. package/adapters/swift.sh +0 -0
  21. package/adapters/typescript.sh +0 -0
  22. package/bin/xp-gate.js +0 -0
  23. package/build-integrity/gate-10.ts +290 -263
  24. package/gate-10.sh +25 -6
  25. package/gate-12-file-hygiene.sh +35 -1
  26. package/gate-3.sh +78 -2
  27. package/gate-4.sh +0 -0
  28. package/gate-7.sh +0 -0
  29. package/gate-8.sh +0 -0
  30. package/gate-9.sh +13 -2
  31. package/hooks/adapter-common.sh +0 -0
  32. package/hooks/gate-10.sh +25 -6
  33. package/hooks/gate-12-file-hygiene.sh +35 -1
  34. package/hooks/gate-3.sh +78 -2
  35. package/hooks/gate-4.sh +0 -0
  36. package/hooks/gate-7.sh +0 -0
  37. package/hooks/gate-8.sh +0 -0
  38. package/hooks/gate-9.sh +13 -2
  39. package/hooks/lib/audit-sanitize.sh +51 -0
  40. package/hooks/lib/ccn-ratchet.sh +183 -0
  41. package/hooks/lib/docs-only.sh +49 -0
  42. package/hooks/post-merge +0 -0
  43. package/hooks/pre-commit +74 -15
  44. package/hooks/pre-push +36 -5
  45. package/hooks/sprint-gate.sh +0 -0
  46. package/lib/__tests__/sprint-audit.test.js +207 -207
  47. package/lib/gate-audit.ts +9 -4
  48. package/lib/mattpocock-license-verdict.json +1 -1
  49. package/lib/retro.js +27 -6
  50. package/lib/sprint-audit.js +235 -235
  51. package/mock-policy/AGENTS.md +3 -3
  52. package/mutation/AGENTS.md +3 -3
  53. package/package.json +1 -1
  54. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  55. package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
  56. package/plugins/claude-code/bin/xp-gate-check +0 -0
  57. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/package.json +1 -1
  61. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  62. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  63. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  64. package/plugins/opencode/package.json +1 -1
  65. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
  69. package/plugins/qoder/bin/xp-gate-check +0 -0
  70. package/plugins/qoder/plugin.json +1 -1
  71. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  73. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  74. package/principles/AGENTS.md +3 -3
  75. package/principles/__tests__/many-exports-reexport.test.ts +40 -0
  76. package/principles/adapters/typescript.ts +61 -30
  77. package/principles/boy-scout.ts +22 -17
  78. package/scripts/delphi-external-review.cjs +195 -132
  79. package/skills/delphi-review/AGENTS.md +3 -3
  80. package/skills/sprint-flow/AGENTS.md +3 -3
  81. package/skills/test-specification-alignment/AGENTS.md +3 -3
  82. package/sprint-gate.sh +0 -0
  83. package/plugins/dsh/lib/command.d.ts +0 -32
  84. package/plugins/dsh/lib/command.d.ts.map +0 -1
  85. package/plugins/dsh/lib/command.js +0 -81
  86. package/plugins/dsh/lib/delphi-config.d.ts +0 -75
  87. package/plugins/dsh/lib/delphi-config.d.ts.map +0 -1
  88. package/plugins/dsh/lib/delphi-config.js +0 -183
  89. package/plugins/dsh/lib/delphi-run.d.ts +0 -24
  90. package/plugins/dsh/lib/delphi-run.d.ts.map +0 -1
  91. package/plugins/dsh/lib/delphi-run.js +0 -92
  92. package/plugins/dsh/lib/delphi.d.ts +0 -88
  93. package/plugins/dsh/lib/delphi.d.ts.map +0 -1
  94. package/plugins/dsh/lib/delphi.js +0 -161
  95. package/plugins/dsh/lib/dialect.d.ts +0 -24
  96. package/plugins/dsh/lib/dialect.d.ts.map +0 -1
  97. package/plugins/dsh/lib/dialect.js +0 -36
  98. package/plugins/dsh/lib/gate-runner.d.ts +0 -18
  99. package/plugins/dsh/lib/gate-runner.d.ts.map +0 -1
  100. package/plugins/dsh/lib/gate-runner.js +0 -46
  101. package/plugins/dsh/lib/index.d.ts +0 -5
  102. package/plugins/dsh/lib/index.d.ts.map +0 -1
  103. package/plugins/dsh/lib/index.js +0 -162
  104. package/plugins/dsh/node_modules/.vite/vitest/results.json +0 -1
@@ -45,136 +45,155 @@ function isSupportedModuleFile(file: string): boolean {
45
45
  }
46
46
 
47
47
  /**
48
- * Gate 10: Build Integrity Check — TypeScript compilation verification.
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
- * @param projectRoot - Absolute path to the project root
52
- * @param timeoutMs - Timeout in milliseconds
53
- * @returns CheckResult with status 'pass', 'fail', or 'skip'
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
- export async function runTscCheck(
56
- projectRoot: string,
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
- await fs.access(tsconfigPath);
60
+ fsNative.accessSync(filePath);
65
61
  } catch {
66
- return {
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
- // Resolve tsc binary: try multiple strategies for cross-platform reliability
74
- function findTsc(): string | null {
75
- // Strategy 1: projectRoot/node_modules/typescript/bin/tsc
76
- const direct = path.join(projectRoot, 'node_modules', 'typescript', 'bin', 'tsc');
77
- try { fsNative.accessSync(direct); return direct; } catch { /* continue */ }
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
- // Strategy 2: resolve from cwd's node_modules (works when test symlinks or in project)
80
- const fromCwd = path.join(process.cwd(), 'node_modules', 'typescript', 'bin', 'tsc');
81
- try { fsNative.accessSync(fromCwd); return fromCwd; } catch { /* continue */ }
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
- // Strategy 3: resolve via require.resolve from this file's location
84
- try {
85
- const resolved = require.resolve('typescript/bin/tsc', { paths: [projectRoot, process.cwd()] });
86
- try { fsNative.accessSync(resolved); return resolved; } catch { /* continue */ }
87
- } catch { /* continue */ }
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
- return null;
90
- }
125
+ function isTimeoutFailure(err: ExecFailure): boolean {
126
+ return Boolean(err.killed || err.signal === 'SIGTERM' || err.code === null);
127
+ }
91
128
 
92
- const resolvedTsc = findTsc();
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
- // Check if tsc is available
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
- if (resolvedTsc) {
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 tscCmd = resolvedTsc ? process.execPath : 'npx';
120
- const tscArgs = resolvedTsc
121
- ? [resolvedTsc, '--noEmit', '--incremental']
122
- : ['tsc', '--noEmit', '--incremental'];
123
- const { stdout, stderr } = await execFileAsync(
124
- tscCmd,
125
- tscArgs,
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
- code?: number;
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
- let match: RegExpExecArray | null;
214
- const staticRe = new RegExp(staticImportExportRe.source, 'g');
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
- const dynRe = new RegExp(dynamicImportRe.source, 'g');
231
- while ((match = dynRe.exec(line)) !== null) {
232
- const importPath = match[1];
233
- if (importPath && isRelativeImport(importPath)) {
234
- results.push({ path: importPath, line: lineNum });
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
- if (!isSupportedModuleFile(file)) continue;
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
- export async function runPackCheck(
399
- projectRoot: string,
400
- timeoutMs: number
401
- ): Promise<CheckResult> {
402
- const startTime = Date.now();
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
- status: 'skip',
420
- message: 'package.json has no files field — skipping pack check',
421
- durationMs: Date.now() - startTime,
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 packages = JSON.parse(stdout) as Array<{ files?: Array<{ path: string }> }>;
434
- if (!packages || packages.length === 0) {
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: ${files.length} file(s) included`,
505
+ message: `npm pack: ${verdict.count} file(s) included`,
464
506
  durationMs: Date.now() - startTime,
465
507
  };
466
508
  } catch (error: unknown) {
467
- const err = error as {
468
- killed?: boolean;
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
- const changedFiles = changedFilesStr
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 ─────────────────────────────────────────────────────