gitnexus 1.6.13-rc.80 → 1.6.13-rc.82
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/dist/core/ingestion/languages/dart/package-config.d.ts +9 -2
- package/dist/core/ingestion/languages/dart/package-config.js +38 -9
- package/dist/core/run-analyze.js +27 -12
- package/dist/core/search/fts-indexes.d.ts +12 -1
- package/dist/core/search/fts-indexes.js +79 -33
- package/package.json +2 -1
- package/scripts/cross-platform-tests.ts +6 -3
- package/scripts/execution-reporter.ts +33 -0
- package/scripts/run-benchmarks.ts +32 -0
- package/scripts/run-cross-platform.ts +12 -1
- package/scripts/test-completeness.ts +203 -0
|
@@ -15,14 +15,21 @@ export interface DartPackageConfigOptions {
|
|
|
15
15
|
* is listed and before its entries are opened.
|
|
16
16
|
*/
|
|
17
17
|
readonly beforeEntryOpen?: (relativePath: string) => void | Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Test seam. Production calls omit it. Invoked after the directory is
|
|
20
|
+
* opened and before it is listed.
|
|
21
|
+
*/
|
|
22
|
+
readonly beforeDirectoryList?: (relativePath: string) => void | Promise<void>;
|
|
18
23
|
}
|
|
19
24
|
/** Linux anchors child opens. macOS verifies them. Every other platform does neither. */
|
|
20
25
|
export declare function pubspecWalkAnchored(): boolean;
|
|
21
26
|
export declare function directoryOpenFlags(): number;
|
|
22
27
|
/**
|
|
23
28
|
* Path that lists the directory inode already open on `fd`.
|
|
24
|
-
* Linux
|
|
25
|
-
*
|
|
29
|
+
* Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on
|
|
30
|
+
* `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the
|
|
31
|
+
* walker lists the lexical path and verifies the pinned chain around it.
|
|
32
|
+
* Every other platform returns null and is not walked.
|
|
26
33
|
*/
|
|
27
34
|
export declare function descriptorDirectoryPath(fd: number): string | null;
|
|
28
35
|
/**
|
|
@@ -94,14 +94,14 @@ function directoryIdentity(stat) {
|
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
96
|
* Path that lists the directory inode already open on `fd`.
|
|
97
|
-
* Linux
|
|
98
|
-
*
|
|
97
|
+
* Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on
|
|
98
|
+
* `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the
|
|
99
|
+
* walker lists the lexical path and verifies the pinned chain around it.
|
|
100
|
+
* Every other platform returns null and is not walked.
|
|
99
101
|
*/
|
|
100
102
|
export function descriptorDirectoryPath(fd) {
|
|
101
103
|
if (process.platform === 'linux')
|
|
102
104
|
return `/proc/self/fd/${fd}`;
|
|
103
|
-
if (process.platform === 'darwin')
|
|
104
|
-
return `/dev/fd/${fd}`;
|
|
105
105
|
return null;
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
@@ -204,11 +204,31 @@ async function openChildFile(frames, parentFd, name, lexicalPath) {
|
|
|
204
204
|
return openLexicalFile(frames, lexicalPath);
|
|
205
205
|
throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' });
|
|
206
206
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
/**
|
|
208
|
+
* List the directory open on the last frame of `frames`. Linux lists the
|
|
209
|
+
* pinned inode through its descriptor. macOS re-checks the pinned chain, lists
|
|
210
|
+
* the lexical path, then re-checks the chain, so a path replaced around the
|
|
211
|
+
* listing fails the walk instead of being listed.
|
|
212
|
+
*/
|
|
213
|
+
async function listOpenedDirectory(frames, entryLimit, beforeList) {
|
|
214
|
+
const frame = frames[frames.length - 1];
|
|
215
|
+
if (frame === undefined) {
|
|
216
|
+
throw Object.assign(new Error('no directory to list'), { code: 'EINVAL' });
|
|
217
|
+
}
|
|
218
|
+
const anchored = descriptorDirectoryPath(frame.handle.fd);
|
|
219
|
+
if (anchored === null && process.platform !== 'darwin') {
|
|
210
220
|
throw Object.assign(new Error('no descriptor listing'), { code: 'ENOTSUP' });
|
|
211
221
|
}
|
|
222
|
+
if (anchored === null)
|
|
223
|
+
await assertPinnedChain(frames);
|
|
224
|
+
if (beforeList)
|
|
225
|
+
await beforeList();
|
|
226
|
+
const entries = await readDirectoryBounded(anchored ?? frame.absolute, entryLimit);
|
|
227
|
+
if (anchored === null)
|
|
228
|
+
await assertPinnedChain(frames);
|
|
229
|
+
return entries;
|
|
230
|
+
}
|
|
231
|
+
async function readDirectoryBounded(listing, entryLimit) {
|
|
212
232
|
const dir = await opendir(listing);
|
|
213
233
|
const entries = [];
|
|
214
234
|
try {
|
|
@@ -236,8 +256,16 @@ async function listOpenedDirectory(handle, entryLimit) {
|
|
|
236
256
|
*/
|
|
237
257
|
export async function readDirectoryNoFollow(directory) {
|
|
238
258
|
const opened = await openVerifiedDirectory(directory);
|
|
259
|
+
const frame = {
|
|
260
|
+
relative: '',
|
|
261
|
+
absolute: directory,
|
|
262
|
+
handle: opened.handle,
|
|
263
|
+
identity: opened.identity,
|
|
264
|
+
entries: [],
|
|
265
|
+
next: 0,
|
|
266
|
+
};
|
|
239
267
|
try {
|
|
240
|
-
return await listOpenedDirectory(
|
|
268
|
+
return await listOpenedDirectory([frame], DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT);
|
|
241
269
|
}
|
|
242
270
|
finally {
|
|
243
271
|
await opened.handle.close();
|
|
@@ -280,7 +308,8 @@ export async function loadDartPackageConfig(repoPath, options) {
|
|
|
280
308
|
if (++visited > directoryLimit)
|
|
281
309
|
return incomplete('directory-limit', frame.relative || '.');
|
|
282
310
|
try {
|
|
283
|
-
|
|
311
|
+
const beforeList = options?.beforeDirectoryList;
|
|
312
|
+
frame.entries = await listOpenedDirectory(stack, directoryEntryLimit, beforeList ? () => beforeList(frame.relative) : undefined);
|
|
284
313
|
}
|
|
285
314
|
catch (error) {
|
|
286
315
|
const reason = error.code === 'E2BIG' ? 'directory-entries' : 'read-directory';
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -1035,18 +1035,33 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, c
|
|
|
1035
1035
|
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
|
1036
1036
|
: undefined,
|
|
1037
1037
|
});
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1038
|
+
const catalogDiagnostics = [];
|
|
1039
|
+
let missing = [];
|
|
1040
|
+
let catalogError;
|
|
1041
|
+
try {
|
|
1042
|
+
missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes, (diagnostic) => {
|
|
1043
|
+
catalogDiagnostics.push(diagnostic.message);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
// An unreadable catalog is unknown, not proof of missing indexes.
|
|
1048
|
+
// Keep earlier native build errors when this read also fails.
|
|
1049
|
+
catalogError = error instanceof Error ? error.message : String(error);
|
|
1050
|
+
}
|
|
1051
|
+
if (repairFailures.length > 0 || missing.length > 0 || catalogError !== undefined) {
|
|
1052
|
+
// A failed DROP may leave a valid old catalog entry. Its presence must
|
|
1053
|
+
// not certify that this repair succeeded or clear the recovery marker.
|
|
1054
|
+
const reasons = [
|
|
1055
|
+
missing.length > 0 ? `missing indexes after rebuild: ${missing.join(', ')}` : '',
|
|
1056
|
+
repairFailures.length > 0
|
|
1057
|
+
? summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)
|
|
1058
|
+
: missing.length > 0
|
|
1059
|
+
? 'no build error was returned'
|
|
1060
|
+
: '',
|
|
1061
|
+
...catalogDiagnostics,
|
|
1062
|
+
catalogError !== undefined ? `catalog verification failed: ${catalogError}` : '',
|
|
1063
|
+
].filter(Boolean);
|
|
1064
|
+
throw new Error(`FTS repair failed - ${reasons.join('. ')}. ` +
|
|
1050
1065
|
'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
|
|
1051
1066
|
'if that also fails, verify FTS extension availability via `gitnexus doctor`.');
|
|
1052
1067
|
}
|
|
@@ -159,7 +159,18 @@ export declare function createSearchFTSIndexes(options?: CreateSearchFTSIndexesO
|
|
|
159
159
|
* {@link redactPaths} first, the same rule the query-side warnings follow.
|
|
160
160
|
*/
|
|
161
161
|
export declare const summarizeFtsIndexBuildFailures: (failures: readonly FtsIndexBuildFailure[], indexes?: readonly FTSIndexDefinition[]) => string;
|
|
162
|
-
|
|
162
|
+
/** Bounded catalog evidence for CLI/log diagnostics, never source or index definitions. */
|
|
163
|
+
export interface FtsIndexVerificationDiagnostic {
|
|
164
|
+
table: string;
|
|
165
|
+
indexName: string;
|
|
166
|
+
message: string;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Return missing or invalid configured indexes. An optional callback receives
|
|
170
|
+
* bounded evidence from the same catalog read; a failed read still throws and
|
|
171
|
+
* proves neither presence nor absence.
|
|
172
|
+
*/
|
|
173
|
+
export declare function verifySearchFTSIndexes(executeQuery: (cypher: string) => Promise<unknown[]>, indexes?: readonly FTSIndexDefinition[], onMismatch?: (diagnostic: FtsIndexVerificationDiagnostic) => void): Promise<string[]>;
|
|
163
174
|
/**
|
|
164
175
|
* Why an FTS build failed, so the caller can react correctly (#2658):
|
|
165
176
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createFTSIndex, dropFTSIndex, indexRowName, indexRowTable, resolveGateRows, DEFAULT_FTS_STEMMER, } from '../lbug/lbug-adapter.js';
|
|
1
|
+
import { createFTSIndex, dropFTSIndex, indexRowName, indexRowTable, indexRowType, resolveGateRows, DEFAULT_FTS_STEMMER, } from '../lbug/lbug-adapter.js';
|
|
2
2
|
import { getFtsCapability } from '../lbug/extension-loader.js';
|
|
3
3
|
import { FTS_DISABLED_MESSAGE } from './fts-policy.js';
|
|
4
4
|
import { classifyExtensionLoadError, usesClassifiedLoadRemedy, } from '../lbug/extension-load-error.js';
|
|
@@ -312,38 +312,65 @@ export async function createSearchFTSIndexes(options) {
|
|
|
312
312
|
*/
|
|
313
313
|
export const summarizeFtsIndexBuildFailures = (failures, indexes = FTS_INDEXES) => `FTS index build failed for ${failures.length} of ${indexes.length} tables: ` +
|
|
314
314
|
failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', ');
|
|
315
|
-
|
|
315
|
+
const boundCatalogText = (text, limit) => text.length > limit ? `${text.slice(0, limit - 14)}… [truncated]` : text;
|
|
316
|
+
const catalogFieldEvidence = (value) => typeof value === 'string'
|
|
317
|
+
? JSON.stringify(boundCatalogText(value, 80))
|
|
318
|
+
: value === undefined
|
|
319
|
+
? '<missing>'
|
|
320
|
+
: `<invalid ${value === null ? 'null' : typeof value}>`;
|
|
321
|
+
const catalogPropertiesEvidence = (value) => {
|
|
322
|
+
if (!Array.isArray(value))
|
|
323
|
+
return `<invalid properties: ${catalogFieldEvidence(value)}>`;
|
|
324
|
+
return (`[${value.slice(0, 8).map(catalogFieldEvidence).join(', ')}]` +
|
|
325
|
+
(value.length > 8 ? ` (${value.length - 8} properties truncated)` : ''));
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Return missing or invalid configured indexes. An optional callback receives
|
|
329
|
+
* bounded evidence from the same catalog read; a failed read still throws and
|
|
330
|
+
* proves neither presence nor absence.
|
|
331
|
+
*/
|
|
332
|
+
export async function verifySearchFTSIndexes(executeQuery, indexes = FTS_INDEXES, onMismatch) {
|
|
316
333
|
// Read the catalog once and check each configured index both EXISTS and
|
|
317
334
|
// covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
|
|
318
335
|
// ... catch) is not enough: a stale `name+content`-only index left on a
|
|
319
336
|
// pre-#2299 DB stays queryable yet silently misses `description`, so the probe
|
|
320
337
|
// would pass while doc-comment search is still broken (#2299). SHOW_INDEXES
|
|
321
338
|
// exposes `property_names` (STRING[]) per index, so we assert coverage directly.
|
|
322
|
-
const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *');
|
|
323
|
-
const propsByIndex = new Map();
|
|
324
|
-
for (const row of rows) {
|
|
325
|
-
if (typeof row !== 'object' || row === null)
|
|
326
|
-
continue;
|
|
327
|
-
const record = row;
|
|
328
|
-
const indexName = indexRowName(record);
|
|
329
|
-
// LADYBUGDB-CONTRACT: `property_names` is the one SHOW_INDEXES column with a
|
|
330
|
-
// single reader, so it has no shared accessor — see {@link IndexCatalogRow}
|
|
331
|
-
// in lbug-adapter.ts for the full column list and the re-validation rule.
|
|
332
|
-
// Unlike the gates, an unreadable shape here is safe: it reports the index as
|
|
333
|
-
// not covering its columns, i.e. "missing", which degrades keyword search
|
|
334
|
-
// loudly rather than passing a broken index off as verified.
|
|
335
|
-
const propertyNames = record.property_names;
|
|
336
|
-
if (typeof indexName !== 'string' || !Array.isArray(propertyNames))
|
|
337
|
-
continue;
|
|
338
|
-
propsByIndex.set(indexName, propertyNames.filter((p) => typeof p === 'string'));
|
|
339
|
-
}
|
|
339
|
+
const rows = (await executeQuery('CALL SHOW_INDEXES() RETURN *')).filter((row) => typeof row === 'object' && row !== null);
|
|
340
340
|
const missing = [];
|
|
341
341
|
for (const { table, indexName, properties } of indexes) {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
342
|
+
const valid = rows.some((row) => {
|
|
343
|
+
// LADYBUGDB-CONTRACT: `property_names` is a named STRING[] column;
|
|
344
|
+
// identity/type use the shared catalog readers in lbug-adapter.ts.
|
|
345
|
+
const actual = row.property_names;
|
|
346
|
+
return (indexRowTable(row) === table &&
|
|
347
|
+
indexRowName(row) === indexName &&
|
|
348
|
+
indexRowType(row) === 'FTS' &&
|
|
349
|
+
Array.isArray(actual) &&
|
|
350
|
+
actual.every((property) => typeof property === 'string') &&
|
|
351
|
+
properties.every((property) => actual.includes(property)));
|
|
352
|
+
});
|
|
353
|
+
if (valid)
|
|
354
|
+
continue;
|
|
355
|
+
missing.push(`${table}.${indexName}`);
|
|
356
|
+
if (!onMismatch)
|
|
357
|
+
continue;
|
|
358
|
+
// Show only rows that could explain this identity mismatch, and only
|
|
359
|
+
// catalog identity/type/property fields. Malformed objects are represented
|
|
360
|
+
// by their type rather than serialized (they can carry arbitrary content).
|
|
361
|
+
const relevant = rows.filter((row) => indexRowTable(row) === table || indexRowName(row) === indexName);
|
|
362
|
+
const observed = relevant
|
|
363
|
+
.slice(0, 3)
|
|
364
|
+
.map((row) => `{table=${catalogFieldEvidence(indexRowTable(row))}, ` +
|
|
365
|
+
`index=${catalogFieldEvidence(indexRowName(row))}, ` +
|
|
366
|
+
`type=${catalogFieldEvidence(indexRowType(row))}, ` +
|
|
367
|
+
`properties=${catalogPropertiesEvidence(row.property_names)}}`);
|
|
368
|
+
const message = `${table}.${indexName}: expected table=${catalogFieldEvidence(table)}, ` +
|
|
369
|
+
`index=${catalogFieldEvidence(indexName)}, type=FTS, ` +
|
|
370
|
+
`properties=${catalogPropertiesEvidence(properties)}; observed ` +
|
|
371
|
+
(observed.length > 0 ? observed.join(', ') : 'no matching table or index name') +
|
|
372
|
+
(relevant.length > 3 ? ` (${relevant.length - 3} catalog rows truncated)` : '');
|
|
373
|
+
onMismatch({ table, indexName, message: boundCatalogText(message, 2048) });
|
|
347
374
|
}
|
|
348
375
|
return missing;
|
|
349
376
|
}
|
|
@@ -418,28 +445,47 @@ export async function buildSearchIndexesOrDegrade(executeQuery, options) {
|
|
|
418
445
|
// means description search is broken (#2299).
|
|
419
446
|
const failures = await createSearchFTSIndexes(options);
|
|
420
447
|
const indexes = options?.indexes ?? FTS_INDEXES;
|
|
421
|
-
const
|
|
422
|
-
|
|
448
|
+
const diagnostics = [];
|
|
449
|
+
let missing = [];
|
|
450
|
+
let verificationError;
|
|
451
|
+
try {
|
|
452
|
+
missing = await verifySearchFTSIndexes(executeQuery, indexes, (diagnostic) => diagnostics.push(diagnostic));
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
// A failed catalog read is unknown, not absence. Keep the native build
|
|
456
|
+
// failures already collected rather than replacing them with this error.
|
|
457
|
+
verificationError = e instanceof Error ? e.message : String(e);
|
|
458
|
+
}
|
|
459
|
+
if (failures.length === 0 && missing.length === 0 && verificationError === undefined) {
|
|
423
460
|
return { ok: true };
|
|
424
|
-
|
|
425
|
-
//
|
|
461
|
+
}
|
|
462
|
+
// A failed DROP may leave an old catalog row present. Build failures stand
|
|
463
|
+
// on their own; avoid naming the same table again as an unexplained failure.
|
|
426
464
|
const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`));
|
|
427
|
-
const unexplained =
|
|
465
|
+
const unexplained = diagnostics.filter((d) => !named.has(`${d.table}.${d.indexName}`));
|
|
428
466
|
const error = [
|
|
429
467
|
failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '',
|
|
430
468
|
// Structural incompleteness with no thrown error — classified capability
|
|
431
469
|
// (degrade) below, matching prior behavior; a broken *write* surfaces as
|
|
432
470
|
// a thrown IO/checkpoint error and is classified integrity there.
|
|
433
|
-
unexplained.length > 0
|
|
471
|
+
unexplained.length > 0
|
|
472
|
+
? 'missing indexes or mismatched catalog rows after build ' +
|
|
473
|
+
'(no build error was returned for these indexes): ' +
|
|
474
|
+
unexplained.map((d) => d.message).join('; ')
|
|
475
|
+
: '',
|
|
476
|
+
verificationError !== undefined
|
|
477
|
+
? `FTS catalog verification failed: ${verificationError}`
|
|
478
|
+
: '',
|
|
434
479
|
]
|
|
435
480
|
.filter((part) => part.length > 0)
|
|
436
481
|
.join('; ');
|
|
437
482
|
// Classify per failure, not over the joined text: capability signatures are
|
|
438
483
|
// checked first, so folding the messages together would let an untokenizable
|
|
439
484
|
// row mask a genuinely broken write and downgrade an abort into a degrade.
|
|
440
|
-
const failureClass = failures.some((f) => classifyFtsBuildError(f.error) === 'integrity')
|
|
485
|
+
const failureClass = failures.some((f) => classifyFtsBuildError(f.error) === 'integrity') ||
|
|
486
|
+
(verificationError !== undefined && classifyFtsBuildError(verificationError) === 'integrity')
|
|
441
487
|
? 'integrity'
|
|
442
|
-
:
|
|
488
|
+
: 'capability';
|
|
443
489
|
return { ok: false, error, failureClass };
|
|
444
490
|
}
|
|
445
491
|
catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitnexus",
|
|
3
|
-
"version": "1.6.13-rc.
|
|
3
|
+
"version": "1.6.13-rc.82",
|
|
4
4
|
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
|
5
5
|
"author": "Abhigyan Patwari",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"test:watch": "vitest",
|
|
60
60
|
"test:coverage": "vitest run --coverage",
|
|
61
61
|
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
|
|
62
|
+
"test:benchmarks": "node --import tsx scripts/run-benchmarks.ts",
|
|
62
63
|
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
|
|
63
64
|
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
|
|
64
65
|
"prepare": "node scripts/build.js",
|
|
@@ -206,9 +206,8 @@ const LBUG_NATIVE = [
|
|
|
206
206
|
// (the same reason fts-extension-e2e.test.ts is registered below), so the
|
|
207
207
|
// FTS-unavailable branch has to run on a real Windows/macOS runner rather
|
|
208
208
|
// than only on Ubuntu where FTS always loads. And its both-blocked case is
|
|
209
|
-
// gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml
|
|
210
|
-
//
|
|
211
|
-
// failing. Budget: four real analyze runs, so expect it to sit alongside the
|
|
209
|
+
// gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml requires in both
|
|
210
|
+
// coverage and platform jobs so an unavailable VECTOR fails loudly. Budget: four real analyze runs, so expect it to sit alongside the
|
|
212
211
|
// VECTOR sibling's ~87s Windows measurement.
|
|
213
212
|
'test/unit/incremental-index-extension-dml-gate.test.ts',
|
|
214
213
|
];
|
|
@@ -308,6 +307,10 @@ const NATIVE_ADDON_SMOKE = [
|
|
|
308
307
|
// Filesystem behavior tests — exercise operations that vary across
|
|
309
308
|
// platforms (CRLF, symlinks, permissions, temp dirs)
|
|
310
309
|
const FILESYSTEM = [
|
|
310
|
+
// The deletion-guard cases in this file require real Windows path semantics.
|
|
311
|
+
'test/unit/canonicalize-path-long-path-prefix.test.ts',
|
|
312
|
+
'test/unit/storage-resolver.test.ts',
|
|
313
|
+
'test/unit/dart-package-imports.test.ts',
|
|
311
314
|
// Cargo membership uses path normalization, descriptor validation, symlinks,
|
|
312
315
|
// and Rust native parsing (including long Windows source strings).
|
|
313
316
|
'test/unit/scope-resolution/rust-cargo-targets.test.ts',
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { JsonReporter, type TestRunEndReason } from 'vitest/node';
|
|
2
|
+
|
|
3
|
+
/** Vitest's stock JSON omits unhandled errors, even when success is true. */
|
|
4
|
+
export default class ExecutionReporter extends JsonReporter {
|
|
5
|
+
private executionErrors = 0;
|
|
6
|
+
private executionReason: TestRunEndReason = 'interrupted';
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super({});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
override async onTestRunEnd(
|
|
13
|
+
modules: Parameters<JsonReporter['onTestRunEnd']>[0],
|
|
14
|
+
errors: readonly unknown[] = [],
|
|
15
|
+
reason: TestRunEndReason = 'interrupted',
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
this.executionErrors = errors.length;
|
|
18
|
+
this.executionReason = reason;
|
|
19
|
+
await super.onTestRunEnd(modules);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override async writeReport(json: string): Promise<void> {
|
|
23
|
+
const report = JSON.parse(json);
|
|
24
|
+
await super.writeReport(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
...report,
|
|
27
|
+
success: report.success && this.executionErrors === 0 && this.executionReason === 'passed',
|
|
28
|
+
executionErrors: this.executionErrors,
|
|
29
|
+
executionReason: this.executionReason,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { globSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
6
|
+
// Discover the opt-in suites from source so a new benchmark cannot silently
|
|
7
|
+
// miss a hand-maintained workflow list. Keep wall-clock measurements serial.
|
|
8
|
+
const files = globSync('test/**/*.test.ts', { cwd: root })
|
|
9
|
+
.filter((file) =>
|
|
10
|
+
/process\.env(?:\.GITNEXUS_BENCH|\[['"]GITNEXUS_BENCH['"]\])/.test(
|
|
11
|
+
readFileSync(new URL(`../${file.replaceAll('\\', '/')}`, import.meta.url), 'utf8'),
|
|
12
|
+
),
|
|
13
|
+
)
|
|
14
|
+
.sort();
|
|
15
|
+
if (!files.length) throw new Error('No benchmark test suites discovered');
|
|
16
|
+
execFileSync(
|
|
17
|
+
process.execPath,
|
|
18
|
+
[
|
|
19
|
+
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
|
|
20
|
+
'run',
|
|
21
|
+
'--no-file-parallelism',
|
|
22
|
+
...files,
|
|
23
|
+
'--reporter=default',
|
|
24
|
+
'--reporter=./scripts/execution-reporter.ts',
|
|
25
|
+
'--outputFile=benchmarks.json',
|
|
26
|
+
],
|
|
27
|
+
{
|
|
28
|
+
cwd: root,
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
env: { ...process.env, GITNEXUS_BENCH: '1' },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
@@ -80,8 +80,19 @@ console.log(
|
|
|
80
80
|
);
|
|
81
81
|
|
|
82
82
|
const startedAt = Date.now();
|
|
83
|
+
const reportName = process.env.GITNEXUS_TEST_REPORT;
|
|
84
|
+
if (reportName && !/^[a-zA-Z0-9_-]+\.json$/.test(reportName)) {
|
|
85
|
+
throw new Error('GITNEXUS_TEST_REPORT must be a JSON basename');
|
|
86
|
+
}
|
|
87
|
+
const reportArgs = reportName
|
|
88
|
+
? [
|
|
89
|
+
'--reporter=default',
|
|
90
|
+
'--reporter=./scripts/execution-reporter.ts',
|
|
91
|
+
`--outputFile=${reportName}`,
|
|
92
|
+
]
|
|
93
|
+
: [];
|
|
83
94
|
try {
|
|
84
|
-
execFileSync('npx', ['vitest', 'run', ...files], {
|
|
95
|
+
execFileSync('npx', ['vitest', 'run', ...files, ...reportArgs], {
|
|
85
96
|
cwd: ROOT,
|
|
86
97
|
stdio: 'inherit',
|
|
87
98
|
timeout: timeoutMs,
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { globSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import type { TestRunEndReason } from 'vitest/node';
|
|
5
|
+
|
|
6
|
+
type Assertion = {
|
|
7
|
+
ancestorTitles: string[];
|
|
8
|
+
title: string;
|
|
9
|
+
fullName: string;
|
|
10
|
+
status: string;
|
|
11
|
+
location?: { line: number; column: number };
|
|
12
|
+
};
|
|
13
|
+
type Suite = {
|
|
14
|
+
name: string;
|
|
15
|
+
status: string;
|
|
16
|
+
assertionResults: Assertion[];
|
|
17
|
+
endTime?: number;
|
|
18
|
+
message?: string;
|
|
19
|
+
};
|
|
20
|
+
type Report = {
|
|
21
|
+
success?: boolean;
|
|
22
|
+
numTotalTests?: number;
|
|
23
|
+
startTime?: number;
|
|
24
|
+
executionErrors: number;
|
|
25
|
+
executionReason: TestRunEndReason;
|
|
26
|
+
testResults: Suite[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Stable across GitHub's Linux, macOS and Windows checkout roots. */
|
|
30
|
+
function testPath(name: string, packageName: string): string {
|
|
31
|
+
const normalized = name.replaceAll('\\', '/');
|
|
32
|
+
const relative =
|
|
33
|
+
normalized.match(new RegExp(`(?:^|/)${packageName}/((?:test|src)/.+)$`))?.[1] ?? normalized;
|
|
34
|
+
if (!/^(?:test|src)\/.+\.test\.[jt]sx?$/.test(relative)) {
|
|
35
|
+
throw new Error(`Invalid test report path: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
return relative;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A skip is resolved only by a recorded pass of that same test in another job. */
|
|
41
|
+
export function reconcileTestReports(
|
|
42
|
+
inputs: unknown[],
|
|
43
|
+
expectedFiles: string[] = [],
|
|
44
|
+
packageName = 'gitnexus',
|
|
45
|
+
) {
|
|
46
|
+
if (inputs.length === 0) throw new Error('No execution reports supplied');
|
|
47
|
+
const tests = new Map<
|
|
48
|
+
string,
|
|
49
|
+
{ file: string; assertion: Assertion; passed: boolean; failed: boolean }
|
|
50
|
+
>();
|
|
51
|
+
const failures = new Set<string>();
|
|
52
|
+
const failedSuites = new Set<string>();
|
|
53
|
+
const startTimes: number[] = [];
|
|
54
|
+
const endTimes: number[] = [];
|
|
55
|
+
for (const [index, input] of inputs.entries()) {
|
|
56
|
+
const report = input as Report;
|
|
57
|
+
if (!report || !Array.isArray(report.testResults) || report.testResults.length === 0) {
|
|
58
|
+
throw new Error(`Missing or empty execution report ${index}`);
|
|
59
|
+
}
|
|
60
|
+
if (report.success === false) failures.add(`Report ${index}: unsuccessful runner result`);
|
|
61
|
+
if (
|
|
62
|
+
!Number.isInteger(report.executionErrors) ||
|
|
63
|
+
report.executionErrors < 0 ||
|
|
64
|
+
!['passed', 'failed', 'interrupted'].includes(report.executionReason)
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`Report ${index}: missing execution reporter evidence`);
|
|
67
|
+
}
|
|
68
|
+
if (report.executionErrors || report.executionReason !== 'passed') {
|
|
69
|
+
failures.add(
|
|
70
|
+
`Report ${index}: ${report.executionErrors} unhandled errors; ${report.executionReason}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (typeof report.startTime === 'number') startTimes.push(report.startTime);
|
|
74
|
+
let count = 0;
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
for (const suite of report.testResults) {
|
|
77
|
+
const file = testPath(suite.name, packageName);
|
|
78
|
+
if (!Array.isArray(suite.assertionResults) || suite.assertionResults.length === 0) {
|
|
79
|
+
throw new Error(`No collected tests in ${file}`);
|
|
80
|
+
}
|
|
81
|
+
if (suite.status === 'failed') {
|
|
82
|
+
failures.add(`${file}: failed suite`);
|
|
83
|
+
failedSuites.add(file);
|
|
84
|
+
}
|
|
85
|
+
if (typeof suite.endTime === 'number') endTimes.push(suite.endTime);
|
|
86
|
+
for (const assertion of suite.assertionResults) {
|
|
87
|
+
if (
|
|
88
|
+
!['passed', 'failed', 'pending', 'skipped', 'todo', 'disabled'].includes(
|
|
89
|
+
assertion.status,
|
|
90
|
+
) ||
|
|
91
|
+
!Array.isArray(assertion.ancestorTitles) ||
|
|
92
|
+
typeof assertion.title !== 'string' ||
|
|
93
|
+
(assertion.location !== undefined &&
|
|
94
|
+
(!Number.isInteger(assertion.location.line) ||
|
|
95
|
+
!Number.isInteger(assertion.location.column)))
|
|
96
|
+
) {
|
|
97
|
+
throw new Error(`Malformed assertion in ${file}`);
|
|
98
|
+
}
|
|
99
|
+
// Collection order differs across platforms. Source location, not an
|
|
100
|
+
// ordinal, distinguishes equal titles declared at different locations.
|
|
101
|
+
// Helper-generated tests may have no location; their complete title
|
|
102
|
+
// must then be unique within the file (the duplicate check still applies).
|
|
103
|
+
const title = JSON.stringify([...assertion.ancestorTitles, assertion.title]);
|
|
104
|
+
const key = JSON.stringify([
|
|
105
|
+
file,
|
|
106
|
+
title,
|
|
107
|
+
assertion.location?.line ?? null,
|
|
108
|
+
assertion.location?.column ?? null,
|
|
109
|
+
]);
|
|
110
|
+
if (seen.has(key))
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Ambiguous test identity in ${file}: ${title}; give parameterized cases unique titles`,
|
|
113
|
+
);
|
|
114
|
+
seen.add(key);
|
|
115
|
+
const result = tests.get(key) ?? { file, assertion, passed: false, failed: false };
|
|
116
|
+
result.passed ||= assertion.status === 'passed';
|
|
117
|
+
result.failed ||= assertion.status === 'failed';
|
|
118
|
+
tests.set(key, result);
|
|
119
|
+
if (result.failed) failures.add(`${file}: ${assertion.fullName ?? title}`);
|
|
120
|
+
count++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (typeof report.numTotalTests === 'number' && report.numTotalTests !== count) {
|
|
124
|
+
throw new Error(`Report ${index}: expected ${report.numTotalTests} tests, found ${count}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const unverified = [...tests.values()]
|
|
128
|
+
.filter((test) => !test.passed && !test.failed)
|
|
129
|
+
.map((test) => `${test.file}: ${test.assertion.fullName}`);
|
|
130
|
+
const suites = new Map<string, Suite>();
|
|
131
|
+
for (const test of tests.values()) {
|
|
132
|
+
const status = test.failed ? 'failed' : test.passed ? 'passed' : 'pending';
|
|
133
|
+
const suite = suites.get(test.file) ?? {
|
|
134
|
+
name: test.file,
|
|
135
|
+
status: 'passed',
|
|
136
|
+
assertionResults: [],
|
|
137
|
+
};
|
|
138
|
+
suite.assertionResults.push({ ...test.assertion, status });
|
|
139
|
+
if (status === 'failed' || failedSuites.has(test.file)) suite.status = 'failed';
|
|
140
|
+
suites.set(test.file, suite);
|
|
141
|
+
}
|
|
142
|
+
for (const file of expectedFiles) {
|
|
143
|
+
if (!suites.has(file.replaceAll('\\', '/')))
|
|
144
|
+
throw new Error(`Test file was never collected: ${file}`);
|
|
145
|
+
}
|
|
146
|
+
const passed = [...tests.values()].filter((test) => test.passed && !test.failed).length;
|
|
147
|
+
const endTime = endTimes.length ? Math.max(...endTimes) : 0;
|
|
148
|
+
return {
|
|
149
|
+
total: tests.size,
|
|
150
|
+
passed,
|
|
151
|
+
unverified,
|
|
152
|
+
failures: [...failures],
|
|
153
|
+
report: {
|
|
154
|
+
success: failures.size === 0 && unverified.length === 0,
|
|
155
|
+
numTotalTests: tests.size,
|
|
156
|
+
numPassedTests: passed,
|
|
157
|
+
numFailedTests: [...tests.values()].filter((test) => test.failed).length,
|
|
158
|
+
numPendingTests: unverified.length,
|
|
159
|
+
numTotalTestSuites: suites.size,
|
|
160
|
+
numFailedTestSuites: failedSuites.size,
|
|
161
|
+
executionFailures: [...failures],
|
|
162
|
+
startTime: startTimes.length ? Math.min(...startTimes) : 0,
|
|
163
|
+
testResults: [...suites.values()].map((suite) => ({ ...suite, endTime })),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
169
|
+
const [directory, output, shardsText] = process.argv.slice(2);
|
|
170
|
+
const shards = Number(shardsText);
|
|
171
|
+
if (!directory || !output || !Number.isInteger(shards) || shards < 1) {
|
|
172
|
+
throw new Error('Usage: test-completeness.ts <reports-dir> <output.json> <platform-shards>');
|
|
173
|
+
}
|
|
174
|
+
const names = ['coverage.json', 'benchmarks.json', 'preflight.json'];
|
|
175
|
+
for (const os of ['windows-latest', 'macos-latest']) {
|
|
176
|
+
for (let shard = 1; shard <= shards; shard++) names.push(`${os}-${shard}.json`);
|
|
177
|
+
}
|
|
178
|
+
// Exact expected receipts: an absent/cancelled job is never an empty success.
|
|
179
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
180
|
+
const expected = globSync('test/**/*.test.ts', { cwd: root });
|
|
181
|
+
const result = reconcileTestReports(
|
|
182
|
+
names.map((name) => JSON.parse(readFileSync(path.join(directory, name), 'utf8'))),
|
|
183
|
+
expected,
|
|
184
|
+
);
|
|
185
|
+
writeFileSync(output, JSON.stringify(result.report));
|
|
186
|
+
console.log(
|
|
187
|
+
`Test execution: ${result.passed}/${result.total} passed; ${result.unverified.length} unverified; ${result.failures.length} failures`,
|
|
188
|
+
);
|
|
189
|
+
for (const message of [...result.unverified, ...result.failures]) console.error(message);
|
|
190
|
+
if (!result.report.success) process.exitCode = 1;
|
|
191
|
+
const webPath = path.join(root, '../gitnexus-web/web-test-results.json');
|
|
192
|
+
const web = reconcileTestReports(
|
|
193
|
+
[JSON.parse(readFileSync(webPath, 'utf8'))],
|
|
194
|
+
globSync('{test,src}/**/*.test.{ts,tsx}', { cwd: path.join(root, '../gitnexus-web') }),
|
|
195
|
+
'gitnexus-web',
|
|
196
|
+
);
|
|
197
|
+
writeFileSync(webPath, JSON.stringify(web.report));
|
|
198
|
+
console.log(
|
|
199
|
+
`Web execution: ${web.passed}/${web.total} passed; ${web.unverified.length} unverified; ${web.failures.length} failures`,
|
|
200
|
+
);
|
|
201
|
+
for (const message of [...web.unverified, ...web.failures]) console.error(message);
|
|
202
|
+
if (!web.report.success) process.exitCode = 1;
|
|
203
|
+
}
|