@haystackeditor/cli 0.25.1 → 0.26.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.
Files changed (97) hide show
  1. package/README.md +39 -455
  2. package/dist/capture/app-config.js +25 -1
  3. package/dist/commands/capture-brief.js +41 -32
  4. package/dist/commands/feedback.js +66 -0
  5. package/dist/commands/init-telemetry.js +53 -7
  6. package/dist/commands/init.js +7 -2
  7. package/dist/commands/lockfile-pin.js +307 -0
  8. package/dist/commands/verify.js +68 -13
  9. package/dist/index.js +27 -923
  10. package/dist/schema.js +4 -10
  11. package/dist/utils/haystack-api.js +0 -36
  12. package/package.json +1 -5
  13. package/schemas/feedback.v1.json +13 -0
  14. package/schemas/pre-verify.v2.json +240 -0
  15. package/schemas/verify-raw.v1.json +1132 -0
  16. package/schemas/verify.v2.json +655 -0
  17. package/dist/assets/hooks/agent-context/detect.ts +0 -316
  18. package/dist/assets/hooks/agent-context/format.ts +0 -100
  19. package/dist/assets/hooks/agent-context/index.ts +0 -41
  20. package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
  21. package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
  22. package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
  23. package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
  24. package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
  25. package/dist/assets/hooks/agent-context/types.ts +0 -58
  26. package/dist/assets/hooks/llm-rules-template.md +0 -59
  27. package/dist/assets/hooks/package-lock.json +0 -598
  28. package/dist/assets/hooks/package.json +0 -12
  29. package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
  30. package/dist/assets/hooks/scripts/post-commit.sh +0 -5
  31. package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
  32. package/dist/assets/hooks/scripts/pre-push.sh +0 -25
  33. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
  34. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
  35. package/dist/assets/hooks/truncation-checker/index.ts +0 -595
  36. package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
  37. package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
  38. package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
  39. package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
  40. package/dist/assets/skills/map-your-system.md +0 -143
  41. package/dist/assets/skills/submit.md +0 -200
  42. package/dist/commands/ask.js +0 -20
  43. package/dist/commands/cloud-verifier-behaviors.js +0 -218
  44. package/dist/commands/cloud-verifier-data-store-census.js +0 -539
  45. package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
  46. package/dist/commands/cloud-verifier-identity-census.js +0 -4060
  47. package/dist/commands/cloud-verifier-materialization.js +0 -704
  48. package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
  49. package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
  50. package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
  51. package/dist/commands/cloud-verifier-universe.js +0 -10178
  52. package/dist/commands/config.js +0 -549
  53. package/dist/commands/design-verify.js +0 -311
  54. package/dist/commands/dismiss.js +0 -159
  55. package/dist/commands/hooks.js +0 -226
  56. package/dist/commands/inbox.js +0 -137
  57. package/dist/commands/mcp.js +0 -201
  58. package/dist/commands/policy.js +0 -371
  59. package/dist/commands/pr-status.js +0 -207
  60. package/dist/commands/pr.js +0 -105
  61. package/dist/commands/prepare-universe-review.js +0 -1092
  62. package/dist/commands/production-source-deny-policy.js +0 -100
  63. package/dist/commands/request-review.js +0 -74
  64. package/dist/commands/review.js +0 -191
  65. package/dist/commands/rules.js +0 -98
  66. package/dist/commands/scaffold-provisional-universe.js +0 -806
  67. package/dist/commands/setup.js +0 -1170
  68. package/dist/commands/skills.js +0 -447
  69. package/dist/commands/submit.js +0 -745
  70. package/dist/commands/system-map.js +0 -228
  71. package/dist/commands/triage.js +0 -598
  72. package/dist/commands/webhooks.js +0 -241
  73. package/dist/states.js +0 -46
  74. package/dist/tools/detect.js +0 -832
  75. package/dist/triage/astra.js +0 -202
  76. package/dist/triage/prompts.js +0 -188
  77. package/dist/triage/runner.js +0 -200
  78. package/dist/triage/types.js +0 -7
  79. package/dist/utils/action-output.js +0 -26
  80. package/dist/utils/analysis-api.js +0 -416
  81. package/dist/utils/design-verifier-api.js +0 -294
  82. package/dist/utils/design-verifier-history.js +0 -79
  83. package/dist/utils/design-verifier-result.js +0 -424
  84. package/dist/utils/github-api.js +0 -324
  85. package/dist/utils/pending-state.js +0 -86
  86. package/dist/utils/pr-ref.js +0 -56
  87. package/dist/utils/prompter.js +0 -328
  88. package/schemas/action.v1.json +0 -22
  89. package/schemas/ask.v1.json +0 -40
  90. package/schemas/inbox.v1.json +0 -27
  91. package/schemas/pr-status.v1.json +0 -61
  92. package/schemas/pr.v1.json +0 -97
  93. package/schemas/pr.v3.json +0 -45
  94. package/schemas/setup.v1.json +0 -75
  95. package/schemas/submit.v1.json +0 -90
  96. package/schemas/triage.v1.json +0 -103
  97. package/schemas/triage.v2.json +0 -64
@@ -1,1092 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, } from 'node:fs';
3
- import { execFileSync } from 'node:child_process';
4
- import { tmpdir } from 'node:os';
5
- import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
6
- import { CUSTOMER_REPLICA_PRODUCTION_SOURCE_DENY_POLICY, isCustomerReplicaProductionSourceDeniedPath, } from './production-source-deny-policy.js';
7
- import { findGitRoot } from '../utils/hooks.js';
8
- const MANIFEST_NAME = 'manifest.json';
9
- const MAX_INLINE_SCAN_BYTES = CUSTOMER_REPLICA_PRODUCTION_SOURCE_DENY_POLICY.inlineTestScan
10
- .maximumTextFileBytes;
11
- const COMPACT_RECEIPT_SAMPLE_LIMIT = 20;
12
- const MAX_SNAPSHOT_ENTRY_COUNT = 100_000;
13
- const MAX_SNAPSHOT_OUTPUT_BYTES = 1024 * 1024 * 1024;
14
- const MAX_GIT_BLOB_BATCH_BYTES = 32 * 1024 * 1024;
15
- const MAX_GIT_BLOB_BATCH_ENTRIES = 512;
16
- const GIT_NO_LAZY_FETCH_ENV = { ...process.env, GIT_NO_LAZY_FETCH: '1' };
17
- const DENIED_COMPONENTS = [
18
- '.git',
19
- '.haystack',
20
- '.nyc_output',
21
- '.pytest_cache',
22
- '__tests__',
23
- 'acceptance-test',
24
- 'acceptance-tests',
25
- 'acceptance_test',
26
- 'acceptance_tests',
27
- 'cypress',
28
- 'e2e',
29
- 'integration-test',
30
- 'integration-tests',
31
- 'integration_test',
32
- 'integration_tests',
33
- 'playwright',
34
- 'playwright-report',
35
- 'system-test',
36
- 'system-tests',
37
- 'system_test',
38
- 'system_tests',
39
- 'test',
40
- 'test-data',
41
- 'test-results',
42
- 'testdata',
43
- 'tests',
44
- ];
45
- const DENIED_COMPONENT_SET = new Set(DENIED_COMPONENTS);
46
- const CONDITIONAL_COMPONENTS = [
47
- 'spec',
48
- 'specs',
49
- 'fixtures',
50
- '__fixtures__',
51
- 'mocks',
52
- '__mocks__',
53
- 'snapshots',
54
- '__snapshots__',
55
- ];
56
- const DENIED_BASENAME_GLOBS = [
57
- '*.test.{js,jsx,ts,tsx,mjs,cjs}',
58
- '*.spec.{js,jsx,ts,tsx,mjs,cjs,rb}',
59
- '*.e2e.{js,jsx,ts,tsx}',
60
- '*.e2e-spec.{js,jsx,ts,tsx}',
61
- 'test-*.{js,jsx,ts,tsx,py,rb,go,rs}',
62
- '*-test.{js,jsx,ts,tsx,py,rb,go,rs}',
63
- '*_test.{py,go,rs,cc,cpp}',
64
- '*_tests.{py,rb}',
65
- 'test_*.py',
66
- '*_unittest.{cc,cpp,py}',
67
- '*Test.{java,kt,kts,scala,groovy,cs,php,swift}',
68
- '*Tests.{java,kt,kts,scala,groovy,cs,php,swift}',
69
- '*ITCase.{java,kt,kts,scala,groovy,cs,php,swift}',
70
- '*.{snap,golden,approved}',
71
- '{jest,vitest,playwright,cypress}.config.*',
72
- 'karma.conf.*',
73
- 'mocha.config.*',
74
- 'pytest.ini',
75
- 'tox.ini',
76
- '.coveragerc',
77
- 'junit*.xml',
78
- ];
79
- const JS_EXTENSIONS = new Set(['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs']);
80
- const TEST_PREFIX_EXTENSIONS = new Set([...JS_EXTENSIONS, 'py', 'rb', 'go', 'rs']);
81
- const JVM_TEST_EXTENSIONS = new Set([
82
- 'java',
83
- 'kt',
84
- 'kts',
85
- 'scala',
86
- 'groovy',
87
- 'cs',
88
- 'php',
89
- 'swift',
90
- ]);
91
- const EXACT_TEST_FILE_NAMES = new Set([
92
- '.coveragerc',
93
- 'pytest.ini',
94
- 'tox.ini',
95
- ]);
96
- function splitStemAndExtension(name) {
97
- const dot = name.lastIndexOf('.');
98
- if (dot <= 0 || dot === name.length - 1)
99
- return { stem: name, extension: '' };
100
- return { stem: name.slice(0, dot), extension: name.slice(dot + 1).toLowerCase() };
101
- }
102
- function isTypedTestBasename(path) {
103
- const name = basename(path);
104
- const lowerName = name.toLowerCase();
105
- const { stem, extension } = splitStemAndExtension(name);
106
- const lowerStem = stem.toLowerCase();
107
- if (JS_EXTENSIONS.has(extension) && /\.(?:test|e2e|e2e-spec)$/i.test(stem))
108
- return true;
109
- if ((JS_EXTENSIONS.has(extension) || extension === 'rb') && /\.spec$/i.test(stem))
110
- return true;
111
- if (TEST_PREFIX_EXTENSIONS.has(extension) &&
112
- (lowerStem.startsWith('test-') || lowerStem.endsWith('-test')))
113
- return true;
114
- if (new Set(['py', 'go', 'rs', 'cc', 'cpp']).has(extension) &&
115
- lowerStem.endsWith('_test'))
116
- return true;
117
- if (new Set(['py', 'rb']).has(extension) && lowerStem.endsWith('_tests'))
118
- return true;
119
- if (extension === 'py' && lowerStem.startsWith('test_'))
120
- return true;
121
- if (new Set(['cc', 'cpp', 'py']).has(extension) && lowerStem.endsWith('_unittest'))
122
- return true;
123
- if (JVM_TEST_EXTENSIONS.has(extension) &&
124
- (stem.endsWith('Test') || stem.endsWith('Tests') || stem.endsWith('ITCase')))
125
- return true;
126
- if (new Set(['snap', 'golden', 'approved']).has(extension))
127
- return true;
128
- if (/^(?:jest|vitest|playwright|cypress)\.config\..+$/i.test(name))
129
- return true;
130
- if (/^karma\.conf\..+$/i.test(name) || /^mocha\.config\..+$/i.test(name))
131
- return true;
132
- if (EXACT_TEST_FILE_NAMES.has(lowerName))
133
- return true;
134
- return /^junit.*\.xml$/i.test(name);
135
- }
136
- /**
137
- * These rules intentionally match conventional path components and filename
138
- * shapes. They never apply a broad `*test*` substring match: names such as
139
- * latest.ts, contest.ts, and attestation.py remain visible.
140
- */
141
- const EXCLUSION_RULES = [
142
- {
143
- id: CUSTOMER_REPLICA_PRODUCTION_SOURCE_DENY_POLICY.policyId,
144
- description: 'exclude paths selected by the canonical reviewed production-source policy',
145
- matches: isCustomerReplicaProductionSourceDeniedPath,
146
- },
147
- ];
148
- const APPROVED_INACTIVE_SUBMODULE_RULE = {
149
- id: 'approved-inactive-submodule-omission',
150
- description: 'omit only exact normalized tracked gitlink paths explicitly approved as inactive',
151
- };
152
- const INLINE_MARKER_RULES = [
153
- {
154
- id: 'rust-cfg-test',
155
- description: 'Rust cfg(test) module',
156
- expression: /^[ \t]*#[ \t]*\[[ \t]*cfg[ \t]*\([ \t]*test[ \t]*\)[ \t]*\]/m,
157
- },
158
- {
159
- id: 'zig-inline-test',
160
- description: 'Zig inline test declaration',
161
- expression: /^[ \t]*test[ \t]+"[^"]+"[ \t]*\{/m,
162
- },
163
- {
164
- id: 'vitest-import-meta',
165
- description: 'Vitest import.meta.vitest inline-test hook',
166
- expression: /\bimport\.meta\.vitest\b/,
167
- },
168
- {
169
- id: 'deno-inline-test',
170
- description: 'Deno.test inline-test registration',
171
- expression: /\bDeno\.test\s*\(/,
172
- },
173
- {
174
- id: 'bun-inline-test',
175
- description: 'Bun.test inline-test registration',
176
- expression: /\bBun\.test\s*\(/,
177
- },
178
- {
179
- id: 'c-unit-test-conditional',
180
- description: 'C/C++ UNIT_TEST preprocessor block',
181
- expression: /^[ \t]*#[ \t]*ifn?def[ \t]+UNIT_TESTS?\b/m,
182
- },
183
- {
184
- id: 'rust-test-attribute',
185
- description: 'Rust test function attribute',
186
- expression: /^[ \t]*#[ \t]*\[[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*::)*test[ \t]*\]/m,
187
- extensions: new Set(['rs']),
188
- },
189
- {
190
- id: 'jvm-test-annotation',
191
- description: 'JVM @Test method annotation',
192
- expression: /^[ \t]*@(?:org\.junit(?:\.jupiter\.api)?\.)?Test\b/m,
193
- extensions: new Set(['java', 'kt', 'kts', 'scala', 'groovy']),
194
- },
195
- {
196
- id: 'dotnet-test-attribute',
197
- description: '.NET xUnit Fact or Theory attribute',
198
- expression: /^[ \t]*\[[ \t]*(?:Fact|Theory)[ \t]*(?:\([^\]\r\n]*\))?[ \t]*\]/m,
199
- extensions: new Set(['cs']),
200
- },
201
- {
202
- id: 'xctest-case',
203
- description: 'XCTestCase subclass declaration',
204
- expression: /^[ \t]*(?:final[ \t]+)?(?:class|@interface)[^\r\n:{]*:[ \t]*XCTestCase\b/m,
205
- extensions: new Set(['swift', 'm', 'mm']),
206
- },
207
- {
208
- id: 'python-unittest-case',
209
- description: 'Python unittest.TestCase subclass',
210
- expression: /^[ \t]*class[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]*\([^\r\n)]*\bunittest\.TestCase\b[^\r\n)]*\)[ \t]*:/m,
211
- extensions: new Set(['py']),
212
- },
213
- {
214
- id: 'python-test-function',
215
- description: 'Python unittest or pytest test function registration',
216
- expression: /^[ \t]*(?:async[ \t]+)?def[ \t]+test_[A-Za-z0-9_]*[ \t]*\(/m,
217
- extensions: new Set(['py']),
218
- },
219
- ];
220
- /**
221
- * Symlink outcomes that must stop the review.
222
- *
223
- * A link that resolves outside the repository, into an excluded path, to an
224
- * absolute location, ambiguously, or in a cycle is a containment question and
225
- * blocks. A link whose target simply is not a retained tracked file is not: the
226
- * resolver already declines to materialize it, so the snapshot never contains
227
- * it. Blocking on that as well made the reviewer unreachable on ordinary
228
- * monorepos — one holdout repository had 74 such links, all build-tree
229
- * conveniences pointing at generated or untracked paths.
230
- */
231
- const BLOCKING_SYMLINK_REASONS = new Set([
232
- 'absolute-target',
233
- 'target-escapes-repository',
234
- 'target-is-excluded',
235
- 'target-cycle',
236
- 'ambiguous-target',
237
- 'unreadable-link',
238
- ]);
239
- function parseHeadTreeEntries(raw) {
240
- return raw
241
- .toString('utf8')
242
- .split('\0')
243
- .filter(Boolean)
244
- .map((record) => {
245
- const tab = record.indexOf('\t');
246
- const metadata = tab >= 0 ? record.slice(0, tab) : '';
247
- const path = tab >= 0 ? record.slice(tab + 1) : '';
248
- const [mode = '', , objectId = ''] = metadata.split(' ');
249
- return { mode, objectId, stage: '0', path };
250
- });
251
- }
252
- function readTrackedWorktreeChanges(root, revision) {
253
- const output = execFileSync('git', [
254
- 'diff-index',
255
- '--raw',
256
- '-z',
257
- '--no-ext-diff',
258
- '--no-renames',
259
- '--ignore-submodules=all',
260
- revision,
261
- '--',
262
- ], {
263
- cwd: root,
264
- encoding: 'buffer',
265
- env: GIT_NO_LAZY_FETCH_ENV,
266
- stdio: ['ignore', 'pipe', 'pipe'],
267
- maxBuffer: 64 * 1024 * 1024,
268
- });
269
- const fields = output.toString('utf8').split('\0');
270
- const changes = [];
271
- for (let index = 0; index < fields.length - 1; index += 2) {
272
- const metadata = fields[index] ?? '';
273
- const path = fields[index + 1] ?? '';
274
- const match = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])$/.exec(metadata);
275
- if (!match || path.length === 0)
276
- continue;
277
- const [, headMode, worktreeMode, status] = match;
278
- let reason;
279
- if (status === 'D' || worktreeMode === '000000') {
280
- reason = 'tracked file is missing from the working tree';
281
- }
282
- else if (headMode !== worktreeMode || status === 'T') {
283
- reason = `tracked working-tree type or executable mode differs from Git HEAD (${headMode} -> ${worktreeMode})`;
284
- }
285
- else if (headMode === '120000') {
286
- reason = 'tracked symlink target differs from Git HEAD';
287
- }
288
- else {
289
- reason = 'tracked working-tree content differs from Git HEAD';
290
- }
291
- changes.push({ path, reason });
292
- }
293
- return changes;
294
- }
295
- function createLocalGitBlobReader(root, entries, recordUnavailable) {
296
- const blobEntries = entries.filter((entry) => entry.mode === '100644' || entry.mode === '100755' || entry.mode === '120000');
297
- const objectIds = [...new Set(blobEntries.map((entry) => entry.objectId))];
298
- const blobInfo = new Map();
299
- const unavailableReason = (objectId) => `Git HEAD blob ${objectId} is unavailable locally while lazy fetching is disabled`;
300
- if (objectIds.length > 0) {
301
- try {
302
- const output = execFileSync('git', ['cat-file', '--batch-check'], {
303
- cwd: root,
304
- encoding: 'utf8',
305
- env: GIT_NO_LAZY_FETCH_ENV,
306
- input: `${objectIds.join('\n')}\n`,
307
- stdio: ['pipe', 'pipe', 'pipe'],
308
- maxBuffer: Math.max(1024 * 1024, objectIds.length * 160),
309
- });
310
- const lines = output.trimEnd().split('\n');
311
- for (const [index, objectId] of objectIds.entries()) {
312
- const line = lines[index] ?? '';
313
- const match = /^([0-9a-f]+) blob ([0-9]+)$/.exec(line);
314
- const size = match ? Number(match[2]) : Number.NaN;
315
- if (!match ||
316
- match[1] !== objectId ||
317
- !Number.isSafeInteger(size) ||
318
- size < 0) {
319
- blobInfo.set(objectId, {
320
- kind: 'unavailable',
321
- reason: unavailableReason(objectId),
322
- });
323
- continue;
324
- }
325
- blobInfo.set(objectId, { kind: 'available', size });
326
- }
327
- }
328
- catch {
329
- for (const objectId of objectIds) {
330
- blobInfo.set(objectId, {
331
- kind: 'unavailable',
332
- reason: unavailableReason(objectId),
333
- });
334
- }
335
- }
336
- }
337
- const batches = [];
338
- let pendingObjectIds = [];
339
- let pendingBytes = 0;
340
- const flushBatch = () => {
341
- if (pendingObjectIds.length === 0)
342
- return;
343
- batches.push({ objectIds: pendingObjectIds, payloadBytes: pendingBytes });
344
- pendingObjectIds = [];
345
- pendingBytes = 0;
346
- };
347
- for (const objectId of objectIds) {
348
- const info = blobInfo.get(objectId);
349
- if (!info || info.kind !== 'available')
350
- continue;
351
- if (pendingObjectIds.length > 0 &&
352
- (pendingObjectIds.length >= MAX_GIT_BLOB_BATCH_ENTRIES ||
353
- pendingBytes + info.size > MAX_GIT_BLOB_BATCH_BYTES)) {
354
- flushBatch();
355
- }
356
- pendingObjectIds.push(objectId);
357
- pendingBytes += info.size;
358
- }
359
- flushBatch();
360
- const batchIndexByObjectId = new Map();
361
- for (const [batchIndex, batch] of batches.entries()) {
362
- for (const objectId of batch.objectIds)
363
- batchIndexByObjectId.set(objectId, batchIndex);
364
- }
365
- const cachedBatches = new Map();
366
- const failedBatches = new Set();
367
- const loadBatch = (batchIndex) => {
368
- const cached = cachedBatches.get(batchIndex);
369
- if (cached) {
370
- cachedBatches.delete(batchIndex);
371
- cachedBatches.set(batchIndex, cached);
372
- return cached;
373
- }
374
- if (failedBatches.has(batchIndex))
375
- return null;
376
- const batch = batches[batchIndex];
377
- if (!batch)
378
- return null;
379
- try {
380
- const output = execFileSync('git', ['cat-file', '--batch'], {
381
- cwd: root,
382
- encoding: 'buffer',
383
- env: GIT_NO_LAZY_FETCH_ENV,
384
- input: Buffer.from(`${batch.objectIds.join('\n')}\n`, 'utf8'),
385
- stdio: ['pipe', 'pipe', 'pipe'],
386
- maxBuffer: batch.payloadBytes + batch.objectIds.length * 160 + 1024,
387
- });
388
- const contentsByObjectId = new Map();
389
- let cursor = 0;
390
- for (const expectedObjectId of batch.objectIds) {
391
- const headerEnd = output.indexOf(0x0a, cursor);
392
- if (headerEnd < 0)
393
- throw new Error('truncated Git cat-file header');
394
- const header = output.toString('utf8', cursor, headerEnd);
395
- const match = /^([0-9a-f]+) blob ([0-9]+)$/.exec(header);
396
- const size = match ? Number(match[2]) : Number.NaN;
397
- if (!match ||
398
- match[1] !== expectedObjectId ||
399
- !Number.isSafeInteger(size) ||
400
- size < 0) {
401
- throw new Error('unexpected Git cat-file response');
402
- }
403
- const contentsStart = headerEnd + 1;
404
- const contentsEnd = contentsStart + size;
405
- if (contentsEnd >= output.length || output[contentsEnd] !== 0x0a) {
406
- throw new Error('truncated Git cat-file payload');
407
- }
408
- contentsByObjectId.set(expectedObjectId, output.subarray(contentsStart, contentsEnd));
409
- cursor = contentsEnd + 1;
410
- }
411
- if (cursor !== output.length)
412
- throw new Error('unexpected trailing Git cat-file output');
413
- cachedBatches.set(batchIndex, contentsByObjectId);
414
- while (cachedBatches.size > 2) {
415
- const oldest = cachedBatches.keys().next().value;
416
- if (oldest === undefined)
417
- break;
418
- cachedBatches.delete(oldest);
419
- }
420
- return contentsByObjectId;
421
- }
422
- catch {
423
- failedBatches.add(batchIndex);
424
- return null;
425
- }
426
- };
427
- const inspect = (path, entry) => {
428
- const info = blobInfo.get(entry.objectId);
429
- const batchIndex = batchIndexByObjectId.get(entry.objectId);
430
- if (!info || info.kind !== 'available' || batchIndex === undefined) {
431
- recordUnavailable(path, info?.kind === 'unavailable' ? info.reason : unavailableReason(entry.objectId));
432
- return null;
433
- }
434
- return { size: info.size, batchIndex };
435
- };
436
- return {
437
- size(path, entry) {
438
- return inspect(path, entry)?.size ?? null;
439
- },
440
- read(path, entry) {
441
- const inspected = inspect(path, entry);
442
- if (!inspected)
443
- return null;
444
- const contents = loadBatch(inspected.batchIndex)?.get(entry.objectId);
445
- if (!contents) {
446
- recordUnavailable(path, unavailableReason(entry.objectId));
447
- return null;
448
- }
449
- return contents;
450
- },
451
- };
452
- }
453
- function constrainedLimit(value, hardLimit, label) {
454
- if (value === undefined)
455
- return hardLimit;
456
- if (!Number.isSafeInteger(value) || value <= 0 || value > hardLimit) {
457
- throw new Error(`${label} must be a positive safe integer no greater than ${hardLimit}`);
458
- }
459
- return value;
460
- }
461
- const CONDITIONAL_EXCLUSION_RULE = {
462
- id: 'detected-framework-roots',
463
- description: 'when a test framework is detected, exclude repository-root spec/specs and fixture/mock/snapshot components below a detected test root',
464
- matches: (path) => {
465
- const components = path.split('/').map((component) => component.toLowerCase());
466
- if (components[0] === 'spec' || components[0] === 'specs')
467
- return true;
468
- const testRootIndex = components.findIndex((component) => DENIED_COMPONENT_SET.has(component));
469
- return (testRootIndex >= 0 &&
470
- components.some((component, index) => index > testRootIndex &&
471
- CONDITIONAL_COMPONENTS.includes(component)));
472
- },
473
- };
474
- function exclusionFor(path, conditionalFrameworkRootsActive) {
475
- const direct = EXCLUSION_RULES.find((rule) => rule.matches(path));
476
- if (direct)
477
- return direct;
478
- return conditionalFrameworkRootsActive && CONDITIONAL_EXCLUSION_RULE.matches(path)
479
- ? CONDITIONAL_EXCLUSION_RULE
480
- : undefined;
481
- }
482
- function detectsTestFramework(entries, readVerifiedContents) {
483
- if (entries.some((entry) => basename(entry.path).toLowerCase() === '.rspec')) {
484
- return true;
485
- }
486
- const rootSpecTestBasename = /(?:[_.](?:spec|test)|^spec_helper)\.(?:rb|js|jsx|ts|tsx|mjs|cjs)$/i;
487
- if (entries.some((entry) => {
488
- const components = entry.path.split('/');
489
- return ((components[0]?.toLowerCase() === 'spec' ||
490
- components[0]?.toLowerCase() === 'specs') &&
491
- (isTypedTestBasename(entry.path) || rootSpecTestBasename.test(basename(entry.path))));
492
- })) {
493
- return true;
494
- }
495
- const frameworkFiles = new Map([
496
- ['package.json', /\b(?:jest|mocha)\b/i],
497
- ['gemfile', /\brspec\b/i],
498
- ['gemfile.lock', /\brspec\b/i],
499
- ['pyproject.toml', /\bpytest\b/i],
500
- ]);
501
- return entries.some((entry) => {
502
- const marker = frameworkFiles.get(basename(entry.path).toLowerCase());
503
- if (!marker || (entry.mode !== '100644' && entry.mode !== '100755'))
504
- return false;
505
- const contents = readVerifiedContents(entry.path, entry);
506
- return contents !== null &&
507
- contents.length <= MAX_INLINE_SCAN_BYTES &&
508
- marker.test(contents.toString('utf8'));
509
- });
510
- }
511
- function isInside(root, candidate) {
512
- const rel = relative(root, candidate);
513
- return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
514
- }
515
- function fileSystemPath(root, gitPath) {
516
- return resolve(root, ...gitPath.split('/'));
517
- }
518
- function representativeSample(values, fullReceipt) {
519
- if (fullReceipt || values.length <= COMPACT_RECEIPT_SAMPLE_LIMIT)
520
- return [...values];
521
- const headCount = Math.ceil(COMPACT_RECEIPT_SAMPLE_LIMIT / 2);
522
- const tailCount = COMPACT_RECEIPT_SAMPLE_LIMIT - headCount;
523
- return [...values.slice(0, headCount), ...values.slice(-tailCount)];
524
- }
525
- function collectionSummary(total, returned) {
526
- return { total, returned, truncated: total - returned };
527
- }
528
- function inlineCandidates(path, contents) {
529
- if (contents.includes(0) || contents.length > MAX_INLINE_SCAN_BYTES)
530
- return [];
531
- const text = contents.toString('utf8');
532
- const extension = splitStemAndExtension(basename(path)).extension;
533
- const found = INLINE_MARKER_RULES.some((rule) => {
534
- if (rule.extensions && !rule.extensions.has(extension))
535
- return false;
536
- rule.expression.lastIndex = 0;
537
- return rule.expression.test(text);
538
- });
539
- return found ? [{ path }] : [];
540
- }
541
- function readTrackedEntries(root, revision) {
542
- const output = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', revision], {
543
- cwd: root,
544
- encoding: 'buffer',
545
- env: GIT_NO_LAZY_FETCH_ENV,
546
- stdio: ['ignore', 'pipe', 'pipe'],
547
- maxBuffer: 256 * 1024 * 1024,
548
- });
549
- return parseHeadTreeEntries(output);
550
- }
551
- function approvedInactiveSubmodulePaths(requestedPaths, trackedEntries) {
552
- const gitlinks = new Set(trackedEntries
553
- .filter((entry) => entry.stage === '0' && entry.mode === '160000')
554
- .map((entry) => entry.path));
555
- const approved = new Set();
556
- for (const requestedPath of requestedPaths) {
557
- const normalized = posix.normalize(requestedPath);
558
- const unsafe = requestedPath.length === 0 ||
559
- requestedPath.includes('\0') ||
560
- requestedPath.includes('\\') ||
561
- requestedPath.endsWith('/') ||
562
- normalized !== requestedPath ||
563
- normalized === '.' ||
564
- normalized === '..' ||
565
- normalized.startsWith('../') ||
566
- posix.isAbsolute(requestedPath) ||
567
- isAbsolute(requestedPath) ||
568
- /^[A-Za-z]:/.test(requestedPath);
569
- if (unsafe) {
570
- throw new Error(`inactive submodule approval must be an exact normalized repository-relative POSIX path: ${JSON.stringify(requestedPath)}`);
571
- }
572
- if (!gitlinks.has(requestedPath)) {
573
- throw new Error(`inactive submodule approval is not a tracked Git submodule: ${JSON.stringify(requestedPath)}`);
574
- }
575
- approved.add(requestedPath);
576
- }
577
- return [...approved].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
578
- }
579
- function resolveMaterializedSymlink(root, sourcePath, outputPath, retainedEntries, conditionalFrameworkRootsActive, symlinkIssues, readRegularEntry, readSymlinkTarget) {
580
- const seen = new Set();
581
- let currentPath = sourcePath;
582
- let firstTarget = '';
583
- for (;;) {
584
- if (seen.has(currentPath)) {
585
- symlinkIssues.push({
586
- path: sourcePath,
587
- target: firstTarget,
588
- reason: 'target-cycle',
589
- });
590
- return null;
591
- }
592
- seen.add(currentPath);
593
- const currentEntry = retainedEntries.get(currentPath);
594
- if (!currentEntry) {
595
- const prefix = currentPath === '' ? '' : `${currentPath}/`;
596
- const descendants = [...retainedEntries.entries()]
597
- .filter(([path]) => prefix === '' || path.startsWith(prefix))
598
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
599
- if (descendants.length === 0) {
600
- symlinkIssues.push({
601
- path: sourcePath,
602
- target: firstTarget,
603
- reason: 'target-is-not-retained-file',
604
- });
605
- return null;
606
- }
607
- if (prefix === '' || sourcePath === currentPath || sourcePath.startsWith(prefix)) {
608
- symlinkIssues.push({
609
- path: sourcePath,
610
- target: firstTarget,
611
- reason: 'target-cycle',
612
- });
613
- return null;
614
- }
615
- return {
616
- kind: 'directory',
617
- targetPath: currentPath,
618
- descendants,
619
- firstTarget,
620
- };
621
- }
622
- if (currentEntry.mode !== '120000') {
623
- if (currentEntry.mode !== '100644' && currentEntry.mode !== '100755') {
624
- symlinkIssues.push({
625
- path: sourcePath,
626
- target: firstTarget,
627
- reason: 'target-is-not-retained-file',
628
- });
629
- return null;
630
- }
631
- const regular = readRegularEntry(currentPath, currentEntry, outputPath);
632
- if (!regular)
633
- return null;
634
- return {
635
- kind: 'file',
636
- contents: regular.contents,
637
- targetPath: currentPath,
638
- targetMode: regular.mode,
639
- firstTarget,
640
- };
641
- }
642
- const target = readSymlinkTarget(currentPath, currentEntry);
643
- if (target === null)
644
- return null;
645
- if (currentPath === sourcePath)
646
- firstTarget = target;
647
- if (isAbsolute(target)) {
648
- symlinkIssues.push({ path: sourcePath, target: firstTarget, reason: 'absolute-target' });
649
- return null;
650
- }
651
- const resolvedTarget = resolve(root, dirname(currentPath), target);
652
- if (!isInside(root, resolvedTarget)) {
653
- symlinkIssues.push({
654
- path: sourcePath,
655
- target: firstTarget,
656
- reason: 'target-escapes-repository',
657
- });
658
- return null;
659
- }
660
- const relativeTarget = relative(root, resolvedTarget).split(sep).join('/');
661
- if (exclusionFor(relativeTarget, conditionalFrameworkRootsActive)) {
662
- symlinkIssues.push({
663
- path: sourcePath,
664
- target: firstTarget,
665
- reason: 'target-is-excluded',
666
- });
667
- return null;
668
- }
669
- currentPath = relativeTarget;
670
- }
671
- }
672
- export function prepareUniverseReview(root, options = {}) {
673
- const omitUnscannable = options.omitUnscannableFiles === true;
674
- const repositoryRoot = realpathSync(root);
675
- const sourceCommit = execFileSync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
676
- cwd: repositoryRoot,
677
- encoding: 'utf8',
678
- env: GIT_NO_LAZY_FETCH_ENV,
679
- stdio: ['ignore', 'pipe', 'pipe'],
680
- }).trim();
681
- const trackedEntries = readTrackedEntries(repositoryRoot, sourceCommit);
682
- const maxSnapshotEntries = constrainedLimit(options.limits?.maxSnapshotEntries, MAX_SNAPSHOT_ENTRY_COUNT, 'limits.maxSnapshotEntries');
683
- const maxSnapshotOutputBytes = constrainedLimit(options.limits?.maxSnapshotOutputBytes, MAX_SNAPSHOT_OUTPUT_BYTES, 'limits.maxSnapshotOutputBytes');
684
- const approvedSubmodulePaths = approvedInactiveSubmodulePaths(options.excludeInactiveSubmodules ?? [], trackedEntries);
685
- const approvedSubmodulePathSet = new Set(approvedSubmodulePaths);
686
- const exclusions = [];
687
- const submodules = [];
688
- const approvedSubmodules = [];
689
- const missingOrChangedType = [];
690
- const changedPathKeys = new Set();
691
- const recordTrackedChange = (path, reason) => {
692
- const key = `${path}\0${reason}`;
693
- if (changedPathKeys.has(key))
694
- return;
695
- changedPathKeys.add(key);
696
- missingOrChangedType.push({ path, reason });
697
- };
698
- const dirtyTrackedPaths = new Set();
699
- const recordWorktreeChanges = () => {
700
- for (const change of readTrackedWorktreeChanges(repositoryRoot, sourceCommit)) {
701
- dirtyTrackedPaths.add(change.path);
702
- recordTrackedChange(change.path, change.reason);
703
- }
704
- };
705
- recordWorktreeChanges();
706
- const localHeadBlobs = createLocalGitBlobReader(repositoryRoot, trackedEntries, recordTrackedChange);
707
- const readHeadRegularContents = (path, entry) => {
708
- if (dirtyTrackedPaths.has(path))
709
- return null;
710
- if (entry.mode !== '100644' && entry.mode !== '100755') {
711
- recordTrackedChange(path, `unsupported Git mode ${entry.mode}`);
712
- return null;
713
- }
714
- return localHeadBlobs.read(path, entry);
715
- };
716
- const readHeadSymlinkTarget = (path, entry) => {
717
- if (dirtyTrackedPaths.has(path))
718
- return null;
719
- if (entry.mode !== '120000') {
720
- recordTrackedChange(path, `unsupported Git mode ${entry.mode}`);
721
- return null;
722
- }
723
- const contents = localHeadBlobs.read(path, entry);
724
- return contents?.toString('utf8') ?? null;
725
- };
726
- const conditionalFrameworkRootsActive = detectsTestFramework(trackedEntries, readHeadRegularContents);
727
- const scratchRoot = mkdtempSync(join(tmpdir(), 'haystack-universe-review-'));
728
- chmodSync(scratchRoot, 0o700);
729
- const snapshotRoot = join(scratchRoot, 'repository');
730
- mkdirSync(snapshotRoot, { mode: 0o700 });
731
- const symlinkIssues = [];
732
- const candidates = [];
733
- const inlineScanSkipped = [];
734
- const retainedEntries = new Map();
735
- for (const entry of trackedEntries) {
736
- if (entry.mode === '160000') {
737
- if (approvedSubmodulePathSet.has(entry.path)) {
738
- approvedSubmodules.push(entry);
739
- exclusions.push({
740
- path: entry.path,
741
- rule_id: APPROVED_INACTIVE_SUBMODULE_RULE.id,
742
- });
743
- }
744
- else {
745
- submodules.push(entry);
746
- }
747
- continue;
748
- }
749
- const rule = exclusionFor(entry.path, conditionalFrameworkRootsActive);
750
- if (rule) {
751
- exclusions.push({ path: entry.path, rule_id: rule.id });
752
- }
753
- else if (entry.stage !== '0') {
754
- missingOrChangedType.push({
755
- path: entry.path,
756
- reason: `unmerged Git index stage ${entry.stage}`,
757
- });
758
- }
759
- else {
760
- retainedEntries.set(entry.path, entry);
761
- }
762
- }
763
- const manifestEntries = [];
764
- const materializedPaths = new Set();
765
- const omittedUnscannable = [];
766
- const snapshotLimitBlockers = [];
767
- const snapshotLimitKinds = new Set();
768
- let copiedBytes = 0;
769
- let symlinksMaterialized = 0;
770
- const recordSnapshotLimit = (kind, path, limit, attempted, reason) => {
771
- if (snapshotLimitKinds.has(kind))
772
- return;
773
- snapshotLimitKinds.add(kind);
774
- snapshotLimitBlockers.push({ kind, path, limit, attempted, reason });
775
- };
776
- if (retainedEntries.size > maxSnapshotEntries) {
777
- const firstBlockedPath = [...retainedEntries.keys()].sort()[maxSnapshotEntries] ?? '(snapshot)';
778
- recordSnapshotLimit('entry-count', firstBlockedPath, maxSnapshotEntries, retainedEntries.size, 'retained HEAD entries exceed the deterministic snapshot entry ceiling');
779
- }
780
- const canMaterialize = (outputPath, size) => {
781
- if (snapshotLimitBlockers.length > 0)
782
- return false;
783
- const attemptedEntries = manifestEntries.length + 1;
784
- if (attemptedEntries > maxSnapshotEntries) {
785
- recordSnapshotLimit('entry-count', outputPath, maxSnapshotEntries, attemptedEntries, 'materialized snapshot entries exceed the deterministic entry ceiling');
786
- return false;
787
- }
788
- const attemptedBytes = copiedBytes + size;
789
- if (attemptedBytes > maxSnapshotOutputBytes) {
790
- recordSnapshotLimit('total-output-bytes', outputPath, maxSnapshotOutputBytes, attemptedBytes, 'materialized snapshot bytes exceed the deterministic output ceiling');
791
- return false;
792
- }
793
- return true;
794
- };
795
- const materializeFile = (outputPath, mode, contents, kind, linkTarget) => {
796
- const destination = fileSystemPath(snapshotRoot, outputPath);
797
- if (!isInside(snapshotRoot, destination) || materializedPaths.has(outputPath)) {
798
- missingOrChangedType.push({
799
- path: outputPath,
800
- reason: materializedPaths.has(outputPath)
801
- ? 'ambiguous materialized destination'
802
- : 'unsafe tracked path',
803
- });
804
- return;
805
- }
806
- if (!canMaterialize(outputPath, contents.length))
807
- return;
808
- materializedPaths.add(outputPath);
809
- // Decide before writing. A file whose test content cannot be ruled out is
810
- // omitted from the snapshot rather than blocking the whole review: omitting
811
- // it satisfies the same guarantee — the reviewer never sees a test — and it
812
- // is recorded, so the omission is visible rather than silent. Blocking
813
- // instead made the reviewer unreachable on most real repositories.
814
- //
815
- // Binary detection stays ahead of the size guard: large binary assets are
816
- // safe to retain and do not make inline-test isolation unknown.
817
- const isBinary = contents.includes(0);
818
- if (!isBinary && omitUnscannable) {
819
- if (contents.length > MAX_INLINE_SCAN_BYTES) {
820
- inlineScanSkipped.push({
821
- path: outputPath,
822
- reason: 'file exceeds inline scan size limit',
823
- });
824
- omittedUnscannable.push({
825
- path: outputPath,
826
- reason: 'text file too large to rule out inline tests',
827
- });
828
- return;
829
- }
830
- const found = inlineCandidates(outputPath, contents);
831
- if (found.length > 0) {
832
- candidates.push(...found);
833
- omittedUnscannable.push({
834
- path: outputPath,
835
- reason: 'inline test declaration detected',
836
- });
837
- return;
838
- }
839
- }
840
- mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
841
- writeFileSync(destination, contents, { mode: mode === '100755' ? 0o700 : 0o600 });
842
- manifestEntries.push({
843
- path: outputPath,
844
- mode,
845
- kind,
846
- size: contents.length,
847
- sha256: createHash('sha256').update(contents).digest('hex'),
848
- ...(linkTarget === undefined ? {} : { link_target: linkTarget }),
849
- });
850
- copiedBytes += contents.length;
851
- if (isBinary) {
852
- inlineScanSkipped.push({ path: outputPath, reason: 'binary file' });
853
- }
854
- else if (!omitUnscannable) {
855
- if (contents.length > MAX_INLINE_SCAN_BYTES) {
856
- inlineScanSkipped.push({
857
- path: outputPath,
858
- reason: 'file exceeds inline scan size limit',
859
- });
860
- }
861
- else {
862
- candidates.push(...inlineCandidates(outputPath, contents));
863
- }
864
- }
865
- };
866
- const readRegularEntry = (path, entry, outputPath = path) => {
867
- if (entry.mode !== '100644' && entry.mode !== '100755') {
868
- missingOrChangedType.push({ path, reason: `unsupported Git mode ${entry.mode}` });
869
- return null;
870
- }
871
- const size = localHeadBlobs.size(path, entry);
872
- if (size === null || !canMaterialize(outputPath, size))
873
- return null;
874
- const contents = readHeadRegularContents(path, entry);
875
- return contents === null ? null : { contents, mode: entry.mode };
876
- };
877
- for (const [gitPath, entry] of [...retainedEntries.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
878
- if (dirtyTrackedPaths.has(gitPath) || snapshotLimitBlockers.length > 0)
879
- continue;
880
- if (entry.mode === '120000') {
881
- const resolved = resolveMaterializedSymlink(repositoryRoot, gitPath, gitPath, retainedEntries, conditionalFrameworkRootsActive, symlinkIssues, readRegularEntry, readHeadSymlinkTarget);
882
- if (!resolved)
883
- continue;
884
- symlinksMaterialized += 1;
885
- if (resolved.kind === 'file') {
886
- materializeFile(gitPath, resolved.targetMode, resolved.contents, 'materialized-symlink', resolved.firstTarget);
887
- continue;
888
- }
889
- const aliasEntries = resolved.descendants.map(([targetPath, targetEntry]) => {
890
- const suffix = targetPath.slice(resolved.targetPath === '' ? 0 : resolved.targetPath.length + 1);
891
- return {
892
- outputPath: `${gitPath}/${suffix}`,
893
- targetPath,
894
- targetEntry,
895
- };
896
- });
897
- const excludedAlias = aliasEntries.find(({ outputPath }) => exclusionFor(outputPath, conditionalFrameworkRootsActive));
898
- if (excludedAlias) {
899
- symlinkIssues.push({
900
- path: gitPath,
901
- target: resolved.firstTarget,
902
- reason: 'target-is-excluded',
903
- });
904
- continue;
905
- }
906
- for (const alias of aliasEntries) {
907
- if (alias.targetEntry.mode === '120000') {
908
- const nested = resolveMaterializedSymlink(repositoryRoot, alias.targetPath, alias.outputPath, retainedEntries, conditionalFrameworkRootsActive, symlinkIssues, readRegularEntry, readHeadSymlinkTarget);
909
- if (!nested)
910
- continue;
911
- if (nested.kind === 'directory') {
912
- symlinkIssues.push({
913
- path: alias.targetPath,
914
- target: nested.firstTarget,
915
- reason: 'ambiguous-target',
916
- });
917
- continue;
918
- }
919
- materializeFile(alias.outputPath, nested.targetMode, nested.contents, 'materialized-symlink', nested.firstTarget);
920
- continue;
921
- }
922
- const regular = readRegularEntry(alias.targetPath, alias.targetEntry, alias.outputPath);
923
- if (regular) {
924
- materializeFile(alias.outputPath, regular.mode, regular.contents, 'materialized-symlink', resolved.firstTarget);
925
- }
926
- }
927
- continue;
928
- }
929
- const regular = readRegularEntry(gitPath, entry);
930
- if (regular)
931
- materializeFile(gitPath, regular.mode, regular.contents, 'file');
932
- }
933
- // Catch tracked changes that raced the initial status check. Snapshot payloads
934
- // come only from sourceCommit's local Git blobs, never from working-tree bytes.
935
- recordWorktreeChanges();
936
- const finalHead = execFileSync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
937
- cwd: repositoryRoot,
938
- encoding: 'utf8',
939
- env: GIT_NO_LAZY_FETCH_ENV,
940
- stdio: ['ignore', 'pipe', 'pipe'],
941
- }).trim();
942
- if (finalHead !== sourceCommit) {
943
- recordTrackedChange('(repository HEAD)', `repository HEAD changed during snapshot (${sourceCommit} -> ${finalHead})`);
944
- }
945
- const compareText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
946
- exclusions.sort((a, b) => compareText(a.path, b.path));
947
- symlinkIssues.sort((a, b) => compareText(a.path, b.path));
948
- candidates.sort((a, b) => compareText(a.path, b.path));
949
- inlineScanSkipped.sort((a, b) => compareText(a.path, b.path));
950
- missingOrChangedType.sort((a, b) => compareText(a.path, b.path));
951
- snapshotLimitBlockers.sort((a, b) => compareText(a.path, b.path));
952
- const binaryInlineScanSkips = inlineScanSkipped.filter((entry) => entry.reason === 'binary file');
953
- const blockingInlineScanSkips = inlineScanSkipped.filter((entry) => entry.reason !== 'binary file');
954
- const returnedExclusions = representativeSample(exclusions, options.fullReceipt === true);
955
- const returnedApprovedSubmodulePaths = representativeSample(approvedSubmodulePaths, options.fullReceipt === true);
956
- const returnedBinaryInlineScanSkips = representativeSample(binaryInlineScanSkips, options.fullReceipt === true);
957
- const returnedInlineScanSkips = [
958
- ...returnedBinaryInlineScanSkips,
959
- ...blockingInlineScanSkips,
960
- ].sort((a, b) => compareText(a.path, b.path));
961
- const activeRules = [
962
- ...EXCLUSION_RULES,
963
- ...(conditionalFrameworkRootsActive ? [CONDITIONAL_EXCLUSION_RULE] : []),
964
- ...(approvedSubmodulePaths.length > 0 ? [APPROVED_INACTIVE_SUBMODULE_RULE] : []),
965
- ].map(({ id, description }) => ({ id, description }));
966
- const activatedConditionalRuleIds = conditionalFrameworkRootsActive
967
- ? [CONDITIONAL_EXCLUSION_RULE.id]
968
- : [];
969
- const blockingSymlinkIssues = symlinkIssues.filter((issue) => BLOCKING_SYMLINK_REASONS.has(issue.reason));
970
- const omittedSymlinks = symlinkIssues.filter((issue) => !BLOCKING_SYMLINK_REASONS.has(issue.reason));
971
- const manifestDocument = {
972
- version: 1,
973
- source_commit: sourceCommit,
974
- policy: {
975
- rules: activeRules,
976
- denied_components: DENIED_COMPONENTS,
977
- denied_basename_globs: DENIED_BASENAME_GLOBS,
978
- conditional_components: CONDITIONAL_COMPONENTS,
979
- activated_conditional_rule_ids: activatedConditionalRuleIds,
980
- approved_inactive_submodule_omissions: {
981
- rule: APPROVED_INACTIVE_SUBMODULE_RULE,
982
- paths: approvedSubmodulePaths,
983
- },
984
- substring_test_matching: false,
985
- snapshot_limits: {
986
- max_entries: maxSnapshotEntries,
987
- max_output_bytes: maxSnapshotOutputBytes,
988
- },
989
- },
990
- audit_receipt: {
991
- exclusions,
992
- submodules_omitted: submodules.map((entry) => entry.path).sort(compareText),
993
- approved_inactive_submodule_omissions: approvedSubmodulePaths,
994
- symlink_issues: blockingSymlinkIssues,
995
- omitted_symlinks: omittedSymlinks,
996
- omitted_unscannable: omittedUnscannable,
997
- missing_or_changed_type: missingOrChangedType,
998
- inline_test_candidates: candidates,
999
- inline_scan_skipped: inlineScanSkipped,
1000
- snapshot_limit_blockers: snapshotLimitBlockers,
1001
- },
1002
- entries: manifestEntries,
1003
- };
1004
- const manifestBytes = Buffer.from(`${JSON.stringify(manifestDocument, null, 2)}\n`, 'utf8');
1005
- const manifestPath = join(scratchRoot, MANIFEST_NAME);
1006
- writeFileSync(manifestPath, manifestBytes, { mode: 0o600 });
1007
- const blocked = (!omitUnscannable && candidates.length > 0) ||
1008
- (!omitUnscannable && blockingInlineScanSkips.length > 0) ||
1009
- blockingSymlinkIssues.length > 0 ||
1010
- missingOrChangedType.length > 0 ||
1011
- submodules.length > 0 ||
1012
- snapshotLimitBlockers.length > 0;
1013
- return {
1014
- status: blocked ? 'blocked' : 'ready-for-confinement',
1015
- path: snapshotRoot,
1016
- process_confinement_required: true,
1017
- counts: {
1018
- tracked: trackedEntries.length,
1019
- retained: retainedEntries.size,
1020
- copied: manifestEntries.length,
1021
- excluded: exclusions.length,
1022
- submodules_omitted: submodules.length,
1023
- approved_inactive_submodules_omitted: approvedSubmodules.length,
1024
- symlinks_materialized: symlinksMaterialized,
1025
- symlink_issues: blockingSymlinkIssues.length,
1026
- symlinks_omitted_target_not_retained: omittedSymlinks.length,
1027
- omitted_unscannable: omittedUnscannable.length,
1028
- missing_or_changed_type: missingOrChangedType.length,
1029
- inline_test_candidates: candidates.length,
1030
- inline_scan_skipped: inlineScanSkipped.length,
1031
- binary_inline_scan_skipped: binaryInlineScanSkips.length,
1032
- oversized_text_inline_scan_blockers: blockingInlineScanSkips.length,
1033
- snapshot_limit_blockers: snapshotLimitBlockers.length,
1034
- copied_bytes: copiedBytes,
1035
- },
1036
- exclusions: returnedExclusions,
1037
- submodules_omitted: submodules.map((entry) => entry.path).sort(compareText),
1038
- approved_inactive_submodule_omissions: {
1039
- rule: APPROVED_INACTIVE_SUBMODULE_RULE,
1040
- paths: returnedApprovedSubmodulePaths,
1041
- },
1042
- rules: activeRules,
1043
- activated_conditional_rule_ids: activatedConditionalRuleIds,
1044
- inline_marker_rules: INLINE_MARKER_RULES.map(({ id, description }) => ({
1045
- id,
1046
- description,
1047
- })),
1048
- symlink_issues: blockingSymlinkIssues,
1049
- omitted_symlinks: omittedSymlinks,
1050
- omitted_unscannable: omittedUnscannable,
1051
- missing_or_changed_type: missingOrChangedType,
1052
- inline_test_candidates: candidates,
1053
- inline_scan_skipped: returnedInlineScanSkips,
1054
- snapshot_limit_blockers: snapshotLimitBlockers,
1055
- receipt: {
1056
- detail: options.fullReceipt === true ? 'full' : 'compact',
1057
- sample_limit: COMPACT_RECEIPT_SAMPLE_LIMIT,
1058
- complete_blocking_details: true,
1059
- collections: {
1060
- exclusions: collectionSummary(exclusions.length, returnedExclusions.length),
1061
- approved_inactive_submodule_omissions: collectionSummary(approvedSubmodulePaths.length, returnedApprovedSubmodulePaths.length),
1062
- binary_inline_scan_skips: collectionSummary(binaryInlineScanSkips.length, returnedBinaryInlineScanSkips.length),
1063
- },
1064
- },
1065
- manifest: {
1066
- path: manifestPath,
1067
- sha256: `sha256:${createHash('sha256').update(manifestBytes).digest('hex')}`,
1068
- entries: manifestEntries.length,
1069
- },
1070
- };
1071
- }
1072
- export async function prepareUniverseReviewCommand(options) {
1073
- const root = findGitRoot();
1074
- if (!root)
1075
- throw new Error('prepare-universe-review must be run inside a Git repository');
1076
- const result = prepareUniverseReview(root, {
1077
- excludeInactiveSubmodules: options.excludeInactiveSubmodule,
1078
- omitUnscannableFiles: options.omitUnscannableFiles,
1079
- fullReceipt: options.fullReceipt,
1080
- });
1081
- if (options.json) {
1082
- console.log(JSON.stringify(result, null, 2));
1083
- }
1084
- else {
1085
- console.log(`Review snapshot: ${result.path}`);
1086
- console.log(`Isolation status: ${result.status}`);
1087
- console.log(`Copied ${result.counts.copied} of ${result.counts.tracked} tracked entries.`);
1088
- console.log('The reviewer process still requires OS-level network and filesystem confinement.');
1089
- }
1090
- if (result.status === 'blocked')
1091
- process.exitCode = 1;
1092
- }