diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ff75bca26..2129e765a 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -58,6 +58,7 @@ jobs: timeout-minutes: 5 permissions: contents: read + pull-requests: read # read PR labels on the merge commit outputs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} @@ -74,6 +75,8 @@ jobs: FORCE: ${{ inputs.force }} BUMP_INPUT: ${{ inputs.bump }} EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | set -euo pipefail HEAD_SHA=$(git rev-parse HEAD) @@ -96,6 +99,49 @@ jobs: exit 0 fi + # ── Skip when the merge commit corresponds to a release ───────── + # Two complementary checks (belt-and-suspenders): + # 1. The HEAD commit subject matches `chore: release vX.Y.Z` + # (the canonical release-PR title in this repo). Anchored + # at both ends to require the bare title or the squash-merge + # `(#NNNN)` suffix exactly — rejects noisy variants like + # `chore: release v1.0.0 (something unrelated)`. + # 2. The squash-merged PR carries the `release` label. + # Either match suppresses the rc build — stable releases publish + # via publish.yml on the v-tag, so the rc cycle should pause for + # them rather than racing the npm publish. + HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)" + # Sanitise GitHub-Actions annotation prefixes before logging the + # raw subject — defence-in-depth so a hypothetical commit subject + # containing `::error::` or `::set-output::` cannot forge log + # annotations even though %s strips newlines. + HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}" + RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$' + if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then + echo "HEAD commit subject matches a release commit — skipping rc." + echo " subject (sanitised): $HEAD_SUBJECT_SAFE" + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Squash-merge commits include `(#NNNN)` at the end of the subject. + if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then + PR_NUM="${BASH_REMATCH[1]}" + echo "Detected squash-merge of PR #$PR_NUM — checking labels." + if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then + if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then + echo "PR #$PR_NUM has the 'release' label — skipping rc." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "PR #$PR_NUM has no 'release' label — proceeding." + else + # Lookup failure is not fatal — fall through to the dedup check + # so a transient GH API hiccup doesn't silently suppress rc builds. + echo "::warning::Could not read labels for PR #${PR_NUM} — falling through." + fi + fi + # Dedup: is there already an rc/ marker pointing at HEAD? MARKER="rc/${HEAD_SHA}" if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index ca51f0493..583ed97b3 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to GitNexus will be documented in this file. ## [Unreleased] +## [1.6.4] - 2026-05-10 + +### Added + +- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425) +- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156) +- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439) +- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234) +- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260) +- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256) +- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302) +- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050) +- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093) +- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127) +- **`--embeddings ` opt-in cap** — bound the embeddings pass on huge graphs (closes #382, #1375) +- **Pino structured logger** — replaces ad-hoc console output across the core with structured JSON logs (with pretty-print for TTY) (#1336) +- **Shared resilient-fetch helper** — single retries + circuit breaker module reused by HF / Docker / publish flows (#1448) +- **`/autofix` ChatOps button** — fork-safe PR autofix pipeline replaces the inline reviewdog flow (#1446, #1458) +- **Automated security & vulnerability scans** in CI (#1297, #1455) + +### Fixed + +- **FTS read-only DB cluster** — hook resolves canonical repo root and guards read-only FTS ensure; missing-FTS warning is now surfaced. Closes #1255, #1287, #1170, #1449, #1440, #1216, #1438 (#1226, #1418, #1107, #1123) +- **WAL corruption recovery** — quarantine corrupted `.wal` files instead of failing analyze; CHECKPOINT before close prevents recurrence; `safeClose` consolidates flush. Closes #1402, #1236, #1273, #1361 (#1417, #1314, #1377) +- **Embedding download failures** — actionable HF_ENDPOINT guidance, retries, timeout, and circuit breaker; bridge `HF_ENDPOINT` to transformers.js; iterative DFS; HF cache via `os.homedir()`. Closes #1378, #1437, #1205 (#1419, #1252, #1078) +- **Windows reliability** — pin tree-sitter-c/cpp to fix segfault, prefer `.cmd`/`.bat` from `where` output, robust LadybugDB lock acquisition for CI integration tests, surface silent finalize-skips so analyze cannot exit 0 without persisting. Closes #1242, #1427, #1447, #1468, #1400; partial #1218 (#1243, #1299, #1430, #1237, #1226, #1235) +- **DuckDB / LadybugDB native** — bumped to 0.16.0 then 0.16.1; prevent extension install hangs; CHECKPOINT before close; WAL quarantine on corruption. Closes #1162, #1160, #273 (#1235, #1326, #1129, #1314, #1417) +- **C# scope-resolution "Cannot add property" crashes** — generic typed properties included in context and impact, fixing crashes on Unity ECS partial structs and on properties whose name matches the class name. Closes #1426, #1465 (#1399) +- **C# frozen-bucket regression** + scope-resolution I8 hardening — closes #1066 (#1082, #1085) +- **Scope resolution** — same-range Module-as-parent for top-level scopes (closes #1086) (#1087); avoid variadic reference-site aggregation (#1112); skip empty scope extraction (#1100); classify Python class methods as Method (#1102) +- **Python** — index repos with empty `__init__.py` and >32 KB files (#1163); walk ancestors for multi-segment dotted imports (#1241); deterministic multi-segment suffix fallback (#1253) +- **TypeScript** — capture missed CALLS edges from HOF callbacks and JSX (#1175); name HOC-wrapped const declarations (`forwardRef` / `memo` / `useCallback` / `useMemo` / `observer`) (#1261); pair-with-arrow `@declaration.function` anchored on inner arrow +- **Go** — loose equality for `Array.find()` null checks (#1384) +- **Swift** — switched to the official prebuilt parser runtime (#1130) +- **Server hardening cluster (U2–U8)** — JS path-injection on `/api/file` + docker-server (U2, #1322); git-clone path/CLI-injection / ReDoS hardening (U3, #1325); per-route rate limiting on FS-touching endpoints (U4, #1327); URL/regex/tag-filter sanitization (U7, #1330); ReDoS in cobol-preprocessor + rust-workspace + cross-impact resource exhaustion (U8, #1331); critical type-confusion + validation helper (#1317); rate-limit `/api/analyze` and `/api/embed` (closes #1328, #1339); IPv6 ipKeyGenerator (closes #1360, #1374); IPv4-compatible IPv6 / NAT64 SSRF bypasses in `validateGitUrl` (closes #1148, 95814847); predictable tempfile names → `crypto.randomBytes` (#1387); log-injection / http-to-file-access / client-side request forgery (#1456); pin Docker Node base images + Trivy verification + Dependabot policy (#1455) +- **Group / contracts** — `runExactMatch` honours `.gitnexusignore` via shared `IgnoreService` (closes #1185, #1247); custom manifest links resolved against graph symbols (#1254); `IgnoreService` EACCES test under uid=0 (#1108) +- **MCP** — close MCP server timeout via stdout discipline + cold-start friction (#1383); avoid `git` from non-repo cwd in sibling-cwd match (closes #1138, #1293); start MCP bridge correctly when using `npx` (#1114); project `tool_map` flows from handlers (#1113); parallelize staleness checks in `list_repos` (#1416) +- **Storage / CLI** — derive registry name from canonical repo root, not worktree slug (closes #1259, #1296); `--skip-git` treats cwd as index root (#1245); keep GitNexus ignores inside `.gitnexus/` (#1248); surface silent finalize-skips so `analyze` cannot exit 0 without persisting (closes #1169, #1237); ignore global registry during staleness checks (#1141); use `os.homedir()` instead of `process.env.HOME` for HF cache dir (#1078); correct OpenCode skills install path in status message (#1386) +- **Docker / server** — dedicated health endpoint for container healthcheck (closes #1147, #1355); HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182); flush WAL after `/api/embed` so search sees new embeddings (closes #1149, #1359); platform-aware semantic fallback (#1150); skip vector index query on unsupported platforms (closes #1178, #1181); serve web UI at root path instead of 404 (#1048) +- **Worker pool** — wait for replacement worker online before dispatch (#1324); prevent premature pool resolution in worker split-and-retry path (#1321); recover worker parse stalls (#1121); widened CI flake-tolerant timeouts (#1323, #1347, #1354) +- **Embeddings storage** — CHECKPOINT before closing DB to prevent WAL corruption (#1314) +- **Performance** — replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316) +- **Install** — vendor tree-sitter-dart source (#1125) +- **Git utils** — suppress stderr leak in `getCurrentCommit` and `getGitRoot` (closes #1172, #1341) +- **Search** — load FTS during core DB init (#1123); create FTS indexes during `analyze` (#1107); surface warning when FTS indexes are missing (#1418) +- **Hooks** — clarify `PostToolUse` hook is notification-only, not auto-reindex (#1070) +- **Docs** — README Web UI section corrected (closes #1110, #1159, #2ff3e64f); Goliath capitalisation typo (#1126) +- **CI** — fork-safe PR autofix pipeline (#1446); consolidated Claude review workflow (#1258); fine-grained PAT for RC tag push (#1407); handle expired artifacts in base coverage fetch (#1410, #1412); allow expected legacy parity failures (#1099); avoid duplicate main push checks; isolate native LadybugDB / CLI e2e flakes; seed e2e with a small fixture repo (#1249); configure e2e GitNexus home at runtime; widen rate-limit test window for Windows CI (#1347) + +### Changed + +- **`gitnexus publish` artefact contract** — universal opt-in publish format introduced (#1425, #1458) +- **Refactor: per-language patterns consolidated into `LanguageProvider`** (#1279) +- **Refactor: `safeClose` helper** consolidates WAL flush across LadybugDB call sites (#1377) +- **Quality: exclude `test/fixtures` from CodeQL, ESLint, and Prettier** (#1313) +- **Regression coverage** for `.gitnexusignore` behaviour with `--skip-git` (#1450) + +### Chore / Dependencies + +- `@ladybugdb/core` 0.16.0 → 0.16.1 (#1235, #1326) +- `@anthropic-ai/sdk` (#1442), `@langchain/anthropic` (#1389), `@langchain/core` (#1394), `@langchain/openai` (#1215) +- `hono` 4.12.9 → 4.12.18 + `@hono/node-server` (#1310, #1311, #1443) +- `axios` (#1345), `fast-uri` 3.1.0 → 3.1.2 (#1441), `lru-cache` 11.3.5 → 11.3.6 (#1344), `mnemonist` 0.40.3 → 0.40.4 (#1239), `express-rate-limit` (#1343, #1397), `onnxruntime-node` (#1213, #1435), `uuid` 13 → 14 in /gitnexus-web (#1211, after revert #1222 / re-land #1250 + #1208) +- `react`/`@types/react` (#1210), `react-dom` 19.2.5 → 19.2.6 (#1396), `react-zoom-pan-pinch` (#1214), `jsdom` 29.0.2 → 29.1.1 (#1395) +- npm_and_yarn group bump (#1312), uv group bump (#1315), `python-dotenv` (#1320), `@types/node` (#1212, #1421, #1436) +- GitHub Actions: `docker/build-push-action` 6.19.2 → 7.1.0 (#1391), `github/codeql-action` 3.35.3 → 4.35.3 (#1390) + ## [1.6.3] - 2026-04-24 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 04233b1a1..dfc5e3c4c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 810052502..633c69f24 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", diff --git a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts index 6dd6d9955..fb5b66cb1 100644 --- a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts +++ b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts @@ -21,85 +21,112 @@ import { } from '../../src/core/group/cross-impact.js'; /** - * Time a single regex.exec call. Used by the linearity tests below to - * compute a 10k/5k ratio in addition to the absolute <500ms bound. + * Linearity-test methodology + * -------------------------- + * Wall-clock perf assertions in CI are notoriously flaky. To make these + * robust without losing regression-detection power, we combine four + * techniques: * - * Ratio assertions catch sub-exponential O(n²) regressions that fit - * inside the absolute cap on warm CI; the absolute cap catches - * catastrophic backtracking on cold CI. Two complementary signals. - */ -function timeRegex(re: RegExp, input: string): number { - // Reset regex.lastIndex for global/sticky regexes — ours are not, but - // be defensive in case future shape changes add the `g` flag. - re.lastIndex = 0; - const start = performance.now(); - re.exec(input); - return performance.now() - start; -} - -function timeFn(fn: () => T): number { - const start = performance.now(); - fn(); - return performance.now() - start; -} - -// Linear scaling is ~2.0× when input doubles; 3.0× allows generous -// slack for CI-runner GC and tier-up jitter. An O(n²) regression on a -// 2× input takes ~4× as long, well outside this bound. -const LINEAR_RATIO_BOUND = 3.0; - -/** - * Minimum elapsed time (in ms) below which `performance.now()` ratios - * are dominated by scheduler jitter and become meaningless. When both - * timed runs come in below this floor, we skip the ratio assertion — - * the absolute <500ms bound still catches catastrophic backtracking, - * and the next CI run will measure higher absolute times that the - * ratio assertion can evaluate reliably. + * 1. **Warmup** — run the function a few times before timing, so the + * JIT has tiered up by the time we measure. + * 2. **Median of N trials** — single measurements are dominated by + * GC pauses, scheduler jitter, and OS interrupts. Median of 5 + * eliminates almost all of that. + * 3. **4× input ratio** (not 2×) — linear → ~4×, O(n²) → ~16×, + * catastrophic → ≫16×. A wider input ratio gives a much bigger + * gap between "linear" and "regressed", so the bound can be loose + * enough to absorb noise without losing signal. + * 4. **Generous bound (8×)** with a noise floor — only assert the + * ratio when the *large* measurement is well above the noise + * floor. The absolute <500ms cap still catches catastrophic + * backtracking on cold CI even when the ratio is skipped. * - * Calibrated empirically: a flake on macOS reported ratio 5.29× - * between two sub-millisecond measurements (~0.5ms vs ~2.6ms), both - * genuinely linear but indistinguishable from noise. 5ms is a - * comfortable floor where individual measurements are well-separated - * from the ~10-100µs `performance.now()` resolution band. + * Headroom: linear is expected at ~4×; the bound is 8× → 2× headroom. + * O(n²) on a 4× input would clock 16×, well outside the bound. */ +const PERF_WARMUP_RUNS = 3; +const PERF_TRIAL_COUNT = 5; +const SIZE_RATIO = 4; +const LINEAR_RATIO_BOUND = SIZE_RATIO * 2; // 8× — 2× headroom over expected linear +// Median-of-N tightens the noise floor we can rely on. A single-sample 5ms +// measurement is ~50% jitter; median-of-5 brings the same 5ms into the +// reliably-resolvable range above `performance.now()`'s ~10-100µs band. const RATIO_MEASUREMENT_FLOOR_MS = 5; +function median(samples: number[]): number { + const sorted = [...samples].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + /** - * Assert linear scaling between two timed runs on inputs that differ - * by 2×. When measurements are too small to be reliable, the ratio - * assertion is skipped (the absolute bound still fires elsewhere). + * Median time of `PERF_TRIAL_COUNT` runs of `fn`, after `PERF_WARMUP_RUNS` + * warmup iterations. Trial cost: (warmup + trials) × fn cost. */ -function assertSubLinearRatio(elapsedSmall: number, elapsedLarge: number, label: string): void { +function medianTimeFn(fn: () => T): number { + for (let i = 0; i < PERF_WARMUP_RUNS; i++) fn(); + const samples: number[] = []; + for (let i = 0; i < PERF_TRIAL_COUNT; i++) { + const start = performance.now(); + fn(); + samples.push(performance.now() - start); + } + return median(samples); +} + +/** Median time of regex.exec — defensively resets lastIndex each call. */ +function medianTimeRegex(re: RegExp, input: string): number { + return medianTimeFn(() => { + re.lastIndex = 0; + re.exec(input); + }); +} + +/** + * Assert near-linear scaling between two median-timed runs on inputs + * that differ by `SIZE_RATIO`×. The bound is `LINEAR_RATIO_BOUND` = + * `SIZE_RATIO * 2`, i.e. 2× headroom over the linear expectation — + * comfortably under the ~`SIZE_RATIO²` ratio a quadratic regression + * would produce, so true regressions still fail loudly. + * + * Skip semantics: the ratio assertion is skipped only when *both* + * measurements are below the noise floor. If either run is reliably + * measurable, we still assert — otherwise an O(n²) regression that + * happens to stay under the absolute 500ms cap on a fast runner could + * slip through with no detector firing. Median-of-N + the 5ms floor + * keeps the assertion stable while preserving regression coverage. + */ +function assertNearLinearScaling(elapsedSmall: number, elapsedLarge: number, label: string): void { if (elapsedSmall < RATIO_MEASUREMENT_FLOOR_MS && elapsedLarge < RATIO_MEASUREMENT_FLOOR_MS) { - // Both runs completed faster than the noise floor — the ratio is - // not meaningful. The absolute <500ms bound elsewhere in this - // describe block still pins linearity; we skip rather than risk a - // flake on a genuinely-linear implementation. + // Both runs completed below the noise floor — even the median is + // dominated by `performance.now()` resolution. The absolute <500ms + // cap elsewhere still catches catastrophic backtracking. return; } const ratio = elapsedLarge / Math.max(elapsedSmall, 0.001); if (ratio >= LINEAR_RATIO_BOUND) { throw new Error( `${label}: ratio ${ratio.toFixed(2)}× exceeds bound ${LINEAR_RATIO_BOUND}× ` + - `(small=${elapsedSmall.toFixed(2)}ms, large=${elapsedLarge.toFixed(2)}ms)`, + `on ${SIZE_RATIO}× input (small=${elapsedSmall.toFixed(2)}ms, ` + + `large=${elapsedLarge.toFixed(2)}ms, median of ${PERF_TRIAL_COUNT} trials)`, ); } } describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological input', () => { - it('matches in <500ms on 50k repetitions of "A OF A " AND 100k/50k ratio is sub-linear when measurable', () => { - // 50k/100k repetitions chosen so timings exceed the - // RATIO_MEASUREMENT_FLOOR_MS noise floor on typical CI hardware. - // Pre-fix nested-quantifier shape would be exponential here; the - // post-fix `.+?` shape is linear (~2× when input doubles). + it('matches in <500ms on 50k repetitions of "A OF A " AND scales sub-linearly on a 4× input', () => { + // 50k → 200k (4× input ratio). Pre-fix nested-quantifier shape would + // be exponential here; the post-fix `.+?` shape is linear (~4× when + // input quadruples). Median of 5 trials with warmup eliminates GC + // and tier-up jitter. const inputSmall = 'SET ' + 'A OF A '.repeat(50_000) + 'TO TRUE'; - const inputLarge = 'SET ' + 'A OF A '.repeat(100_000) + 'TO TRUE'; - const elapsedSmall = timeRegex(RE_SET_TO_TRUE, inputSmall); - const elapsedLarge = timeRegex(RE_SET_TO_TRUE, inputLarge); + const inputLarge = 'SET ' + 'A OF A '.repeat(50_000 * SIZE_RATIO) + 'TO TRUE'; + const elapsedSmall = medianTimeRegex(RE_SET_TO_TRUE, inputSmall); + const elapsedLarge = medianTimeRegex(RE_SET_TO_TRUE, inputLarge); expect(RE_SET_TO_TRUE.exec(inputSmall)).not.toBeNull(); expect(elapsedSmall).toBeLessThan(500); expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE'); + assertNearLinearScaling(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE'); }); it('still matches a normal SET ... TO TRUE statement', () => { @@ -110,17 +137,17 @@ describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological inpu }); describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input', () => { - it('rejects in <500ms on 50k tokens with no valid suffix AND 100k/50k ratio is sub-linear when measurable', () => { + it('rejects in <500ms on 50k tokens with no valid suffix AND scales sub-linearly on a 4× input', () => { // Forces backtracking against the (TO|UP\s+BY|DOWN\s+BY) alternation // — the richer pathological surface of the two regexes. const inputSmall = 'SET ' + 'A '.repeat(50_000) + 'X'; - const inputLarge = 'SET ' + 'A '.repeat(100_000) + 'X'; - const elapsedSmall = timeRegex(RE_SET_INDEX, inputSmall); - const elapsedLarge = timeRegex(RE_SET_INDEX, inputLarge); + const inputLarge = 'SET ' + 'A '.repeat(50_000 * SIZE_RATIO) + 'X'; + const elapsedSmall = medianTimeRegex(RE_SET_INDEX, inputSmall); + const elapsedLarge = medianTimeRegex(RE_SET_INDEX, inputLarge); expect(RE_SET_INDEX.exec(inputSmall)).toBeNull(); expect(elapsedSmall).toBeLessThan(500); expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_INDEX'); + assertNearLinearScaling(elapsedSmall, elapsedLarge, 'RE_SET_INDEX'); }); it('still matches a normal SET INDEX statement', () => { @@ -133,22 +160,22 @@ describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input' }); describe('rust-workspace parseCargoPackageName — linear-time line walk', () => { - it('extracts the package name in <500ms on 100k blank lines AND 200k/100k ratio is sub-linear when measurable', () => { - // 100k/200k blank lines chosen so timings exceed the - // RATIO_MEASUREMENT_FLOOR_MS noise floor. Earlier 10k/20k pairing - // produced sub-millisecond measurements where scheduler jitter - // dominated and the ratio became meaningless (a real macOS run - // saw 5.29× between two genuinely-linear sub-ms measurements). + it('extracts the package name in <500ms on 100k blank lines AND scales sub-linearly on a 4× input', () => { + // 100k → 400k blank lines (4× input ratio). Median of 5 trials with + // warmup keeps the ratio stable across CI runners. A previous 2× + // input + 3× bound + single-trial setup flaked at 3.01× on macOS + // (small=7.41ms, large=22.31ms) — both above the noise floor but + // close enough that single-shot jitter pushed the ratio over. const cargoTomlSmall = '[package]\n' + '\n'.repeat(100_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; const cargoTomlLarge = - '[package]\n' + '\n'.repeat(200_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; - const elapsedSmall = timeFn(() => parseCargoPackageName(cargoTomlSmall)); - const elapsedLarge = timeFn(() => parseCargoPackageName(cargoTomlLarge)); + '[package]\n' + '\n'.repeat(100_000 * SIZE_RATIO) + 'name = "myrepo"\nversion = "0.1.0"\n'; + const elapsedSmall = medianTimeFn(() => parseCargoPackageName(cargoTomlSmall)); + const elapsedLarge = medianTimeFn(() => parseCargoPackageName(cargoTomlLarge)); expect(parseCargoPackageName(cargoTomlSmall)).toBe('myrepo'); expect(elapsedSmall).toBeLessThan(500); expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'parseCargoPackageName'); + assertNearLinearScaling(elapsedSmall, elapsedLarge, 'parseCargoPackageName'); }); it('returns null when [package] section is absent', () => {