{"record":{"id":"0dbcf1ba8e41ccf3","repo":"garrytan/gstack","slug":"shard-must-be-between-1-and-shards-length-re","errorCode":null,"errorMessage":"--shard must be between 1 and ${shards.length}. Received: ${options.shardIndex}","messagePattern":"--shard must be between 1 and (.+?)\\. Received: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"scripts/test-free-shards.ts","lineNumber":324,"sourceCode":"    }\n  }\n\n  if (options.listOnly) {\n    console.log(`\\nDiscovered ${files.length} test files.`);\n    for (const file of files) console.log(`  ${file}`);\n    return 0;\n  }\n\n  const shards = assignFilesToShards(files, options.shardCount);\n  if (options.dryRun) {\n    console.log(`\\nWould run ${files.length} files across ${shards.length} shards.`);\n    for (const line of formatShardSummary(shards)) console.log(line);\n    return 0;\n  }\n\n  if (options.shardIndex !== null) {\n    if (!Number.isInteger(options.shardIndex) || options.shardIndex < 1 || options.shardIndex > shards.length) {\n      throw new Error(`--shard must be between 1 and ${shards.length}. Received: ${options.shardIndex}`);\n    }\n    return runShard(shards[options.shardIndex - 1], options.shardIndex, shards.length);\n  }\n\n  for (let index = 0; index < shards.length; index += 1) {\n    const exitCode = runShard(shards[index], index + 1, shards.length);\n    if (exitCode !== 0) return exitCode;\n  }\n\n  return 0;\n}\n\nif (import.meta.main) {\n  process.exitCode = main();\n}\n","sourceCodeStart":306,"sourceCodeEnd":340,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/scripts/test-free-shards.ts#L306-L340","documentation":"Thrown by main() in scripts/test-free-shards.ts:324 when --shard N is passed and N fails one of three checks: not an integer (Number.isInteger), less than 1, or greater than shards.length. Critically, shards.length is the count of NON-EMPTY shards returned by assignFilesToShards() — line 219 filters out empty shards, so shards.length can be smaller than the requested shardCount when there are fewer test files than shards. A user who passes --shards 20 --shard 15 with only 10 files will see 'between 1 and 10', not 'between 1 and 20'.","triggerScenarios":"Passing --shard 0 or --shard -1 (1-indexed lower bound). Passing --shard with a non-integer like --shard 1.5 or --shard foo (Number.parseInt produces NaN; Number.isInteger(NaN) is false). Passing --shard N where N exceeds shards.length after empty-shard filtering — most commonly when --windows-only excluded enough tests, or --shards was set higher than the file count. CI matrix generates shard indices from the raw shardCount rather than the realized shard count.","commonSituations":"A GitHub Actions matrix uses `shard: [1,2,...,20]` against `--shards 20` but the free suite only has 12 files; shards 13-20 never get created and the worker for shard 13 throws. A developer hardcodes `--shard 5` from memory after a refactor that shrank the suite. NaN slips in from an unquoted CI variable (${SHARD_INDEX} that expanded empty, then parseInt → NaN). Someone assumes 0-indexed and passes --shard 0.","solutions":["Run with --dry-run first: it prints the realized shard count and per-shard file lists (lines 316-319) without running tests, so you can see the valid 1..shards.length range.","Drive the CI matrix from the realized shard count, not the requested --shards value: either run --dry-run to capture the count, or cap --shard at min(requestedShardCount, fileCount).","If the suite shrank, lower --shards to match the file count (e.g. --shards 10), or remove the --shard pin and let the loop at lines 329-332 run all shards in-process.","Validate the --shard argument is a base-10 integer before invoking — parseInt with radix 10 and a NaN check (the script already does Number.isInteger at line 323 but only after parsing).","For 0-indexed habit: remember this script is 1-indexed (line 326 uses options.shardIndex - 1 to access the array); pass --shard 1 for the first shard."],"exampleFix":"// before: matrix hardcoded against --shards, breaks when suite shrinks\n# strategy: matrix shard in [1..20], cmd: --shards 20 --shard ${{ matrix.shard }}\n\n// after: derive matrix bound from realized shard count via --dry-run\n- run: bun run scripts/test-free-shards.ts --dry-run --shards 20 > plan.txt\n- id: count\n  run: echo \"n=$(grep -c '^Shard ' plan.txt)\" >> $GITHUB_OUTPUT\n- strategy:\n    matrix:\n      shard: [1, 2, 3, \"${{steps.count.outputs.n}}\"]  # bounded by reality\n- run: bun run scripts/test-free-shards.ts --shards 20 --shard ${{ matrix.shard }}","handlingStrategy":"validation","validationCode":"// Before calling main(), bound the requested shard to the realized shard count.\n// Realized count = number of non-empty shards, which is <= options.shardCount\n// when files are scarce. Use --dry-run output or compute it directly.\nimport { collectFreeTestFiles, assignFilesToShards, DEFAULT_SHARD_COUNT } from './scripts/test-free-shards.ts';\nconst files = collectFreeTestFiles();\nconst shards = assignFilesToShards(files, shardCount ?? DEFAULT_SHARD_COUNT);\nconst maxShard = shards.length; // realized, <= requested\nif (shardIndex !== null && (!Number.isInteger(shardIndex) || shardIndex < 1 || shardIndex > maxShard)) {\n  console.error(`--shard out of range [1, ${maxShard}] (realized). Received: ${shardIndex}`);\n  process.exit(2);\n}","typeGuard":"// Narrow a parsed shard index to a valid 1-based integer in range.\nfunction isValidShardIndex(v: unknown, max: number): v is number {\n  return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= max;\n}","tryCatchPattern":"// Distinguish out-of-range from test failure so CI doesn't retry pointlessly.\nif (import.meta.main) {\n  try { process.exitCode = main(); }\n  catch (err) {\n    const msg = (err as Error).message;\n    if (msg.startsWith('--shard must be between')) {\n      console.error('[test-free-shards] shard index out of realized range; run with --dry-run to see valid range');\n      process.exitCode = 2;\n    } else { console.error(msg); process.exitCode = 1; }\n  }\n}","preventionTips":["Always run --dry-run once when changing --shards or the test set; it prints the realized shard count and per-shard file counts (lines 316-319) so the valid 1..N range is visible.","Remember shards.length is post-filter (empty shards removed at line 219), so the valid upper bound can be smaller than the --shards value you passed.","Generate CI matrix bounds from the realized count, not the requested count — query --dry-run or compute assignFilesToShards().length.","Treat the script as 1-indexed for --shard; line 326 subtracts 1 internally, so --shard 0 always throws.","Sanitize untrusted/CI-injected shard values with Number.isInteger before passing — parseInt('') or parseInt('foo') yields NaN and trips the same guard."],"tags":["sharding","ci","argument-validation","matrix"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}