Commit Graph

3830 Commits

Author SHA1 Message Date
Claude fb4c895085 perf(core): Pre-warm security worker pool to overlap @secretlint/core load
The security worker pool currently spawns its 2 workers lazily inside
`runSecurityCheck`, paying a ~50 ms `@secretlint/core` +
`@secretlint/secretlint-rule-preset-recommend` module load on each
freshly spawned worker (~100 ms wall-clock for both workers loading
concurrently). That cold-start cost runs on the critical path inside
the security-check phase, before any scanning begins.

Mirror the existing `createMetricsTaskRunner` pattern: hoist the pool
construction to `pack()` and dispatch one no-op task per worker at the
pipeline entry, so the module load overlaps with the collectFiles + git
ops phase (~200 ms) instead of stalling the security check.

## Mechanism

- New `createSecurityTaskRunner(numOfTasks, deps?)` in
  `src/core/security/securityCheck.ts` returns
  `{ taskRunner, warmupPromise }`. The warm-up dispatches `maxThreads`
  no-op tasks (`{ items: [] }`) — Tinypool spawns a fresh worker for
  each concurrent task, fanning out the @secretlint/core load across
  all workers in parallel.
- `runSecurityCheck` accepts an optional `taskRunner` in `deps`. When
  provided, the caller owns the pool's lifecycle (creation + cleanup);
  when omitted, runSecurityCheck creates and cleans up a fresh pool —
  preserving the existing behavior for direct callers (e.g. the MCP
  fileSystemReadFileTool path).
- `validateFileSafety` accepts and forwards an optional `taskRunner`.
- `pack()` calls `createSecurityTaskRunner` after `searchFiles` resolves
  (file count is now known) and before the parallel collectFiles + git
  ops block, so the warm-up runs concurrently with disk I/O. The
  task runner is plumbed through `validateFileSafety` deps; the pool
  is cleaned up alongside the metrics pool in the surrounding
  try/finally.

## Scope gate

Pre-warming is gated on the same `hasExplicitScope` heuristic that
already differentiates 2- vs. 3-worker metrics warm-up:

| Workload                                         | Pre-warm? |
|--------------------------------------------------|-----------|
| Default scan (no `--include` / `--stdin`)        | yes       |
| `--include`, `config.include`, or `--stdin` set  | no        |

Without the gate, the small/scoped workload regresses by 3.4 % paired
mean: the security check scans only ~5 batches and finishes in ~50–80
ms, so the up-front cost of constructing + destroying a second worker
pool outweighs the saved cold-start. The unconstrained scan runs
security over ~1000+ files where the hidden cold-start dominates.

## Benchmark — `node bin/repomix.cjs --quiet` (1046 files)

Two independent paired n=50 runs (interleaved BEFORE/AFTER alternating
order, NODE_DISABLE_COMPILE_CACHE=1):

|        | min     | median  | mean    | max     | sd     |
|--------|---------|---------|---------|---------|--------|
| BEFORE | 1320 ms | 1454 ms | 1451 ms | 1590 ms | 49 ms  |
| AFTER  | 1318 ms | 1410 ms | 1416 ms | 1501 ms | 40 ms  |

- Mean paired Δ:   **+35.2 ms (2.42 % wall-clock reduction)**
- Median paired Δ: +32.5 ms (2.23 %)
- Paired-delta SD: 64.78 ms · paired t = **3.84** (p < 0.001)
- AFTER faster in **39/50** pairs (78 %)

Confirmation run (same setup, n=50): mean Δ +37.0 ms (2.55 %),
t = 3.93, 36/50 pairs faster.

## Regression check — `--include 'src,tests' --quiet` (258 files)

n=30 paired interleaved, NODE_DISABLE_COMPILE_CACHE=1:

|        | min    | median | mean   | max    |
|--------|--------|--------|--------|--------|
| BEFORE | 670 ms | 732 ms | 730 ms | 783 ms |
| AFTER  | 688 ms | 728 ms | 729 ms | 786 ms |

- Mean paired Δ:   +0.9 ms (0.13 %) — **neutral within noise**
  (paired t = 0.17)
- AFTER faster in 16/30 pairs

The gate falls back to the original lazy-spawn path on this workload,
so AFTER == BEFORE up to noise. Without the gate this workload
regresses by 3.4 % paired (t = -4.88).

## Correctness

- All **1260** unit tests pass (`npm test`); `npm run lint` clean
  (only the two pre-existing `biome-ignore` warnings unrelated to
  this change).
- XML output **byte-identical** between BEFORE and AFTER on both the
  default 1046-file workload and the `--include 'src,tests'`
  258-file workload (verified via `diff` on full ~4.85 MB outputs).
- `runSecurityCheck`'s public signature gains an optional `taskRunner`
  in deps; when omitted, behavior is unchanged. Existing callers
  outside the pack pipeline (e.g. MCP `fileSystemReadFileTool`) still
  spawn their own pool.
- The MCP main-thread security path is unaffected — it uses
  `runSecretLint` directly (worker module loaded once at process
  start) and never goes through the pool.

## Tests

- `tests/core/security/validateFileSafety.test.ts` — assertion on the
  `runSecurityCheck` call updated to include the new `{ taskRunner }`
  deps argument (currently undefined when no pre-warmed runner is
  provided).
- `tests/core/packager.test.ts`,
  `tests/core/packager/diffsFunctionality.test.ts`,
  `tests/core/packager/splitOutput.test.ts`,
  `tests/integration-tests/packager.test.ts` — extended `mockDeps` /
  `baseDeps` with a stubbed `createSecurityTaskRunner` so the default
  scope path no longer attempts to spawn a real worker pool from the
  test environment. The pack-level assertion on `validateFileSafety`
  now matches the new 6th-argument deps object via
  `expect.objectContaining({ taskRunner: expect.any(Object) })`.
2026-05-08 17:05:51 +00:00
Claude 3427aae570 perf(core): Read files via util.promisify(fs.readFile) on the collect path
`node:fs/promises.readFile` wraps every read in a `FileHandle` object,
paying ~60μs of per-call JS bookkeeping vs. the callback-based path. With
~1000 files draining concurrently through `collectFiles`, the overhead
compounds onto the search → collect → security/process critical path.

Switch the single hot read site (`readRawFile` in `src/core/file/fileRead.ts`)
to `util.promisify(fs.readFile)`, which returns the same `Buffer` without
constructing a `FileHandle` per call. All downstream code
(`Buffer.indexOf(0)`, `isBinaryFile(buffer)`, `TextDecoder.decode`, BOM
strip, jschardet/iconv slow path) is unchanged.

Other readFile call sites in the codebase (config load, gitignore parse,
output instruction, MCP tools) are 1–2 calls each and remain on
`fs/promises` for their `'utf-8'` ergonomics.

## Mechanism

Isolated raw-I/O microbenchmark walking the repo with a minimal ignore
list (`node_modules`, `.git`, `lib`, `repomix-output*` only — 1086 files,
slightly looser than repomix's full default-ignore set), n=15 paired
interleaved, `NODE_DISABLE_COMPILE_CACHE=1`:

| concurrency | fs/promises median | promisify median | Δ      |
|-------------|---------------------|------------------|--------|
| 50          | 118.3 ms            | 55.3 ms          | 63.0 ms |
| 100         | 116.2 ms            | 47.1 ms          | 69.1 ms |
| 200         | 122.7 ms            | 49.9 ms          | 72.7 ms |
| unlimited   | 119.3 ms            | 59.5 ms          | 59.8 ms |

The savings are concurrency-independent — the overhead is per-call, not
contention.

In the full pipeline (1046 files after the default-ignore filter), the
60–70 ms read savings flow through to the `collect` phase (verbose
timings: 263 → 223 ms, ~40 ms phase-level reduction), then to wall-clock
with some absorption by the parallel `getGitDiffs` / `getGitLogs` branch
in the same `Promise.all`.

## Benchmark — `node bin/repomix.cjs --quiet` (1046 files)

n=50 paired interleaved, `NODE_DISABLE_COMPILE_CACHE=1`:

|        | min     | median  | mean    | max     | sd     |
|--------|---------|---------|---------|---------|--------|
| BEFORE | 1647 ms | 1800 ms | 1794 ms | 2091 ms | 81 ms  |
| AFTER  | 1606 ms | 1752 ms | 1756 ms | 1926 ms | 70 ms  |

- Mean paired Δ:   **+38.2 ms (2.13% wall-clock reduction)**
- Median paired Δ: +43.0 ms (2.40%)
- Paired-delta SD: 81.3 ms · paired t = **3.33** (p < 0.01)
- AFTER faster in **34/50** pairs (68%)

n=50 was the minimum credible sample size given a paired-delta SD ≈
80 ms and an effect size near 38 ms; an independent reviewer's two
n=20 runs straddled the claim (t=0.93 and t=4.27 respectively),
consistent with this distribution.

## Regression check — `node bin/repomix.cjs --include 'src,tests' --quiet` (258 files)

n=30 paired interleaved, `NODE_DISABLE_COMPILE_CACHE=1`:

|        | min    | median | mean   | max    | sd    |
|--------|--------|--------|--------|--------|-------|
| BEFORE | 850 ms | 918 ms | 922 ms | 984 ms | 38 ms |
| AFTER  | 844 ms | 911 ms | 912 ms | 992 ms | 38 ms |

- Mean paired Δ:   +10.2 ms (1.11%) — **neutral within noise** (paired t = 1.85)
- AFTER faster in 17/30 pairs

## Correctness

- All **1260** unit tests pass (`npm test`); `npm run lint` clean
  (only pre-existing warnings unrelated to this change).
- XML output **byte-identical** between BEFORE and AFTER on both the
  default 1046-file workload and the `--include 'src,tests'` 258-file
  workload (verified via `diff` on full ~4.83 MB outputs).
- The change is a one-site swap of an internal helper; the public
  `readRawFile()` API and all `RawFile` content semantics are unchanged.

## Local review

Two independent sub-agent reviewers approved:

- **Code-correctness reviewer:** APPROVE. Verified API equivalence
  (callback-based `fs.readFile` and `fs.promises.readFile` both delegate
  to the same `uv_fs_read` op and surface identical `SystemError` codes
  for ENOENT/EACCES/EISDIR/EMFILE), `Buffer` return-type match, libuv
  thread-pool concurrency parity, no FD leak (callback path closes its
  own FD before invoking the callback), and dependency-injection mocks
  in `tests/core/file/fileCollect.test.ts` cover the new code path.
- **Benchmark-methodology reviewer:** APPROVE WITH NITS. Confirmed the
  before/after binaries differ exactly at the documented one-site swap,
  reproduced byte-equivalence, and ran two independent paired n=20
  benchmarks bracketing the +38.2 ms claim. The t-stat math checks out
  (38.2 / (81.3/√50) = 3.32 ≈ reported 3.33). Doc nit on the microbench
  vs pipeline file-count difference is addressed inline above.
2026-05-08 13:01:52 +00:00
Claude 15ee2f8d40 perf(core): Use 3 metrics warm-up workers for unconstrained scope
Packing.

  Bumps EAGER_WARMUP_THREADS from 2 to 3 in src/core/packager.ts when the
  user did not narrow the file set via --include / config.include / --stdin.
  Tinypool fixes maxThreads at construction, so the 3rd worker must be
  pre-warmed during the searchFiles + collectFiles window or it stalls
  dispatch (a 4-thread / 2-warm experiment regressed by 27% paired in a
  prior iteration). With explicit scope the file set is typically a few
  hundred files, the metrics phase is shorter, and the 3rd worker's
  ~250ms BPE warm-up dominates the parallelism gain — paired benchmarks
  regressed -11.85% on the 258-file `--include 'src,tests'` workload at
  unconditional EAGER_WARMUP_THREADS=3, so the heuristic falls back to 2.

Reasoning.

  After change 3 on this branch (eager metrics warm-up), the metrics phase
  is the dominant wall-clock contributor on the default 1046-file workload
  (~770 ms in `calculate metrics`, vs ~120 ms output generation, ~370 ms
  search, ~270 ms collect, ~200 ms security check). Five sub-agent
  investigations over independent scopes (CLI startup, file search/glob,
  file collect/security, output generation, token counting) converged on
  metrics worker count as the only candidate clearing the 2% bar without
  regressing other phases. Output gen, security pre-warm, file-search
  scoping, and CLI lazy-load were all measured below threshold or net-
  negative; documented as the previous iteration's notes plus the
  follow-on attempts here:

  - EAGER_WARMUP_THREADS=3 unconditional: -11.85% paired regression on
    the 258-file workload (n=20, t=-10.85), +2.92% on the 1046-file
    workload — net negative because small workloads can't amortize the
    extra BPE parse.
  - Pre-warm the security worker pool gated on the metrics warm-up:
    security-check phase shrunk from 197 ms to 110 ms, but the saving was
    absorbed by the parallel `Process Files` branch and an offsetting
    worker-spawn cost during collect. Paired n=30 measured -4.90% on
    258-file and 0.81% (noise) on 1046-file. Reverted.

Verification.

  Paired interleaved benchmarks (n=20, NODE_DISABLE_COMPILE_CACHE=1):

  Default workload — `node bin/repomix.cjs --quiet` (1046 files):
  |        | min     | median  | mean    | max     | sd     |
  |--------|---------|---------|---------|---------|--------|
  | BEFORE | 1820 ms | 1885 ms | 1886 ms | 2020 ms | 45 ms  |
  | AFTER  | 1700 ms | 1845 ms | 1840 ms | 1970 ms | 62 ms  |
  - Mean paired Δ:   +46.5 ms (2.46% wall-clock reduction)
  - Median paired Δ: +50.0 ms (2.65%)
  - Paired-delta SD: 65.3 ms · paired t = 3.18 (p < 0.01)
  - AFTER faster in 15/20 pairs (75%)

  Scoped workload — `node bin/repomix.cjs --include 'src,tests' --quiet`
  (258 files):
  |        | min     | median  | mean    | max     | sd     |
  |--------|---------|---------|---------|---------|--------|
  | BEFORE | 900 ms  | 955 ms  | 953 ms  | 990 ms  | 25 ms  |
  | AFTER  | 910 ms  | 940 ms  | 946 ms  | 1010 ms | 29 ms  |
  - Mean paired Δ:   +6.5 ms (0.68%) — neutral within noise (t = 0.90)
  - The heuristic falls back to 2 warm workers, so this branch matches
    pre-change behavior; the small positive delta is sampling noise.

  An independent reviewer's paired n=15 NODE_DISABLE_COMPILE_CACHE=1 run
  on a separate sample reported +4.10% (t=6.61, 14/15 pairs) on the
  default workload, consistent direction at higher magnitude.

Correctness.

  - All 1260 unit tests pass (`npm test`); 3 new tests in
    `tests/core/packager.test.ts` exercise both heuristic branches plus
    the `--stdin` (explicitFiles) path.
  - `npm run lint` clean (only pre-existing warnings unchanged from main).
  - XML and Markdown output byte-identical between BEFORE and AFTER on
    both workloads (verified via sha256sum).
  - Worker-pool size confirmed via `--verbose` logs:
    - Default scan: `min=1, max=3 threads` for `calculateMetrics`.
    - `--include 'src,tests'`: `min=1, max=2 threads` (unchanged).
  - Single-CPU and 2-CPU hosts are unaffected (`min(cpuCount, 3) =
    min(cpuCount, 2)` for cpuCount ≤ 2).
  - Public `pack()` API unchanged (no new parameters; the heuristic reads
    existing `config.include` and `explicitFiles` arguments).

Risks.

  The heuristic is a coarse proxy. Pathological cases:
  - User runs default scan on a tiny repo (~50 files): 3 workers, +1
    extra BPE parse. The cost is bounded by the eager-warm-up overlap
    with searchFiles/collectFiles, so the worst case approaches the
    paired noise floor (~30 ms sd on 258-file). Not measured below 50
    files; expected to be neutral-to-slightly-negative within typical
    run-to-run variance.
  - User runs `--include 'huge-dir'` on a 5000-file project: 2 workers,
    misses the parallelism win. Falls back to current production
    behavior — no regression vs main.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 09:48:32 +00:00
Claude d51de61526 perf(core): Start metrics worker warm-up before searchFiles
Background
----------
Each metrics worker independently parses gpt-tokenizer's ~2.2 MB
`o200k_base.js` BPE table on its first task (~200-300 ms pure-CPU per
worker). The pool was previously created in `pack()` after the file
search and sort phases, so the only stages that could absorb the BPE
warm-up were `collectFiles` + git subprocesses + security check + file
processing. On a 258-file run this still left a residual ~80-130 ms
`await metricsWarmupPromise` stall before the metrics phase.

Change
------
Move `createMetricsTaskRunner` to fire before `searchFiles`. This adds
the ~130 ms glob scan to the hidden warm-up budget and shrinks the
residual stall to ~0-12 ms on the 258-file workload.

Pool sizing: Tinypool fixes `maxThreads` at construction, and the file
count is not yet known. Pre-warming exactly the workers we'll use is
essential — Tinypool queues tasks for newly spawned (cold) workers and
the pipeline can't progress until those workers finish their BPE parse
and pick up the queued task (an experiment with `maxThreads=cpuCount=4`
and only 2 warm workers regressed the 258-file workload by 27 % paired).
So the pool is sized to a fixed 2 workers (`numOfTasks = 2 ×
TASKS_PER_THREAD = 400` → `maxThreads = min(cpuCount, 2)`), matching the
security pool's hard cap and the typical metrics pool size for repos
≤400 files after the TASKS_PER_THREAD=200 sizing on this branch.

Larger repos (>400 files) would benefit from more parallelism, but the
1046-file regression check below shows the eager-warmup gain still
net-improves wall-clock at maxThreads=2 (the BPE warm-up cost
~250 ms × cpuCount-2 extra workers dominates the parallelism savings on
the metrics phase). On single-CPU hosts the heuristic naturally
collapses to maxThreads=1, identical to today's behavior.

The `try { } finally { cleanup }` block is widened to cover the new
early call so the worker pool is cleaned up on early throws too. A new
`searchFiles`-rejection test in `tests/core/packager.test.ts` exercises
that path explicitly.

`TASKS_PER_THREAD` is exported from `processConcurrency.ts` and consumed
by name in `packager.ts` to keep the eager-warmup constant tied to the
shared sizing rule.

Benchmark
---------
Both runs use n=… paired interleaved (alternating BEFORE-first /
AFTER-first ordering) with `NODE_DISABLE_COMPILE_CACHE=1` so cold-start
BPE parse is measured rather than masked. 4-vCPU Intel(R) Xeon(R) host.

`node bin/repomix.cjs --include 'src,tests' --quiet` (258 files, n=20):

|        | min     | median  | mean    | max     | sd     |
|--------|---------|---------|---------|---------|--------|
| BEFORE | 1007 ms | 1044 ms | 1054 ms | 1164 ms | 36 ms  |
| AFTER  |  893 ms |  966 ms |  962 ms | 1065 ms | 36 ms  |

- Mean paired Δ:   +91.6 ms (8.69 % wall-clock reduction)
- Median paired Δ: +97.5 ms (9.34 %)
- Paired-delta SD: 36.0 ms · paired t = 11.39 (p < 0.001)
- AFTER faster in 20/20 pairs (100 %)

Regression check — `node bin/repomix.cjs --quiet` (default, 1046 files,
n=15) on a clean repo (baseline binary built outside the working tree
so it does not get picked up as a workload file):

|        | min     | median  | mean    | max     | sd     |
|--------|---------|---------|---------|---------|--------|
| BEFORE | 1769 ms | 1872 ms | 1877 ms | 2063 ms | 79 ms  |
| AFTER  | 1751 ms | 1820 ms | 1837 ms | 2018 ms | 61 ms  |

- Mean paired Δ:   +40.0 ms (2.13 %)
- Median paired Δ: +48.6 ms (2.60 %)
- Paired-delta SD: 51.7 ms · paired t = 2.99 (p ≈ 0.01)
- AFTER faster in 11/15 pairs (73 %)

The larger workload also clears the 2 % threshold; the eager warm-up's
gain offsets the maxThreads=2 cap that's now applied unconditionally.

Correctness
-----------
- All 1257 unit tests pass (`npm test`); `npm run lint` clean (only
  pre-existing warnings).
- XML and Markdown output byte-identical between BEFORE and AFTER on
  both the 258-file and 1046-file workloads.
- Worker-pool size confirmed via `--verbose` logs: `min=1, max=2 threads`
  for `calculateMetrics` on both workloads (was `max=2` on 258 files,
  `max=4` on 1046 files before this change).
- New test `cleans up the metrics worker pool when searchFiles rejects`
  exercises the widened `try/finally` cleanup path.
2026-05-07 02:56:06 +00:00
Claude cf013a0c8f perf(shared): Raise TASKS_PER_THREAD from 100 to 200 to reduce worker contention
Background
----------
On a typical CLI run (`node bin/repomix.cjs --include 'src,tests' --quiet`,
258 files, 4-vCPU host), the metrics worker pool was sized as
`ceil(258 / 100) = 3 workers`. Combined with the security pool's hard cap
of 2 workers (securityCheck.ts:90) and the main thread, the process held
6 active threads on 4 cores during the overlap of `validateFileSafety`
and `calculateMetrics`.

Each metrics worker independently parses gpt-tokenizer's ~2.2 MB
`o200k_base.js` BPE table on its first task — a ~200-300 ms pure-CPU
operation per worker. Spawning 3 cold metrics workers in the warm-up
phase (calculateMetrics.ts:46-48) therefore drove the security workers
off the CPU during their own (concurrent) cold-start, inflating the
critical-path security phase.

Change
------
Raise `TASKS_PER_THREAD` from 100 to 200 so:

- ≤200 file repos:    1 metrics worker (was 1)         — no change
- 201-400 file repos: 2 metrics workers (was 3)        — -1 worker, the win
- 401-600 file repos: 3 metrics workers (was 4-cap)    — -1 worker
- 601-800 file repos: 4 metrics workers (was 4-cap)    — no change
- 801+ file repos:    4 metrics workers (was 4-cap)    — no change (cap)

For the 258-file benchmark this brings active workers during the
metrics+security overlap to 2 + 2 = 4, matching CPU count, and halves
the parallel BPE-loading work in the warm-up phase.

Tests for `getWorkerThreadCount` and `createWorkerPool` are updated to
reflect the new ratio.

Benchmark
---------
`node bin/repomix.cjs --include 'src,tests' --quiet` (258 files), n=20
paired interleaved (alternating BEFORE-first / AFTER-first ordering):

|        | min     | p25     | median  | p75     | mean    | sd     |
|--------|---------|---------|---------|---------|---------|--------|
| BEFORE | 1045 ms | 1092 ms | 1109 ms | 1122 ms | 1107 ms | 27 ms  |
| AFTER  |  937 ms |  973 ms |  991 ms | 1020 ms |  994 ms | 29 ms  |

Mean paired Δ:   +112.5 ms  (10.17 % wall-clock reduction)
Median paired Δ: +115.4 ms  (10.66 % wall-clock reduction)
Paired-delta SD: 36.2 ms  (paired t = 13.88, p < 0.001)
AFTER faster in 20/20 pairs (100 %)

Regression check — `node bin/repomix.cjs --quiet` (default, 1572 files),
n=15 paired interleaved:

|        | min     | p25     | median  | p75     | mean    | sd     |
|--------|---------|---------|---------|---------|---------|--------|
| BEFORE | 1933 ms | 1970 ms | 2016 ms | 2102 ms | 2028 ms | 62 ms  |
| AFTER  | 1955 ms | 1966 ms | 2004 ms | 2131 ms | 2034 ms | 74 ms  |

Mean paired Δ:   -6.2 ms (-0.31 %)  (paired t = -0.29, p > 0.05)
Median paired Δ: -12.7 ms (statistically neutral)

No regression on the large workload — both 100 and 200 saturate the
per-CPU cap at 4 workers for ≥800 file repos, so the dispatch-time
behavior is identical there.

Correctness
-----------
- 1256 / 1256 unit tests pass.
- `npm run lint` clean (only pre-existing warnings unrelated to this change).
- No behavioral change to file processing, tokenization, security checks,
  or output. Pool sizing is the only effect.
2026-05-07 01:11:11 +00:00
Claude 974fb27a81 perf(output): Lazy-load handlebars and style templates to defer ~50ms startup cost
Handlebars and the per-style template modules (which transitively re-import
handlebars via `outputStyleUtils`) collectively add ~50 ms to the synchronous
CLI startup path, even though they are only consumed by `generateHandlebarOutput`
near the end of `pack()` — after file search, collection, processing, and the
security check have all run.

Switch the static `import Handlebars from 'handlebars'` and the three style
imports to a `import type` plus dynamic `await import(...)` inside
`getCompiledTemplate`. The compiled-template cache (`compiledTemplateCache`)
ensures the imports only run once per process. The dynamic load now overlaps
with `calculateMetrics` (the two run inside the `Promise.all` in `packager.ts`),
so on workloads where the metrics phase is the wall-clock critical path the
import cost is fully hidden; on smaller workloads the cost moves off the
serial startup path.

## Benchmark — node bin/repomix.cjs --include 'src,tests' --quiet (258 files), n=30 paired interleaved

|        | min     | p25     | median  | p75     | mean    | sd      |
|--------|---------|---------|---------|---------|---------|---------|
| BEFORE |  983 ms | 1061 ms | 1088 ms | 1108 ms | 1081.6 ms | 39.1 ms |
| AFTER  |  998 ms | 1040 ms | 1061 ms | 1078 ms | 1058.8 ms | 32.7 ms |

- Mean paired Δ: +22.7 ms (~2.10 % wall-clock reduction)
- Median paired Δ: +24.5 ms
- AFTER faster in 22/30 pairs (73 %)

A second independent re-run on the same machine (n=15, AFTER-first ordering)
reproduced the direction with mean Δ +21.5 ms / median Δ +12 ms / 10/15 pairs
faster — paired-delta SD ≈ 48 ms, so the 95 % CI on the per-machine effect
straddles zero (t(14) ≈ 1.75, p ≈ 0.10). The mean magnitude is consistent
across runs but the per-pair variance on this 4-vCPU host is large relative
to the effect; the percentage-level claim should be read as "~2 % mean
reduction in the typical case, with run-to-run noise dominating any single
pair." Cleaner machines would likely tighten the CI.

## Benchmark — node bin/repomix.cjs --quiet (default, 1572 files), n=30 paired interleaved

|        | min     | p25     | median  | p75     | mean    | sd      |
|--------|---------|---------|---------|---------|---------|---------|
| BEFORE | 1906 ms | 2040 ms | 2084 ms | 2147 ms | 2087.2 ms | 80.0 ms |
| AFTER  | 1912 ms | 2024 ms | 2097 ms | 2145 ms | 2089.6 ms | 81.2 ms |

- Mean paired Δ: -2.5 ms · Median paired Δ: +2.5 ms — statistically neutral
- AFTER faster in 16/30 pairs (53 %)
- No regression: the import cost is fully absorbed by the longer metrics tail
  on this workload.

## Correctness

- XML output (default style) byte-identical between BEFORE and AFTER (verified
  via `cmp` on `--include 'src,tests'`).
- Markdown output byte-identical between BEFORE and AFTER (verified via `cmp`
  with `--style markdown --output /tmp/{before,after}.md`).
- The compiled-template cache continues to dedupe per style; the
  `markdownStyle.ts` top-level call to `registerHandlebarsHelpers()` runs the
  first time the markdown branch is awaited (idempotent — the helper module
  guards on `handlebarsHelpersRegistered`, so the duplicate-import path that
  exists in `skillSectionGenerators.ts` continues to work unchanged).
- All 1256 tests pass (`npm test`); lint clean (only pre-existing warnings).

## Why other candidates were not chosen

Five investigation sub-agents (CLI startup, file I/O, security pipeline,
output, metrics) ran in parallel. Other candidates measured below threshold
or regressed on the larger workload:

- Run `lintSource` on the main thread instead of the security worker pool
  (security candidate): +3.78 % on 258-file pack, but a reproducible -1.85 %
  regression on the 1572-file pack — the main-thread `await lintSource` loop
  starves I/O callbacks (worker-pool messages, git pipe drains) for ~190 ms
  on the large repo and the savings are eaten by downstream phases.
  `setImmediate` yielding reduced but did not eliminate the regression.
- `METRICS_BATCH_SIZE` 50 → 100 (metrics candidate): claimed alignment with
  `TASKS_PER_THREAD = 100`, but measured -2.56 % regression on the 1572-file
  pack (5/20 pairs faster). The original comment-warning held empirically.
- Skip `calculateFileLineCounts` / `calculateMarkdownDelimiter` for non-markdown
  styles (output candidate): only 0.6–1.0 % wall-clock impact, below threshold.
- Skip the `**/.ignore` globby file-tree scan when no root `.ignore` exists
  (file I/O candidate): warm-disk savings ~10–35 ms, below threshold.
2026-05-06 23:23:51 +00:00
Kazuki Yamada b99706131b Merge pull request #1515 from yamadashy/feat/dart-extra-definitions
feat(core): Capture mixin, typedef, getter, setter, and factory in Dart query
2026-05-06 22:28:35 +09:00
Kazuki Yamada 110995b384 style(core): Wrap redirecting factory query in (declaration ...) for symmetry
The redirecting_factory_constructor_signature pattern previously stood on
its own. Wrap it in (declaration ...) like the other constructor and
factory patterns above so the comment about being a direct child of
'declaration' is reflected in the query shape itself. Behavior is
unchanged — the wrapper does not alter what gets matched in practice.

Addresses PR review feedback from coderabbitai.
2026-05-06 22:12:39 +09:00
Kazuki Yamada d10814163d fix(core): Cover const and external constructors in Dart query
Three constructor variants were silently dropped during --compress:

- `const Foo(...);` and `const Foo.named(...) : ...;` parse as
  `(declaration (constant_constructor_signature ...))` — a node type the
  existing constructor query did not list.
- `const factory Foo() = Bar;` parses as
  `(redirecting_factory_constructor_signature (const_builtin) (identifier) ...)`
  whose first named child is `const_builtin`, so the leading-anchor
  `. (identifier)` pattern failed to match.
- `external factory Foo.make();` parses as
  `(declaration (factory_constructor_signature ...))` — bare under
  `declaration`, not wrapped in `method_signature`, so the existing
  factory query missed it.

Switch the constructor / factory / redirecting-factory queries to
capture the whole signature node as `@name.definition.method`. This
emits the same source line(s) DefaultParseStrategy already produces and
is robust across all body / external / const / redirecting variants.
2026-05-06 22:12:39 +09:00
Kazuki Yamada 34670ec66c fix(core): Tag Dart extension_declaration as @definition.extension
Use a kind-specific tag for extension_declaration instead of reusing
@definition.class, matching the convention used by the newly added
@definition.mixin / @definition.enum / @definition.type tags.

Output is unchanged: DefaultParseStrategy only branches on
name.includes('name'), and no consumer reads @definition.* values today.
This keeps the labels honest if per-kind handling lands later.

Addresses PR review feedback from gemini-code-assist and coderabbitai.
2026-05-06 22:12:39 +09:00
Kazuki Yamada def2985b30 feat(core): Capture plain constructor and operator overload in Dart query
Two pre-existing gaps surfaced while extending queryDart:

- Plain constructors (e.g. `Animal(this.name);`) live directly under
  `declaration`, not wrapped in `method_signature`, so the existing
  `(method_signature (constructor_signature ...))` query never matched
  them. Add a sibling query against `(declaration (constructor_signature ...))`.
- Operator overloads (`operator +`, `operator []`, `operator []=`,
  `operator ==`, ...) parse as `(method_signature (operator_signature ...))`
  but `operator_signature` has no identifier name field — the operator
  token surfaces as `(binary_operator)` / `([])` / `([]=)` children.
  Capture the whole `operator_signature` as `@name.definition.method` so
  DefaultParseStrategy emits its full source range.

Verified against `--compress` on a real Dart file: signatures that were
previously dropped (only their `///` doc comments survived) now appear
in compressed output.
2026-05-06 22:12:39 +09:00
Kazuki Yamada 3249b87665 chore(deps): Bump @repomix/tree-sitter-wasms to ^0.1.17
Carries upstream Dart 3.10 dot-shorthand and external-member fixes,
unblocking the additions in this PR. Previously deferred by
.npmrc min-release-age=7; 0.1.17 was published 2026-04-22 so the
window is now clear.
2026-05-06 22:12:39 +09:00
Kazuki Yamada ada200a080 feat(core): Capture mixin, typedef, getter, setter, and factory in Dart query
intent(dart-query): make --compress preserve Dart definition kinds that were silently dropped — mixin, typedef, getter, setter, factory, and redirecting factory
decision(capture-naming): align Dart captures with the dominant @name.definition.X convention used by queryTypeScript/queryPython/queryRust; output is unchanged because DefaultParseStrategy matches via name.includes('name')
constraint(redirecting-factory): tree-sitter-dart grammar makes redirecting_factory_constructor_signature a child of `declaration`, not `method_signature`, so it must be queried bare to avoid a "Bad pattern structure" parse error
constraint(type-alias): type_alias's name node is `type_identifier`, not `identifier` — using `identifier` would silently match nothing
learned(external-keyword): `external` modifier in Dart is a sibling token outside function_signature/method_signature, so existing captures already cover `external void foo();` without changes
2026-05-06 22:12:39 +09:00
Kazuki Yamada af128cc9a1 Merge pull request #1545 from yamadashy/perf/turnstile-siteverify-metric
perf(website): Show verifying step + emit siteverify duration metric
2026-05-06 22:11:16 +09:00
Kazuki Yamada 54c6a3d238 fix(website): Address claude third-pass review on siteverify metric
Six items from claude's incremental review (`12:48:43Z`):

- monitoring/dashboard.json: Group the outcomes widget by both
  `metric.label.outcome` and `metric.label.reason`. Previously all
  failures collapsed into a single `turnstile_failed` series, which
  contradicted the README claim that the `reason` label drives the
  breakdown.
- monitoring/metrics/*.yaml: Narrow the metric filter to
  `jsonPayload.event=("turnstile_siteverify" OR "pack_completed")`.
  Without this anchor, any future code path attaching
  `siteverifyDurationMs` to an unrelated log silently joins the
  distribution and creates new metric label values.
- usePackRequest.ts: Mirror `progressMessage.value = null` alongside
  the `progressStage.value = null` clear on token-acquisition aborted /
  error branches. Prevents a future edit setting a verifying message
  from leaking prior-run state.
- turnstile.test.ts: Add a focused `describe` block with five tests
  asserting `siteverifyDurationMs` is attached to every post-siteverify
  log (one success path + four reject branches). The metric YAML
  filters on field presence, so a refactor that drops the field on any
  branch would silently break the metric without other tests failing.
  Uses the existing `vi.spyOn(logger, ...)` pattern; no clock injection
  needed.
- monitoring/README.md: Note that the metric filter pins
  `service_name="repomix-server-us"`, so future regions (`-eu`,
  `-asia`) silently drop out until the filter is broadened or
  per-region counterparts applied.
- monitoring/README.md: Add a `gcloud logging metrics describe` snippet
  for verifying a YAML edit was actually applied (gcloud update is
  silent on no-op vs effective change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:59:53 +09:00
Kazuki Yamada fa06e5059c fix(website): Address PR review feedback on siteverify metric
Six items from gemini, claude initial review, and claude follow-up:

- turnstile.ts: Update misleading comment that claimed the metric filters
  on `event=turnstile_siteverify` and `outcome=success`. The actual
  Cloud Monitoring metrics in `monitoring/metrics/` filter on
  `siteverifyDurationMs` field presence, which uniformly captures both
  the parallel success log (event=turnstile_siteverify) and the four
  rejectAndLog failure paths (event=pack_completed). The comment
  contradicted README and YAML and would mislead future readers.
- turnstile.ts: Wrap rejectAndLog in a local `rejectWithDuration` helper
  so every post-siteverify branch automatically carries
  `siteverifyDurationMs`. Prevents drift if a fifth reject reason gets
  added later.
- client.ts: Split the wire-protocol `PackProgressStage` (server-emitted
  SSE values) from the display-only `DisplayProgressStage` superset that
  adds `verifying`. Keeping the synthetic stage out of the wire type
  prevents silent divergence with the server's `PackProgressStage`.
- usePackRequest.ts, TryItLoading.vue, TryItResult.vue: Switch the
  display-side type to `DisplayProgressStage`. `onProgress` callbacks
  still take the wire `PackProgressStage`.
- usePackRequest.ts: Clear `progressStage` on token-acquisition failure
  branches (aborted / error). Functionally invisible since loading=false
  hides the loading UI, but prevents the next submit's verifying flash
  from briefly showing the previous run's stale state.
- monitoring/metrics/turnstile_siteverify_duration.yaml: Retune the
  exponential bucket layout for the 100ms-1s SLO band where decisions
  get made. Doubling buckets only placed ~3 boundaries between 100ms
  and 1s; growthFactor=1.5 with scale=10 places ~8 boundaries there.
  18 finite buckets cover 10ms to ~9.85s, comfortably above the 5s
  siteverify timeout so timeouts don't land in overflow.
- monitoring/README.md: Document that pre-network rejections
  (secret_missing, missing_token, token_too_long) intentionally don't
  carry siteverifyDurationMs, so they're excluded from both metrics
  but still appear in the existing pack_requests metric.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:42:21 +09:00
Kazuki Yamada 76522fc13e chore(monitoring): Check in Turnstile siteverify metric definitions
Move the metric setup from prose-only README instructions to checked-in
YAML files under `monitoring/metrics/` so the dashboard, the metrics it
depends on, and the apply commands all live next to each other.

- `turnstile_siteverify_duration.yaml`: distribution metric on
  `jsonPayload.siteverifyDurationMs`, exponential buckets 1ms-32s.
- `turnstile_siteverify_outcomes.yaml`: counter metric with `outcome`
  and `reason` labels for the success-vs-failure breakdown widget.

README updated with the gcloud commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:24:48 +09:00
Kazuki Yamada aea2401bfc chore(monitoring): Add Turnstile siteverify dashboard widgets
Wire up two new tiles to surface the `siteverifyDurationMs` field added
in the previous commit:

- "Turnstile siteverify latency P50 / P95 / P99" — line chart with a
  1s threshold marker so a steady regression jumps off the chart.
- "Turnstile siteverify outcomes (by outcome)" — stacked area
  breaking down success vs turnstile_failed counts over time.

Both depend on log-based metrics `turnstile_siteverify_duration`
(distribution) and `turnstile_siteverify_outcomes` (counter) that need
to be created once in the GCP Console — README documents the filter,
field, and label extractors so the setup is reproducible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:21:47 +09:00
Kazuki Yamada 35c56abd02 perf(website): Show verifying step + emit siteverify duration metric
Two changes targeting the visible "..." gap between Pack click and the
first SSE progress event observed after PR #1544 landed:

- Client: add a synthetic `verifying` PackProgressStage so the loading
  UI displays "Verifying request..." while the server runs Turnstile
  siteverify (typically 100-1000ms before the first 'cache-check' SSE
  event arrives). The first onProgress callback from handlePackRequest
  overwrites it with the real server-reported stage.

- Server: time the siteverify round-trip in `turnstileMiddleware` and
  emit `siteverifyDurationMs` on every outcome (success / network
  failure / rejected / action mismatch / hostname mismatch). Success
  path adds a structured log with `event: turnstile_siteverify` so
  Cloud Monitoring can build a log-based distribution metric for
  p50/p95/p99 latency and alert on regressions during Cloudflare
  incidents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:19:01 +09:00
Kazuki Yamada 55f43dedfd Merge pull request #1534 from yamadashy/renovate/scripts-non-major-dependencies
chore(deps): update dependency typescript to ^6.0.3
2026-05-06 00:48:26 +09:00
renovate[bot] b8013a6c74 chore(deps): update dependency typescript to ^6.0.3 2026-05-05 15:29:12 +00:00
Kazuki Yamada 7df0b1ba91 Merge pull request #1542 from yamadashy/perf/isbinaryfile-utf8-fastpath
perf(file): Try UTF-8 decode before isBinaryFile to dodge protobuf-detector pathological case
2026-05-06 00:28:16 +09:00
Kazuki Yamada 35e40fcd1b perf(file): Replace JS NULL-byte loop with native Buffer.indexOf SIMD scan
intent(microopt): cheap pre-screen の NULL probe を JS の `for` ループから native `Buffer.indexOf(0)` に置換。`Buffer.indexOf` は V8 内部で SIMD-backed 実装になっており、JS バイト走査より一貫して速い。

bench(microbench, 1328 files × 5 trials median, M-series Mac):
- A   JS-loop NULL probe (512B) + TextDecoder fatal:        9.87ms (現状)
- A2  native subarray(0,512).indexOf(0) + TextDecoder:       8.89ms (-9.9%)
- A3  native buf.indexOf(0) full-buffer + TextDecoder:       8.96ms (-9.2%)
- B   TextDecoder first + String.includes(NUL):             12.14ms (+23.0%)
- D   TextDecoder first + native subarray.indexOf gated:     8.88ms (-10.1%)

decision(order-unchanged): A2/A3/D は実質同じ速度なので順序の変更ではなく実装の置換だけを採る。reorder (D) は「decode に成功したものを後から `binary-content` として捨てる」フローになって直感的でなく、また invalid UTF-8 + NULL のバイナリで先に decode 例外を踏むぶんだけ無駄が出る。現状の「BOM exemption → NULL probe → fast path」の流れは説明しやすく、A2 / A3 で perf も同等。

decision(full-buffer-scan): 範囲は first-512B から **whole buffer** に拡大。NULL probe を残した正当な理由は「U+0000 が XML 1.0 で不正で、出力 XML を破壊する」という出力 robustness 要件で、これは buffer 全長で成立しないと意味がない。前版の 512B 制限は `isbinaryfile` の `MAX_BYTES` を mirror した名残だが、`isbinaryfile` の rule mirror は本 PR が依存を断ち切った範囲なので、512B に縛る理由はもうない。bench で full-scan の追加コストは 0.07ms (= ノイズ) なので採用。

decision(skip-string-includes): `String.includes('\0')` は decode 後の string scan が必要で、buffer scan より無駄が多い (+23% measured)。avoid。

constraint(no-behavior-change-for-tests): BOM exemption / decode 結果は同一なので既存テスト 12 件すべて pass。出力差分も既存 base に対して 0 削除 / +1 (Korean md) で同一。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:19:57 +09:00
Kazuki Yamada 3ff49306e1 refactor(file): Simplify cheap pre-screen down to NULL probe + BOM exemption
intent(simpler): 前 commit の cheap pre-screen は `isbinaryfile@5.0.2` の `isBinaryCheck` のうち valid UTF-8 でも binary 判定する 3 規則 (PDF magic / NULL / suspicious 制御バイト比率 >10%) を mirror していたが、`TextDecoder('utf-8', { fatal: true })` を `isBinaryFile` の前に動かしている時点で valid UTF-8 buffer は protobuf detector に渡らない。pathological case 回避という主目的は TextDecoder の reorder だけで達成しており、PDF magic と suspicious-byte ratio の mirror は (1) 実害ほぼゼロのエッジケースを救うだけで (2) `isbinaryfile` 内部実装への coupling を抱える、という割に合わない構成だった。

fix(simplify): cheap pre-screen を NULL-byte probe + BOM exemption の最小構成に縮小。

decision(keep-null-probe): NULL byte だけは独立した正当な理由で残す — `U+0000` は **XML 1.0 で不正な文字** で、本ツールの主出力フォーマット (XML) の正当性を破壊する。`TextDecoder` は `0x00` を valid UTF-8 (U+0000) として通すので、ここで弾かないと NULL を含む buffer が text として pack され、downstream の XML parser が落ちる。これは `isbinaryfile` の rule mirror ではなく、repomix 自身の出力 robustness 要件。

decision(drop-pdf-magic): PDF は `is-binary-path` の `.pdf` 拡張子で先に弾かれる。拡張子なしの ASCII-only PDF stub は実例ほぼゼロ (本物の PDF は cross-ref とバイナリストリームを内包し UTF-8 decode で失敗する経路を通る)。守る価値が低い。

decision(drop-suspicious-ratio): 純粋な C0 制御バイト高比率の valid UTF-8 buffer は実プロジェクトに存在しない。`isbinaryfile` の UTF-8 lookahead を完全 mirror する保守コスト (DEL boundary 等のドリフトリスク) > 効用。

constraint(coupling-minimal): NULL byte は universal な binary signal で、`isbinaryfile` の rule に縛られない。同 BOM exemption も標準 BOM の規格に準拠したもので upstream ドリフトの影響を受けない。

test(cleanup): 関連 regression test 3 件を削除 (PDF magic / suspicious ratio / DEL boundary)。これらは削除した規則の挙動を保証するもので、現実装ではすべて意図的に「text として pack」する。残るのは UTF-8 multi-byte / UTF-8 BOM+NULL / UTF-16 LE BOM の 3 件で、いずれも本 PR が回避したい pathological / regression を直接守る。

bench(no-regression, M-series Mac, hyperfine --warmup 1 --runs 5):
- `node bin/repomix.cjs --quiet`: 399ms → 418ms (誤差範囲、JS の手書き 512-byte loop が消えた分の差は noise floor)
- 出力差分: 既存の base に対して 0 ファイル削除、Korean md が +1 (silent drop 解消、変更なし)
- fileRead.ts: 175 → 159 行 (-16 行)。pre-screen 関連で実質 ~35 行削減。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:11:29 +09:00
Kazuki Yamada be96e7a41e Merge pull request #1544 from yamadashy/perf/turnstile-premint
perf(website): Pre-mint Turnstile token on user intent
2026-05-05 23:07:38 +09:00
Kazuki Yamada 6fe66f172a revert(website): Drop ?repo= -> userTouched flip to avoid amplification
A valid `?repo=` permalink was treated as an intent signal so the
visitor's click path used a pre-minted token. claude's follow-up review
flagged that this re-creates the dashboard counter inflation this PR is
meant to fix: any third-party page driving traffic to
`https://repomix.com/?repo=<owner/repo>` (Slack / Discord / Twitter card
validators that execute JS) would mint a token per visit, regardless of
whether the visitor ever submits.

Permalink visitors now pay the cold mint on their first click; the user's
first real form interaction (typing, mode click, option tweak, file
upload) is what gates the pre-mint.

Also document the idle-tab cliff in `expired-callback`: re-arming
pre-mint there would burn a challenge every 240s for the lifetime of
an open tab, which is worse than the cold-mint-on-return cost it would
save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:37:59 +09:00
claude[bot] 23494e5c8d fix(website): Remove leftover useTurnstile.error references
Commit 2a0922d dropped the `error` ref declaration from `useTurnstile`
but left two references behind — `error.value = null` at the top of
`mintToken()` and `error` in the returned object. The first throws
ReferenceError at runtime; the second is a tsc error. No external
caller reads `useTurnstile().error` (TryIt.vue only consumes
`usePackRequest.error`), so the export was already vestigial.

Co-Authored-By: Kazuki Yamada <yamadashy@users.noreply.github.com>
2026-05-05 13:34:07 +00:00
Kazuki Yamada 2a0922d114 refactor(website): Address claude follow-up review on Turnstile pre-mint
- Drop the unused `error` ref from `useTurnstile`. The widget-level
  error-callback writes had no observer (only `usePackRequest.error`
  feeds the UI), so the export and writes were vestigial.
- Drop `getResponse` from `TurnstileGlobal`. Never called anywhere in
  the codebase; clearer to leave it off the typed surface.
- Don't `console.warn` on normal cancel/timeout flows in
  `acquireTurnstileToken`. Move the warn after the `signal.aborted`
  check so the dev console only logs genuine challenge / script-load
  failures.
- Hoist the consecutive `if (widgetId.value)` guards in `mintToken` by
  capturing the rendered widget id into a local const after the throw.
- Drop the redundant `userTouched.value` check in the post-pack
  pre-mint guard. `userTouched` is necessarily true at this point —
  it was a precondition for `isSubmitValid` being true when the submit
  started.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:28:33 +09:00
Kazuki Yamada 2ee93b3214 refactor(website): Address remaining PR feedback on Turnstile pre-mint
- Mark `userTouched=true` when arriving via a valid `?repo=` permalink so
  the visitor's click path uses a pre-minted token. Browser autofill and
  malformed `?repo=` values still don't burn a challenge.
- After a non-aborted submit completes, schedule a fresh `preMintToken()`
  in the finally block. Warms the cache for the typical "view result →
  tweak options → repack" flow and for `repackWithSelectedFiles`.
- Reduce the pre-mint debounce from 500ms to 300ms. Tightens the window
  where a paste-and-click cadence misses the cache.
- Split composables to fit the 250-line file-size guideline:
  * Extract token cache (cache state + single-flight mint + atomic
    one-shot consumption) into `useTurnstileTokenCache.ts`. Shrinks
    `useTurnstile.ts` from 358 → 241 lines and lets the widget file
    focus on render lifecycle / supersede logic.
  * Extract pre-mint debounce trigger into `usePreMintDebounce.ts`.
  * Extract Turnstile token acquisition + user-facing failure copy into
    `turnstileSubmit.ts`. Drops `usePackRequest.ts` from 345 → 331
    lines; `submitRequest` is a single cohesive request lifecycle that
    resists further splitting.
- Drop the unused `consumed` flag on `CachedToken` (claude review). The
  cache nulls the entry on consumption instead, which is what the
  takeToken atomic-claim loop already relies on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:01:25 +09:00
Kazuki Yamada b9a9e719f8 fix(website): Address PR review feedback on Turnstile pre-mint
- Drop the unused `invalidateCache` export from useTurnstile. Both call
  paths (takeToken cache claim, expired-callback) already null
  cachedToken inline, so the helper had no callers.
- Update stale `turnstile.getToken()` references in usePackRequest and
  useTurnstileScript comments to match the renamed `takeToken()` /
  `preMintToken()` API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:41:48 +09:00
Kazuki Yamada 01a1d237b9 fix(website): Address codex review on Turnstile pre-mint flow
- useTurnstile: Make takeToken() one-shot under concurrency. Two callers
  awaiting the same shared mintPromise both received the same token,
  which siteverify rejects as `timeout-or-duplicate`. Claim the cache
  atomically post-await and loop into a fresh mint if another caller won.
- usePackRequest: Drop pending pre-mint debounce timer at submitRequest
  start and on unmount, and skip scheduling while loading is true. Stops
  a debounce-firing-during-submit from minting an extra Turnstile
  challenge alongside the click path's mint.
- TryItPackOptions: Emit userInput from option handlers and wire to
  markUserTouched in TryIt. Without this, users hydrating via `?repo=`
  who only tweak format/include patterns/checkboxes never tripped the
  pre-mint gate, so their click path always cold-minted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:54:28 +09:00
Kazuki Yamada 0248b61085 fix(file): Include UTF-8 BOM in cheap pre-screen exemption
intent(no-regression): codex re-review で指摘 — BOM 例外関数 `hasNonUtf8TextBom` が UTF-16/UTF-32/GB18030 のみで UTF-8 BOM (`EF BB BF`) を含めていなかった。`isbinaryfile@5.0.2` の `isBinaryCheck` は UTF-8 BOM を見た瞬間に `false` を返すため、`EF BB BF 00 41` のような buffer は変更前は text として fast path に流れていた。今回の差分では UTF-8 BOM 後の NULL byte が cheap probe で先に拾われ binary 判定される regression。

fix(utf8-bom-exempt): 関数を `hasTextBom` に rename し、UTF-8 BOM (`EF BB BF`) の判定を最初に追加。`isbinaryfile` 本家と同じ並びで BOM 例外を持たせ、cheap probe を skip して UTF-8 fast path に到達させる。

constraint(test-source-non-binary): codex iter2 指摘 — 新規 BOM exemption テストの期待値に raw NUL byte literal を埋めると `fileRead.test.ts` 自身が `grep`/`rg` から binary 扱いになり、ファイル横断検索や CI のテキスト走査ジョブから不可視化される。`'\0A'` エスケープ表記に置換して、実行時の文字列 (char codes 0, 65) は同じまま source は ASCII に戻した。

test(regression): 1 件追加。
- `EF BB BF 00 41` (UTF-8 BOM + NULL + 'A') が `binary-content` で skip されず、UTF-8 fast path で `'\0A'` として decode されることを確認。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:30 +09:00
Kazuki Yamada 6e81c449b6 fix(test): Drop hyphen from "mis-classified" to satisfy typos check
intent(ci-green): typos@1.45.1 flags `mis` as a typo of `miss`/`mist` and the
hyphenated `mis-classified` in the new PDF-magic regression test comment
trips the `Check typos` job. The unhyphenated `misclassified` is the more
common spelling and passes the dictionary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:38 +09:00
Kazuki Yamada bef4c4a805 fix(website): Share mint promise between pre-mint and click paths
intent(fast-click-race): When the user clicked Pack within the 500 ms pre-mint debounce window, takeToken() cold-pathed into mintToken() *and* the debounce timer later fired preMintToken() which started a second mintToken(). The generation-counter supersede logic in mintToken() rejected the first call as "Superseded by new Turnstile request", so the user's own click surfaced as a verification failure even though Turnstile would have happily issued a token.

fix(unified-startMint): Extract a single `startMint()` that both takeToken (cold path) and preMintToken share. Concurrent calls return the same in-flight promise, so only one `turnstile.execute()` ever runs and the supersede branch only triggers when there is genuinely a stale request.

fix(takeToken-abort-race): The signal threading is now via a `waitWithAbort` helper that races the awaiter against the abort signal but lets the underlying mint keep going. If the user cancels mid-mint, the underlying challenge still runs to completion and the token lands in the cache for whoever submits next, instead of being thrown away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:32:09 +09:00
Kazuki Yamada 7c9e44616b perf(website): Pre-mint Turnstile token on user intent, not on mount
intent(latency-and-counter): The Cloudflare Turnstile dashboard's "challenges issued" counter sits at roughly 1.25× of GA page_view, which means simply loading `api.js` on every visitor (the PR #1541 pre-warm) is already inflating the counter — render() and execute() are not the only trigger. At the same time, click→token latency is the main remaining UX cost. This PR reshapes pre-warm so script load + challenge happen only when the user has shown real intent (filled a valid URL or chose a file *and* interacted with the form), achieving both a lower counter and a near-zero click→token latency.

fix(useTurnstile-api): Replace the single `getToken()` entry point with a `preMintToken()` / `takeToken()` pair backed by an in-memory `{ token, mintedAt, consumed }` cache. `preMintToken()` runs the challenge in the background and stashes the resulting token; `takeToken()` consumes the cache synchronously (instant submit) or awaits the in-flight mint, falling back to a cold mint with the supplied AbortSignal. `invalidateCache()` lets the caller drop the cache without minting a new token. TTL is bounded at 240 s — Cloudflare hard-caps tokens at 300 s, the margin absorbs clock skew and network round-trips so a token that's "almost expired" is never sent to siteverify.

fix(useTurnstile-no-mount-prewarm): Stop calling `loadTurnstileScript()` from `setContainer()`. The mount-time script load was the source of the page-view-shaped counter inflation. Pre-warm now only runs from the intent-gated trigger in `usePackRequest`, so visitors who never interact with the form never appear on the dashboard.

fix(usePackRequest-intent-gate): Add a `userTouched` ref that flips on real user interaction (input event, file upload, mode switch) and never goes back. A debounced (500 ms) `watch(isSubmitValid && userTouched)` calls `preMintToken()`, so URL-parameter hydration (`?repo=`), browser form restoration, and autofill never pre-mint. `submitRequest()` switches from `getToken()` to `takeToken()` so the cached token is consumed on the first click, with the cold mint path as a transparent fallback.

fix(TryItUrlInput-user-input-event): Emit a new `userInput` event from the URL field's actual `@input` handler. `TryIt.vue` wires it to `markUserTouched()`. Watching `inputUrl` directly would have re-fired during onMounted hydration and defeated the gate.

learned(cloudflare-counter-includes-script-load): Even with `execution: 'execute'`, the Cloudflare Turnstile dashboard counts api.js loads toward "challenges issued" (verified by comparing GA page_view ≈ 106 with CF issued ≈ 132 in the same 30 min window after the PR #1541 deploy). Treat any new place that loads api.js as a billable analytics side effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:16:54 +09:00
Kazuki Yamada b743c9659c Merge pull request #1543 from yamadashy/chore/codex-review-loop-command
chore(agents): Add codex-review-loop command
2026-05-05 16:22:31 +09:00
Kazuki Yamada 50e9c1ac05 docs(agents): Add frontmatter description to codex-review-loop
Match the frontmatter convention of the sibling review-loop.md so the
description shows up consistently in tooling that surfaces commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:20:12 +09:00
Kazuki Yamada 5ab82d5152 fix(file): Mirror isbinaryfile's PDF magic + suspicious-byte ratio rules in cheap pre-screen
intent(no-regression): codex review で指摘 — 元の差分は cheap pre-screen を NULL-byte probe + UTF-16/UTF-32 BOM 例外のみで構成していたが、`isbinaryfile@5.0.2` の `isBinaryCheck` には valid UTF-8 でも binary 判定する規則が他に2つある: (1) 先頭 5 バイトが `%PDF-` (PDF magic), (2) 先頭 512 バイト中の suspicious 制御バイト比率 >10%。これらを cheap pre-screen に含めないと、`%PDF-` 始まりの拡張子なし/`.txt` ファイルや、ASCII 制御文字が高比率の valid UTF-8 ファイルが従来 skip されていたのに pack に含まれる回帰が発生する。

fix(pdf-magic): UTF-16/UTF-32 BOM 例外の後、NULL probe の前に `%PDF-` 判定を追加。`isbinaryfile` 本家と同じ位置。

fix(suspicious-ratio): 既存の NULL probe ループに suspicious カウンタを追加し、ループ後に `>10%` 閾値で binary 判定。suspicious 集合は `isbinaryfile` の `(b < 7 || b > 14) && (b < 32 || b > 127)` 条件を valid-UTF-8 入力に絞って簡略化したもの: `b < 7` または `b in 0x0F..0x1F`。これは valid UTF-8 multi-byte の continuation/lead bytes (0x80..0xFF) と排他なので、UTF-8 awareness なしの flat byte scan で正しい結果になる。protobuf 検出器 (`isBinaryProto`) は意図的に mirror しない — それが本 PR で回避している pathological case 本体。

constraint(del-boundary): codex 再レビュー指摘 — 当初 0x7F (DEL) を suspicious 集合に含めていたが、`isbinaryfile` の条件 `b < 32 || b > 127` は 127 を排除する。修正版では `b === 0x7f` を外し、コメントも本家挙動に合わせて訂正。

test(regression): 3 件追加。
- valid-UTF-8 PDF magic (`%PDF-1.4\n...`) を `binary-content` で skip することを確認
- 64 バイトの 0x01 のみの buffer (suspicious 100%) を `binary-content` で skip することを確認
- 64 バイトの 0x7F のみの buffer (valid UTF-8, DEL 100%) は **skip しない** ことを境界として固定

bench(no-perf-regression, M-series Mac, hyperfine --warmup 1 --runs 5):
- `node bin/repomix.cjs --quiet`: 406ms → 399ms (誤差範囲)
- pre-screen の追加コストは 512 バイト線形スキャン分で、UTF-8 fast path (元から TextDecoder で全バッファ走査) に比べて無視できる

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:15:44 +09:00
Kazuki Yamada 3390ea36ce chore(agents): Add codex-review-loop command
Adds a review/triage/fix/verify/re-review cycle command that delegates
review to the codex reviewer agent. Sibling to the existing review-loop
command, but explicitly uses codex as the reviewer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:02:18 +09:00
Kazuki Yamada 8a08815ba1 perf(file): Try UTF-8 decode before isBinaryFile to dodge protobuf-detector pathological case
intent(latency): `node bin/repomix.cjs` がリポジトリ自身を pack する際の wall-clock が PR #1533 (`docs(website): Add localized page metadata`, commit 9bd663ae) で 0.38s → 1.15s に約3倍に増えた。9bd663ae は 14言語 × 24ページ = 336個の md に YAML frontmatter (`title` + `description`) を追記しただけで、出力サイズはほぼ変わらないのに実行時間だけが伸びていた。

root-cause(isbinaryfile): `readRawFile` は全ファイルの buffer を `isBinaryFile` (= `isbinaryfile` パッケージ) に通してから UTF-8 fast path に進む。`isbinaryfile` の `isBinaryCheck` は protobuf-shape 検出器 (`isBinaryProto`) を含み、これが任意ファイル先頭バイトを varint として解釈し `new Array(varint)` で配列を確保する。一部の正当な UTF-8 バイト列ではこのループが数秒スピン or `RangeError: Invalid array length` を投げる。具体例: `website/client/src/ko/guide/tips/best-practices.md` (4,243 bytes, valid UTF-8 韓国語 md) は単独で `isBinaryFile` 呼び出しに ~3,500ms かかり最終的に throw → 外側の try/catch で握り潰され `encoding-error` で silent drop されていた。デフォルトの pack 時はこの 1 ファイルだけで毎回 ~3,500ms を払っていた。これは upstream `isbinaryfile` のバグ (信頼できない入力で `new Array(n)` を bound せず確保) だが、修正を待たずに自衛する。

fix(reorder): `isBinaryFile` を UTF-8 fast path の **後** に動かし、UTF-8 として decode 失敗した buffer のみに適用する。NULL バイト (= U+0000、valid UTF-8) を含むバイナリは UTF-8 fast path を素通りしてしまうため、`isBinaryFile` の前に 512 バイトの cheap な NULL-byte probe を挿入。NULL は最強のバイナリシグナルかつ `isBinaryCheck` のうち UTF-8 fatal decode を通過する入力に triggers する唯一の規則。残りの heuristics (PDF magic / suspicious-byte ratio / protobuf shape) は非 UTF-8 バイト列を要求するので、UTF-8 fast path に乗らないファイルだけが従来通り `isBinaryFile` に渡る。

constraint(utf16-utf32-bom): UTF-16 LE は ASCII `A` を `0x41 0x00` と encode し、UTF-32 BE BOM は `0x00 0x00 0xFE 0xFF` で始まる。NULL probe をそのまま走らせるとこれらの text ファイルを binary 誤判定する。`isbinaryfile` 自身の `isBinaryCheck` は BOM 例外を持っているので、`hasNonUtf8TextBom` でこれを mirror し、UTF-16/UTF-32 BOM 始まりの buffer は probe を skip して slow path (jschardet+iconv) にそのまま落とす。挙動は pre-change と同一。

side-effect(restored-file): 上の Korean Markdown ファイルは throw → silent drop されて出力から消えていたが、本修正後は正しく出力に含まれる。

test(regression): `tests/core/file/fileRead.test.ts` に2件追加。
- valid UTF-8 multi-byte (Hangul 3-byte 連続、NULL なし) を text としてそのまま round-trip
- UTF-16 LE BOM ファイル ("Hello\n") が NULL を含んでも slow path で正しく decode

bench(local, M-series Mac, hyperfine --warmup 1 --runs 5):
- `node bin/repomix.cjs --quiet` 全体: 1152ms → **406ms** (約 2.8× 速)
- `--include 'website/client/src/ko/guide/tips/best-practices.md'` 単独: 880ms → **170ms** (約 5.2× 速)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:56:10 +09:00
Kazuki Yamada 5a74a7be9d Merge pull request #1541 from yamadashy/fix/turnstile-prewarm-no-render
fix(website): Defer Turnstile render() until pack click
2026-05-05 15:49:59 +09:00
Kazuki Yamada 0f3a6e69a1 fix(website): Address review feedback on PR #1541
intent(comment-drift): Claude's review surfaced three stale comments that no longer match the code after pre-warm was narrowed to the script-load step. No behaviour change — the comments were lying about what the code actually does now.

fix(useTurnstileScript-jsdoc): The JSDoc on `TurnstileRenderOptions.execution` claimed `'execute'` was chosen so the pre-warm path could render() without firing a challenge. PR #1541 proved that's not what `'execute'` does in practice — render() itself counts toward the dashboard's challenge counters, regardless of `execution`. Rewrote the comment to explain why we still pass `'execute'` (token-mint guardrail) and why we no longer pre-warm by rendering.

fix(useTurnstile-render-comment): The inline comment at the render() call site said this option is "what makes the pre-warm in setContainer() free of side-effects". setContainer() no longer calls render(), so the rationale is obsolete. Updated to describe the current role: a guardrail against an accidental render() minting a token before getToken() is ready.

fix(useTurnstile-race-comment): The single-flight cache comment said "pre-warm and getToken() can both race past the widgetId.value null check". Pre-warm doesn't enter ensureWidget anymore, so only back-to-back getToken() submits can race. Updated to reflect the narrower scope.

perf(preconnect-crossorigin): Add a `crossorigin` companion to the existing `<link rel="preconnect">`. Turnstile's `api.js` is fetched anonymously, but the Turnstile iframe issues CORS sub-requests on a separate browser connection pool. Without both hints, the iframe's first handshake still happens on click. Cheap defensive addition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:37:59 +09:00
Kazuki Yamada c5fd291399 fix(website): Defer Turnstile render() until pack click
intent(token-waste): Production telemetry (12-hour Cloudflare dashboard sample) showed 2,620 challenges issued and 986 solved against only ~150-200 actual pack clicks tracked in GA. Despite the widget being configured with `execution: 'execute'`, calling `turnstile.render()` at form-mount time was inflating the dashboard's challenge counters by every visitor — humans, crawlers, ad-blocked browsers, abandoned tabs. Cloudflare's docs say render() should be side-effect free in execute mode, but the analytics disagree.

fix(prewarm-scope): Drop the widget render from `setContainer()`. Pre-warm now only calls `loadTurnstileScript()` so the script is cached before the user clicks; the actual `turnstile.render()` happens on the first `getToken()` call. This restores the documented 1:1 relationship between solved challenges and actual pack submissions.

perf(preconnect): Add `preconnect` and `dns-prefetch` hints to challenges.cloudflare.com so the DNS / TLS / HTTP/2 handshake is warm before the click. Compensates for losing the render() pre-warm — the script load and the challenge round-trip both reuse the warmed connection.

learned(don't-trust-docs-when-telemetry-disagrees): When the dashboard says one thing and the docs say another, the dashboard wins. The previous PR added pre-warm believing render() was inert in execute mode; the analytics showed it wasn't. Going forward, treat any new render() call site as a billable side effect until proved otherwise on the dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:38:41 +09:00
Kazuki Yamada 0fd60a428f Merge pull request #1539 from yamadashy/perf/turnstile-prewarm
perf(website): Pre-warm Turnstile widget and stop wasting tokens
2026-05-04 13:32:47 +09:00
Kazuki Yamada 7fdda0e897 fix(website): Single-flight ensureWidget to prevent double render race
intent(race-fix): gemini レビューで指摘 — pre-warm 導入により ensureWidget が `setContainer` (pre-warm) と `getToken` (submit) の両方から並行に呼ばれる経路ができた。両者が `await loadTurnstileScript()` で待機 → 解決後に両方が `if (!widgetId.value)` を通過し `turnstile.render()` を 2 回実行 → 最初の widgetId が上書きされ leak (onBeforeUnmount の remove() は 2 個目しか掃除できない)。

fix(single-flight): module-level ではなく composable-instance スコープの `ensureWidgetPromise` で in-flight render を 1 件に絞る。後続呼び出しは同じ promise を返すので render() は最大 1 回。失敗時は promise を null にして retry 経路を維持 (useTurnstileScript の resetForRetry 由来)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:23:12 +09:00
Kazuki Yamada cbf41ba9a7 fix(website): Clear Turnstile containerEl on unmount
intent(race-safety): codex re-review で指摘 — pre-warm 中に component が unmount された場合、ensureWidget の script load Promise が後から戻ってきて detached DOM element に widget を render してしまう余地がある。既存の `containerEl.value !== el` ガードはあるが、それを成立させるには unmount 時に containerEl を解放する必要がある。
fix(unmount-cleanup): onBeforeUnmount 冒頭で `containerEl.value = null` を明示。これで in-flight pre-warm が後から戻っても `null !== el` で render を skip し、widget が detached node にバインドされて remove() がない状態で leak することを防ぐ。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:14:11 +09:00
Kazuki Yamada 0e0f572964 fix(website): Defer Turnstile challenge with execution='execute'
intent(token-waste): デプロイ後の Turnstile ダッシュボードで「解決チャレンジ 90 / siteverify 22」というギャップ (約 68 個の token が無駄に発行されて捨てられている) と「未解決チャレンジ 127 件 / ボット可能性 58.53%」が観測された。

fix(root-cause): Cloudflare Turnstile の invisible widget はデフォルトで `execution: 'render'` モードであり、`render()` 直後に automatic challenge を実行して token を発行する。我々の pre-warm ロジック (commit `68090aa2`) は render() で widget を立ち上げると同時に challenge も走らせていた。pack ボタンを押すと execute() で 2 度目の challenge が走り、最初の token は捨てられる。さらに正規クローラ (Googlebot 等) がホームページを訪れた際、JS 実行で widget が render → automatic challenge → クローラは解けず「未解決」としてカウント、これがボット率を inflate していた。

fix(execution-execute): render オプションに `execution: 'execute'` を追加。これで pre-warm は script load + widget shell のみ実行し、challenge は明示的な turnstile.execute() (= ユーザが pack ボタン押下時) まで遅延する。pre-warm の latency 削減効果はそのまま、token 浪費とクローラ false-positive は消える。

constraint(type-update): TurnstileRenderOptions interface に `execution?: 'render' | 'execute'` を追加して TypeScript で表現可能に。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:09:42 +09:00
Kazuki Yamada 68090aa2d2 perf(website): Pre-warm Turnstile widget on container mount
intent(latency): Turnstile 導入後の "Processing repository..." 直後の体感ラグ (~1s) を削減。デプロイ後の動作確認で、pack ボタン押下から実 API 呼び出しまでに体感 1 秒のスピナ点滅が観測された。原因は click 時にゼロから「script load → widget init → execute → token」を直列で走らせていたこと。
fix(prewarm): setContainer で element が登録された時点で ensureWidget を fire-and-forget で発火。script load + widget init をページの idle 時間に前倒し、click 時は execute() のみで済むようにする。invisible widget は token を勝手に発行しないので prewarm しても token を浪費しない。
constraint(error-swallow): pre-warm 失敗は意図的に握り潰す。ページレンダリングをブロックさせない方が良く、同じ loadTurnstileScript / ensureWidget の経路は getToken() 呼び出し時に再実行される(リトライ + フルエラー伝搬)ので、ユーザーには submit 時に正しく見える。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:07:03 +09:00
Kazuki Yamada b97176aef0 Merge pull request #1538 from yamadashy/feat/turnstile-pack
feat(website): Add Cloudflare Turnstile to /api/pack
2026-05-04 11:38:19 +09:00
autofix-ci[bot] ac91b99397 [autofix.ci] apply automated fixes 2026-05-03 16:17:10 +00:00