Story S4-03 — ScipIndexProbe via scip-typescript + grammars-lock infrastructure¶
Status: Done (originally) — grammars-lock infrastructure superseded 2026-05-17 by 02-ADR-0011. The ScipIndexProbe half ships unchanged; the grammar-infrastructure half (tools/grammars.lock, tools/regenerate_grammars_lock.sh, vendored .so files, .gitattributes entries, tests/unit/tools/test_grammars_lock.py) was deleted. src/codegenie/grammars/lock.py was rewritten in-place — its public surface is now language_for(name) -> tree_sitter.Language + GrammarLoadRefused instead of load_and_verify(repo_root) -> GrammarLockFile. AC-10/AC-11/AC-12/AC-18 (the grammars-lock ACs) no longer apply; equivalent acceptance lives in tests/unit/grammars/test_lock.py (the new kernel) and pip --require-hashes at the wheel boundary.
Completed: 2026-05-16
Attempts: 1
Evidence:
- Files created:
src/codegenie/exec/__init__.py (promoted from module),
src/codegenie/exec/tool_versions.py,
src/codegenie/grammars/{__init__,lock}.py,
src/codegenie/probes/layer_b/{scip_index,scip_slice}.py,
tests/unit/{exec/test_tool_versions,grammars/test_lock,tools/test_grammars_lock,probes/layer_b/test_scip_index}.py,
tools/grammars.lock, tools/regenerate_grammars_lock.sh,
tools/grammars/{typescript,javascript}.so (placeholders — see tools/grammars/README.md),
tools/grammars/README.md, .gitattributes.
- Files edited (additive):
src/codegenie/probes/__init__.py (import scip_index),
tests/unit/exec/test_run_external_cli.py (S4-03 promoted exec.py to a package — test loosened to "anywhere inside the exec package is exempt").
- Tests (40 added, all green):
tests/unit/probes/layer_b/test_scip_index.py (20),
tests/unit/exec/test_tool_versions.py (6),
tests/unit/grammars/test_lock.py (9),
tests/unit/tools/test_grammars_lock.py (5).
- Full suite: 2097 passed, 5 skipped, 1 xfail, 2 pre-existing env-only failures
(test_lint_imports_canary — lint-imports console script not in local .venv;
passes in CI per S1-05 wiring).
- Tooling: ruff check, ruff format --check, mypy --strict src/, pre-commit run,
shellcheck tools/regenerate_grammars_lock.sh all green.
Step: Step 4 — Ship IndexHealthProbe (B2) + Layer B structural probes
Story status (pre-execution): Ready · VALIDATED (HARDENED — see _validation/S4-03-scip-index-probe.md)
Effort: M
Depends on: S1-07 (run_external_cli on disk, ALLOWED_BINARIES extended with scip-typescript + tree-sitter + nine others), S1-08 (@register_probe(heaviness="heavy") registry annotation; coordinator dispatches heavy probes first), S4-01 (B2's read_raw_slices reads <output_dir>/raw/scip.json — this story produces that file)
Validation notes (2026-05-16)¶
Seven BLOCK-severity inconsistencies with master closed; ten HARDEN findings closed; four design-pattern opportunities elevated to load-bearing ACs. Full audit: _validation/S4-03-scip-index-probe.md. Highlights:
Probe.runis two-argument(self, repo: RepoSnapshot, ctx: ProbeContext)persrc/codegenie/probes/base.py:94. Impl outline corrected throughout.ProcessResultshape is(returncode: int, stdout: bytes, stderr: bytes)— frozen dataclass atsrc/codegenie/exec.py:140-150. Noexit_code. Nostderr_tail. Caller decodes + tails:result.stderr[-4096:].decode("utf-8", errors="replace").run_external_cliexception taxonomy:ProbeTimeoutError(timeout),ToolMissingError(binary missing),DisallowedSubprocessError,FileNotFoundError/NotADirectoryError(cwd missing). Does NOT raiseasyncio.TimeoutErrororCalledProcessError. Non-zero exits returnProcessResultwithreturncode != 0— caller inspects.<output_dir>/raw/scip.jsonis the LOAD-BEARING hand-off to B2 (S4-01 hardened, cross-story commitment line 13). Without it, B2 firesStale(IndexerError("upstream_scip_unavailable"))on every gather. New AC-16 mandates writing it; new AC-17 mandates inclusion inProbeOutput.raw_artifactsso warm-cache replay re-publishes.- Tool-version cache-key sensitivity via
probe.version@property, NOT ascip-typescript-version:<resolved>declared-input token. Master'scache/keys.py::declared_inputs_fordoesrglobonly — no token dispatch. ADR-0004 prescribed it forimage-digest:but S1-09 (Done) only added theProbeContext.image_digest_resolverfield.probe.versionIS in the cache key (cache/keys.py:146); rolling the resolved tool version intoversion(e.g.,f"0.1.0+scip-typescript-{resolved}") closes the gap with zero new mechanism. files_in_reporestricted to.ts/.tsx—scip-typescript's program scope..js/.jsxare S4-04'sTreeSitterImportGraphProbeconcern. The original.ts/.tsx/.js/.jsxwalk would fire B2'sStale(CoverageGap)on every healthy mixed JS+TS repo.- Tool-version resolution extracted to
codegenie.exec.tool_versions(process-wide memo, lazy,clear_for_tests()seam). Rule-of-three crossed:scip-typescript,tree-sitter,grype/syft/semgrep/gitleaksall need it. Subprocess at probe-import time is the anti-pattern this closes. SemanticIndexSlicePydantic smart constructor is the single source of truth for the slice shape; both the envelope andscip.jsonderive frommodel_dump(mode="json", exclude_none=True). Mirrors S3-02'sRedactedSliceprecedent.codegenie.grammars.locktyped loader (load_and_verify(repo_root) -> GrammarLockFile;GrammarLoadRefusedexception) is the shared chokepoint for this story's tests AND S4-04's pre-load BLAKE3 check.- Import-time
assertremoved — Phase 0 forbidden-patterns hook bans bareassertinsrc/codegenie/. Warning-ID conformance verified by unit test (S4-01 precedent).
ADRs honored: 02-ADR-0001 (scip-typescript admissible only via run_external_cli → run_allowlisted), 02-ADR-0002 (the grammars.lock infrastructure ships here, consumed in S4-04), 02-ADR-0003 (heaviness="heavy" is a registry annotation), 02-ADR-0006 (IndexerError(message="timeout") is the typed failure for scip-typescript timeout; B2 consumes), Phase 1 ADR-0004 (sub-schema lands in S4-07), Phase 1 ADR-0007 (warning ID pattern)
Context¶
ScipIndexProbe produces the SCIP semantic index B2 reads to determine "is the index up-to-date with HEAD?" The probe invokes scip-typescript (the indexer; no compile required — reads tsconfig.json and runs the TypeScript compiler API), emits a .scip binary to .codegenie/context/raw/scip-index.scip, and reports a semantic_index slice with the index metadata B2's scip freshness check (S4-01 AC-5) reads.
Phase 2 emits the binary only — the consumption shape is Phase 3's. Final-design §"Patterns rejected" #9 / Phase-3 deferral: Phase 3's ScipAdapter (one of the four Protocols shipped in S1-08) decides whether to mmap, re-parse, or pre-project the .scip blob. Phase 2 must not commit to a binary on-disk format (the performance lens's rejected msgpack proposal; ADR-0002 §Decision). The probe's contract is "produce the blob; report metadata." Nothing more.
Cache key sensitivity. declared_inputs must capture (a) the tool version (running scip-typescript --version-equivalent at probe init, with the resolved version baked into the cache key), and (b) a Merkle hash over the set of .ts/.tsx/.js/.jsx files under the repo root (excluding node_modules, dist, build, and any path declared in .codegenie/exclude.txt). The Phase 0 cache layer (src/codegenie/cache/) computes content-addressed keys from declared_inputs; this probe's job is to declare the right inputs. A wrong Merkle (e.g., over ALL files rather than indexable ones) would over-invalidate; a wrong version anchor (e.g., omitting the tool version) would silently reuse a cache hit across scip-typescript upgrades.
Timeout discipline. SCIP indexing on a huge monorepo can blow past simple timeouts. The phase budget is 300 seconds (phase-arch-design.md §"Edge cases" row 4). On timeout, the probe surfaces a typed IndexerError(message="timeout") in the semantic_index slice's indexer_errors shape AND emits no blob; B2's scip freshness check (S4-01 AC-5e) sees indexer_errors > 0 and emits Stale(IndexerError(...)). Phase 3's adapter falls back to tree-sitter per the ADR-0032 declared-fallback discipline.
grammars.lock ships here, consumed in S4-04. The Phase-2 tree-sitter integration (ADR-0002) requires vendored grammar .so/.dylib artifacts pinned by BLAKE3 in tools/grammars.lock. The lock file + regeneration script are infrastructure both ScipIndexProbe (which uses tree-sitter for JavaScript files outside the TypeScript program; localv2.md §5.2 B1 line 563) and TreeSitterImportGraphProbe (S4-04) consume. Landing both lock + regenerate script in this story (rather than splitting) is a deliberate scope choice: S4-04's grammar-pin verification depends on the lock existing, so the lock must precede the consumer. The lock + regen script is "reviewed-as-code, vendored as data" (Rule 8 — read before you write; the grammar binaries are reviewed at PR time, not parsed by the implementer).
References — where to look¶
- Architecture:
../phase-arch-design.md §"Component design" #5(Layer G scanner pattern —run_external_cli, ToolCache, Pydantic-parse-stdout — same shape applies here even though SCIP is Layer B).../phase-arch-design.md §"Edge cases" row 4—scip-typescripttimeout →IndexFreshness.Stale(reason=IndexerError(message="timeout")); Phase 3 adapter falls back.../phase-arch-design.md §"Component design" #3—run_external_clisignature (probe routes through this, notrun_allowlisted).../phase-arch-design.md §"Process view"— heavy probes dispatched first under the single semaphore.- Phase 2 ADRs:
../ADRs/0001-add-docker-and-security-cli-tools-to-allowed-binaries.md—scip-typescriptis inALLOWED_BINARIES.../ADRs/0002-tree-sitter-grammars-phase-2-amendment.md—grammars.lockshape and policy.../ADRs/0003-coordinator-heaviness-sort-annotation.md—heaviness="heavy"semantics.- Source design:
docs/localv2.md §5.2 B1— SCIP slice shape includingcoverage_pct,any_type_density,unresolved_dynamic_imports,symbol_count,exported_symbols.- Existing code:
src/codegenie/probes/base.py(frozen — subclass).src/codegenie/exec.py(extended in S1-07) —run_external_cli(...)is the single subprocess port.src/codegenie/cache/(Phase 0) — declared-inputs → cache key derivation.- External:
scip-typescriptREADME — its CLI flags (--cwd,--output,--infer-tsconfig); the JSON-stdout schema it emits with--summary-json(used here for slice metadata; the binary itself is opaque to Phase 2).
Goal¶
Running codegenie gather against a TypeScript fixture produces .codegenie/context/raw/scip-index.scip (binary blob, opaque to Phase 2) AND a semantic_index slice in repo-context.yaml with last_indexed_commit, last_indexed_at, files_indexed, files_in_repo, coverage_pct, indexer_errors, indexer_version. Tool-version + .ts-Merkle are part of the cache key; a scip-typescript upgrade or any .ts file change invalidates the cache. Timeout at 300 s emits indexer_errors=1 with structured detail visible to B2's scip freshness check. tools/grammars.lock exists with BLAKE3-pinned TypeScript + JavaScript grammar binaries, and tools/regenerate_grammars_lock.sh is reviewed-as-code.
Acceptance criteria¶
-
[ ] AC-1 — Probe contract attributes + two-arg
run().src/codegenie/probes/layer_b/scip_index.pydefinesclass ScipIndexProbe(Probe)withlist[str]class attributes:name="scip_index",layer="B",tier="base",applies_to_languages=["javascript","typescript"],applies_to_tasks=["*"],requires=["language_detection","node_build_system"],timeout_seconds=300,cache_strategy: Literal["content"] = "content".versionis a@propertyreturningstr(NOT a class attribute) — see AC-2 for the format. The async-run signature isasync def run(self, repo: RepoSnapshot, ctx: ProbeContext) -> ProbeOutput(two-arg persrc/codegenie/probes/base.py:94). The decorator is@register_probe(heaviness="heavy"). -
[ ] AC-2 — Tool-version sensitivity via
probe.versionproperty (NOT a declared-input token).ScipIndexProbe.versionis a@propertyreturningf"0.1.0+scip-typescript-{resolved}"where<resolved>iscodegenie.exec.tool_versions.resolve_tool_version("scip-typescript")(lazy, process-wide memoized — see AC-19). Format pinned by regexr"^0\.1\.\d+\+scip-typescript-.+$"in T-06 so the resolved suffix can vary across CI environments. Rationale:probe.versionis inkey_for's tuple (src/codegenie/cache/keys.py:146); ascip-typescriptupgrade automatically invalidates the cache with zero new mechanism. The performance-lens-proposedscip-typescript-version:<resolved>declared-input token is REJECTED — master'scache/keys.py::declared_inputs_fordoesrglobonly (no token dispatch); ADR-0004's token mechanism is unimplemented in the cache layer; and_OUTPUT_NAMESPACE = ".codegenie"would filter any version-stamp file written under that namespace.declared_inputsis therefore the filesystem-only list["**/*.ts", "**/*.tsx", "tsconfig.json", "tsconfig.*.json", "package.json"](the.ts-Merkle channel of cache-key sensitivity —content_hash_of_inputsaggregates these percache/keys.py:146). Cache-key sensitivity test (T-06) verifies BOTH (a) altering any.tsfile changes the key (Merkle path) and (b) altering the resolved tool version changesprobe.versionand therefore the key (version path). -
[ ] AC-3 — Invocation via
run_external_clionly. The probe invokesscip-typescriptonly via_exec.run_external_cli("scip_index", ["scip-typescript", "index", "--cwd", str(repo.root), "--output", str(blob_path), "--infer-tsconfig"], cwd=repo.root, timeout_s=300, max_stdout_bytes=64*1024*1024). No directsubprocess.run/Popen/os.systemappears anywhere in the module. Argv composition is a pure helper (_build_scip_argv(repo_root: Path, blob_path: Path) -> list[str]) testable in isolation. The probe imports asfrom codegenie import exec as _execso unit tests can monkeypatchcodegenie.exec.run_external_cli. The stub contract for T-04 is spelled out (it writes the blob then returns a realProcessResult):
async def _stub_writes_blob(probe_id, argv, *, cwd, timeout_s, max_stdout_bytes=64*1024*1024, env_extra=None):
out_path = Path(argv[argv.index("--output") + 1])
out_path.write_bytes(b"FAKE-SCIP-BLOB")
return ProcessResult(returncode=0, stdout=b'{"files_indexed":1}', stderr=b"")
-
[ ] AC-4 — Blob path via
ctx.output_dir; slice URI is repo-relative. The blob lands atctx.output_dir / "raw" / "scip-index.scip"(Phase 1 convention —ProbeContext.output_diris the "where probe writes raw artifacts" surface persrc/codegenie/probes/base.py:53;ctx.output_diris normally<repo>/.codegenie/context). Thesemantic_indexslice'sscip_index_uriis the path relative torepo.root(e.g.".codegenie/context/raw/scip-index.scip") computed via(ctx.output_dir / "raw" / "scip-index.scip").relative_to(repo.root). The directory is created via(ctx.output_dir / "raw").mkdir(parents=True, exist_ok=True)before invocation. If the directory cannot be created (read-only filesystem, OSError), the probe emitsconfidence="low",errors=["scip_index.raw_artifact_dir_unwritable"], and skips invocation. -
[ ] AC-5 — Slice fields per
localv2.md §5.2 B1+ B2-compatible key set; built viaSemanticIndexSlicePydantic model (see AC-18). The slice emits these fields (all required unless marked optional): scip_index_uri: str(path relative torepo.root).indexer: Literal["scip-typescript"].indexer_version: str(the resolved version from AC-2 — just the suffix after+scip-typescript-, OR"unknown"on tool-missing).files_indexed: int ≥ 0(parsed fromscip-typescript --summary-jsonstdout; if--summary-jsonis unavailable on the installed version, derived by counting.ts/.tsxfiles via_count_indexable_files— see AC-9).files_in_repo: int ≥ 0(count of.ts/.tsxfiles under repo root excludingnode_modules,dist,build,.git, and any path in.codegenie/exclude.txtif present)..js/.jsxare EXCLUDED —scip-typescript's program scope is TypeScript-only (perlocalv2.md §5.2 B1 lines 565-567);.js/.jsxcoverage is S4-04'sTreeSitterImportGraphProbeconcern. Including them would make B2'sStale(CoverageGap)fire on every healthy mixed JS+TS repo (scip_freshnessAC-5(d) comparesfiles_indexed < files_in_repo).coverage_pct: float ∈ [0.0, 100.0](=files_indexed / files_in_repo * 100rounded to one decimal; iffiles_in_repo == 0, emit0.0). Onfiles_in_repo == 0, the probe envelopeconfidence="low"(the index is not informative); B2'sscip_freshnessreads0 == 0and emitsFresh(the index matches HEAD). The two layers disagree intentionally; the agreement is documented in Notes for implementer.last_indexed_commit: str(the SHA at the timescip-typescriptwas invoked; obtained viaawait _exec.run_allowlisted(["git", "rev-parse", "HEAD"], cwd=repo.root, timeout_s=5). Onresult.returncode != 0(NOTCalledProcessError—run_allowlistedreturns; it does not raise persrc/codegenie/exec.py:216-355),last_indexed_commit="unknown"andwarnings.append("scip_index.head_unresolvable").last_indexed_at: str(ISO-8601 UTC timestamp at invocation start; e.g.,"2026-05-16T12:34:56.789+00:00"fromdatetime.now(timezone.utc).isoformat()).indexer_errors: int ≥ 0(= 1 on timeout/non-zero-exit/tool-missing, else 0).indexer_warnings: int ≥ 0(parsed from--summary-jsonif available; else 0).-
Optional fields per
localv2.md §5.2 B1lines 581–586 (any_type_density,unresolved_dynamic_imports,unresolved_computed_access,symbol_count,exported_symbols): emit if--summary-jsonprovides them, else omit from the slice viamodel_dump(exclude_none=True)(the sub-schema in S4-07 marks them optional). -
[ ] AC-6 — Timeout path emits typed
IndexerError-flavored slice. Ifrun_external_cliraisescodegenie.errors.ProbeTimeoutError(NOTasyncio.TimeoutError—run_external_cliwraps the asyncio timeout persrc/codegenie/exec.py:330-338), the probe: - Removes the partial blob if
blob_path.exists()— partial SCIP would mislead Phase 3'sScipAdapter. - Emits the slice with
indexer_errors=1,files_indexed=0,coverage_pct=0.0,confidence="low",warnings=["scip_index.timeout"]. - Does NOT raise. The next probe in the queue continues.
-
Writes
scip.jsonper AC-16 with the same slice contents so B2 readsindexer_errors > 0and (per S4-01 AC-5(e)) emitsStale(IndexerError(message=f"indexer_reported_{n}_errors"))wheren=1. T-07 asserts this end-to-end by both (a) inspecting the slice fields directly AND (b) calling S4-01's publishedscip_freshness(slice, head)check on the JSON and asserting the typedStale(IndexerError(...))outcome. The message-format string"indexer_reported_{n}_errors"is consumer-coupled to S4-01; T-07 references S4-01's check function rather than re-encoding the f-string (so a S4-01 message-format refactor surfaces as a test break on both sides, not a silent drift). -
[ ] AC-7 — Non-zero exit path. If
run_external_clireturns aProcessResult(frozen; fieldsreturncode: int, stdout: bytes, stderr: bytes— noexit_code, nostderr_tail) withresult.returncode != 0, the probe: - Emits the slice with
indexer_errors=1,files_indexed=0,coverage_pct=0.0,confidence="low",warnings=["scip_index.exit_nonzero"]. - Computes
stderr_tail: str = result.stderr[-4096:].decode("utf-8", errors="replace")and includes it in the probe's structured-log event (_log.warning("scip_index.exit_nonzero", returncode=..., stderr_tail=stderr_tail, ...)) but NOT in the slice (stderr can contain repo paths; the slice is the auditable contract). - Removes the partial
.scipblob if it exists (same reasoning as AC-6). -
Writes
scip.jsonper AC-16 so B2 readsindexer_errors > 0and emitsStale(IndexerError(...)). -
[ ] AC-8 — Tool-missing path. If
run_external_cliraisescodegenie.errors.ToolMissingError(NOTFileNotFoundError— the latter is raised only whencwddoes not exist, persrc/codegenie/exec.py:320), the probe emits the slice withindexer="scip-typescript",indexer_version="unknown",files_indexed=0,files_in_repo=<actual>,coverage_pct=0.0,indexer_errors=1,confidence="low",warnings=["scip_index.tool_missing"]. No blob is written.scip.jsonis still written (AC-16) so B2 emitsStale(IndexerError("indexer_reported_1_errors")). The tool-version resolver (AC-19) must also return"unknown"onToolMissingErrorrather than raising — the probe'sversionproperty must be safe to read even when the binary is missing. -
[ ] AC-9 —
files_in_repoand Merkle share one walker (consistency invariant). A pure helper_count_indexable_files(root: Path) -> intwalksroot, counts files matching*.ts|*.tsx(NOT*.js|*.jsx— see AC-5), excluding paths undernode_modules,dist,build,.git, and (if present) any path declared in.codegenie/exclude.txt. A second pure helper_compute_indexable_merkle(root: Path) -> strwalks the SAME exclusion set and returns a BLAKE3 over the sorted (path, content-hash) pairs. The two helpers share a private_walk_indexable_files(root: Path) -> Iterator[Path]so divergence is mechanically impossible. T-09 verifies symmetric exclusion: plantingnode_modules/extra.tsleaves BOTH the count AND the Merkle unchanged versus an emptynode_modules/. -
[ ] AC-10 —
tools/grammars.locklands with TypeScript + JavaScript pinned.tools/grammars.lockis a small machine-readable file (YAML or TOML — implementer choice; YAML matches Phase 1 ADR-0004's "human-facing YAML, machine-readable JSON" convention) with at minimum:Eachschema_version: 1 grammars: - language: typescript version: "0.20.6" # tree-sitter-typescript release tag file: tools/grammars/typescript.so # vendored binary path (relative to repo root) blake3: <64-hex-chars> # BLAKE3 of the vendored binary - language: javascript version: "0.20.4" file: tools/grammars/javascript.so blake3: <64-hex-chars>fileentry exists on disk (vendored), and the BLAKE3 of the vendored binary matches theblake3field byte-for-byte. A unit test (tests/unit/tools/test_grammars_lock.py) validates the schema AND verifies every BLAKE3 matches the vendored file (blake3.blake3(open(file, "rb").read()).hexdigest()). -
[ ] AC-11 —
tools/regenerate_grammars_lock.shis reviewed-as-code. Executable shell script undertools/regenerate_grammars_lock.sh. Walkstools/grammars/, recomputes BLAKE3 for each vendored binary, rewritestools/grammars.lockwith the recomputed values. Idempotent — second run produces an identical file. Refuses to run if any vendored binary is missing (loud failure, exit code 1). The script does NOT download grammars — vendoring is a manual PR-reviewable step (per ADR-0002 §Consequences — "Grammar regeneration is a PR with a binary diff — heavier review"). A unit test (test_regenerate_grammars_lock_idempotent) runs the script twice in a tempdir copy and asserts byte-identical output. -
[ ] AC-12 —
tools/grammars/directory + at-least-two binaries.tools/grammars/typescript.so(or.dylibfor macOS dev; the CI runner is Linux-canonical so.sois the committed artifact — see ADR-0002 §Tradeoffs "Wheel matrix stays small") andtools/grammars/javascript.soexist as vendored binaries..gitattributesdeclarestools/grammars/*.so binaryandtools/grammars/*.dylib binaryso git does not corrupt them. The actual binary content is implementer-time work — sourced from the tree-sitter-typescript and tree-sitter-javascript releases at the version pinned ingrammars.lock. The PR description must include the upstream release URL and the BLAKE3 it computed locally. -
[ ] AC-13 — Warning + error ID frozenset (unit-test verified, NOT import-time
assert). All warning IDs (scip_index.timeout,scip_index.exit_nonzero,scip_index.tool_missing,scip_index.head_unresolvable,scip_index.raw_artifact_dir_unwritable,scip_index.summary_json_unavailable) are declared in a module-level_WARNING_IDS: frozenset[str]. Phase 0 forbidden-patterns hook bans bareassertinsrc/codegenie/; a unit test (test_warning_ids_match_adr_0007) iterates_WARNING_IDSand asserts every member matches the Phase 1 ADR-0007 regex (precedent: S4-01 uses the same pattern). A second test (test_run_only_emits_declared_warning_ids) AST-walksrun()and asserts everywarnings.append(...)literal is a member of_WARNING_IDS— closes the silent-drift gap where a new warning string is added without being declared. -
[ ] AC-14 — Registry membership +
for_taskfilter.src/codegenie/probes/__init__.pyimportsScipIndexProbevia an explicit additive line.default_registry.all_probes()includes it withheaviness="heavy".for_task("*", frozenset({"typescript"}))andfor_task("*", frozenset({"javascript"}))include it;for_task("*", frozenset({"go"}))does NOT. -
[ ] AC-15 — Tooling green.
ruff check,ruff format --check,mypy --strict src/codegenie/probes/layer_b/scip_index.py src/codegenie/exec/tool_versions.py src/codegenie/grammars/lock.py,pytest tests/unit/probes/layer_b/test_scip_index.py tests/unit/exec/test_tool_versions.py tests/unit/grammars/test_lock.py tests/unit/tools/test_grammars_lock.py,shellcheck tools/regenerate_grammars_lock.sh. All green. -
[ ] AC-16 —
scip.jsonsibling raw artifact for B2 (LOAD-BEARING cross-story hand-off). The probe writesctx.output_dir / "raw" / "scip.json"containingSemanticIndexSlice.model_dump_json(exclude_none=True, indent=2). The filenamescip.jsonis keyed byIndexName("scip")— S4-01'sread_raw_slices(raw_dir)discovers it by stem and passes the parsed dict toscip_freshness(slice, head). Without this file, B2 firesStale(IndexerError("upstream_scip_unavailable"))on every gather (per S4-01 AC-12 — sibling-missing path). The JSON contents MUST contain the keys B2'sscip_freshnessreads (last_indexed_commit,files_indexed,files_in_repo,indexer_errors,last_indexed_at— all top-level). A unit test (T-19) loads the written JSON, feeds it through S4-01's publishedscip_freshnesscheck function, and asserts the typedIndexFreshnessoutcome (Freshon green path;Stale(...)on each error path). This test is the structural guarantee of the cross-story contract. -
[ ] AC-17 —
scip.jsonis inProbeOutput.raw_artifacts(warm-cache hand-off).ProbeOutput.raw_artifacts: list[Path]includes BOTH<ctx.output_dir>/raw/scip-index.scipAND<ctx.output_dir>/raw/scip.json. The coordinator/writer replays theraw_artifactslist on cache HIT (Phase 0 cache replay semantics); withoutscip.jsonin the list, a warm gather would leave the file stale or absent and B2 would mis-fire. Verified by T-Sj-2: instantiate two probe runs back-to-back against the same inputs; the second is a cache HIT (declared inputs unchanged,probe.versionunchanged); assertscip.jsonis still present after the second run AND its bytes are byte-identical to the first run. -
[ ] AC-18 —
SemanticIndexSlicePydantic smart constructor (single source of truth for slice shape). Defineclass SemanticIndexSlice(BaseModel)atsrc/codegenie/probes/layer_b/scip_slice.py(sibling module, kept separate from probe so S4-07's sub-schema canmodel_json_schema()it cleanly):
class SemanticIndexSlice(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
scip_index_uri: str
indexer: Literal["scip-typescript"]
indexer_version: str
files_indexed: int = Field(ge=0)
files_in_repo: int = Field(ge=0)
coverage_pct: float = Field(ge=0.0, le=100.0)
last_indexed_commit: str
last_indexed_at: str
indexer_errors: int = Field(ge=0)
indexer_warnings: int = Field(ge=0)
any_type_density: float | None = None
unresolved_dynamic_imports: int | None = None
unresolved_computed_access: int | None = None
symbol_count: int | None = None
exported_symbols: int | None = None
Both the envelope-side schema_slice["semantic_index"] AND scip.json derive from slice.model_dump(mode="json", exclude_none=True). This closes the F-TQ-6 mutation-killer gap by construction: a renamed field would fail Pydantic validation, not produce a silent mis-key. Mirrors S3-02's RedactedSlice smart-constructor precedent.
- [ ] AC-19 —
codegenie.exec.tool_versionsextracted (kernel for repeated tool-version resolution). Createsrc/codegenie/exec/tool_versions.pyexporting: async def resolve_tool_version(binary: str, *, version_argv: list[str] | None = None, parser: Callable[[bytes], str] | None = None) -> str— invokesrun_external_cli(<binary>, version_argv or ["--version"], cwd=Path.cwd(), timeout_s=5), parses the version withparseror a default first-line-strip, returns the version string. OnToolMissingErrorreturns"unknown"(does NOT raise — callers route throughprobe.version).- Process-wide memoization keyed by
(binary, tuple(version_argv or ["--version"])). Two calls in one process trigger the subprocess exactly once. def clear_for_tests() -> None— mirrors S1-02'sunregister_for_testsprecedent; resets the memo for unit-test isolation.
ScipIndexProbe.version calls resolve_tool_version("scip-typescript") lazily via the @property. Extension-by-addition guarantee: adding a new external-CLI probe must require zero edits to scip_index.py for tool-version resolution. Verified by structural test: any future tree_sitter_import_graph.py (S4-04) imports resolve_tool_version from this module, not from scip_index. T-20 asserts two consecutive await resolve_tool_version("scip-typescript") calls trigger exactly one underlying run_external_cli invocation (subprocess count via spy).
- [ ] AC-20 —
codegenie.grammars.locktyped loader (kernel fortools/grammars.lock). Createsrc/codegenie/grammars/lock.pyexporting:class GrammarPin(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) language: str version: str file: str # repo-relative path blake3: str # 64 hex chars class GrammarLockFile(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: Literal[1] grammars: list[GrammarPin] def load_and_verify(repo_root: Path) -> GrammarLockFile: ... class GrammarLoadRefused(RuntimeError): ...load_and_verifyreads<repo_root>/tools/grammars.lock, validates via Pydantic, recomputes BLAKE3 over everyfileentry, raisesGrammarLoadRefusedon mismatch with a structured message naming the failing language + expected/actual BLAKE3. S4-04'sTreeSitterImportGraphProbeimportsload_and_verifyand consumes the typed result (pre-load supply-chain defense per phase-arch row 10). T-21 exercises happy path (all BLAKE3 match) AND mismatch path (tamper a vendored binary; assertGrammarLoadRefusedwith regex on the failing language).
Implementation outline¶
-
Create
src/codegenie/exec/tool_versions.py(AC-19). Process-wide memo,clear_for_tests(),resolve_tool_version("scip-typescript")returns the version string (or"unknown"onToolMissingError). Lazy: no subprocess fires until first call. -
Create
src/codegenie/grammars/lock.py(AC-20). PydanticGrammarPin+GrammarLockFile;load_and_verify(repo_root) -> GrammarLockFile;GrammarLoadRefusedexception. The reader is reusable by S4-04. -
Create
src/codegenie/probes/layer_b/scip_slice.py(AC-18).SemanticIndexSlicePydantic smart constructor. -
Create
src/codegenie/probes/layer_b/scip_index.pywith the probe class per AC-1 plus pure helpers_build_scip_argv,_count_indexable_files,_compute_indexable_merkle,_walk_indexable_files(private shared walker — AC-9),_parse_summary_json. The probe'sversionis a@propertyreturningf"0.1.0+scip-typescript-{await resolve_tool_version('scip-typescript')}"— but since@propertycannot beasync, the property body runsasyncio.run(...)on a tiny coroutine when first read, OR (cleaner) the memo is sync via a smallasyncio.run-wrapper insidetool_versions. Implementer choice; both shapes are covered by T-20's process-wide single-subprocess assertion. -
async def run(self, repo: RepoSnapshot, ctx: ProbeContext) -> ProbeOutput(two-arg per ABC). Order: last_indexed_at: str = datetime.now(timezone.utc).isoformat().- Resolve HEAD:
result = await _exec.run_allowlisted(["git", "rev-parse", "HEAD"], cwd=repo.root, timeout_s=5). Onresult.returncode != 0(NOTCalledProcessError),last_indexed_commit = "unknown"+warnings.append("scip_index.head_unresolvable"). Elselast_indexed_commit = result.stdout.decode("utf-8").strip(). files_in_repo = _count_indexable_files(repo.root)(.ts/.tsxonly per AC-9).raw_dir = ctx.output_dir / "raw";raw_dir.mkdir(parents=True, exist_ok=True). OnOSError→ AC-4 short-circuit (emit slice withconfidence="low",errors=["scip_index.raw_artifact_dir_unwritable"], skip invocation, still writescip.jsonper AC-16 so B2 doesn't see a missing sibling).blob_path = raw_dir / "scip-index.scip";argv = _build_scip_argv(repo.root, blob_path).try: result = await _exec.run_external_cli(...)withexceptarms forProbeTimeoutError(AC-6),ToolMissingError(AC-8). All other exceptions propagate (the coordinator isolates).- On
result.returncode != 0(AC-7): computestderr_tail = result.stderr[-4096:].decode("utf-8", errors="replace"); log structured; remove blob; emit error slice. - On success: parse
--summary-jsonstdout via_parse_summary_json(Pydantic; tolerant); populate optional slice fields. - Construct
SemanticIndexSlice(...)(Pydantic validates); computescip_index_uriasblob_path.relative_to(repo.root).as_posix(). - Write
<raw_dir>/scip.jsonwithslice.model_dump_json(exclude_none=True, indent=2)(AC-16). -
Compose
ProbeOutputwithschema_slice={"semantic_index": slice.model_dump(mode="json", exclude_none=True)},raw_artifacts=[blob_path, raw_dir / "scip.json"](AC-17),confidence,duration_ms,warnings,errors. Return. -
_build_scip_argv(repo_root: Path, blob_path: Path) -> list[str](pure). Returns["scip-typescript", "index", "--cwd", str(repo_root), "--output", str(blob_path), "--infer-tsconfig"]. Tested independently in T-02. -
_walk_indexable_files(root: Path) -> Iterator[Path](private pure). Yields.ts/.tsxfiles excluding the canonical exclude set. Shared by both_count_indexable_filesand_compute_indexable_merkleso divergence is mechanically impossible (AC-9). -
_count_indexable_files(root: Path) -> int(pure).sum(1 for _ in _walk_indexable_files(root)). T-09. -
_compute_indexable_merkle(root: Path) -> str(pure). BLAKE3 over the sorted(rel_path, content-hash)pairs from_walk_indexable_files. Not directly in cache key (the Merkle channel of cache sensitivity is already provided bycontent_hash_of_inputsoverdeclared_inputs_for); used as a documented invariant probe + future-proofing. -
_parse_summary_json(stdout: bytes) -> _ScipSummary(pure). Pydantic model_ScipSummary(BaseModel, frozen=True, extra="ignore")with optional fields; tolerates absent fields. On parse failure (e.g.,--summary-jsonunavailable on the installed version), returns an empty_ScipSummary()and the probe appendswarnings.append("scip_index.summary_json_unavailable")and proceeds with derivedfiles_indexed = files_in_repo. -
tools/grammars.lock+tools/regenerate_grammars_lock.sh+tools/grammars/{typescript,javascript}.so. Land all in this story; consumer is S4-04. The lock-file SCHEMA is owned bycodegenie.grammars.lock.GrammarLockFile(AC-20). -
Register the probe via
src/codegenie/probes/__init__.pyadditive import.
TDD plan — red / green / refactor¶
Test helpers preamble¶
# tests/unit/probes/layer_b/test_scip_index.py
from __future__ import annotations
import asyncio, json, types
from pathlib import Path
import pytest
from codegenie.probes.layer_b.scip_index import ScipIndexProbe, _build_scip_argv, _count_indexable_files
from codegenie.exec import ProcessResult
RED¶
- T-01
test_probe_contract_attributes: AC-1; instantiate probe; assert each class attribute matches expected value; assertrun.__qualname__ends in.runand signature accepts(self, repo, ctx)(two-arg per ABC). - T-02
test_build_scip_argv_shape: AC-3; expected argv list verbatim against a fixturerepo.root+blob_path. - T-03
test_invocation_via_run_external_cli_only: AC-3; AST-walk module; assert nosubprocess.run/Popen/os.systemcalls anywhere. - T-04
test_blob_lands_at_expected_path: AC-4; stub spelled out:monkeypatchasync def _stub_writes_blob(probe_id, argv, *, cwd, timeout_s, max_stdout_bytes=64*1024*1024, env_extra=None): out_path = Path(argv[argv.index("--output") + 1]) out_path.write_bytes(b"FAKE-SCIP-BLOB") return ProcessResult(returncode=0, stdout=b'{"files_indexed":1}', stderr=b"")codegenie.exec.run_external_clito_stub_writes_blob; run probe; assertctx.output_dir / "raw" / "scip-index.scip"exists and isb"FAKE-SCIP-BLOB"; assertslice["scip_index_uri"]is the repo-relative POSIX form (e.g.".codegenie/context/raw/scip-index.scip"whenctx.output_dir == repo.root / ".codegenie/context"). - T-05
test_slice_fields_localv2_compliance: AC-5; stub--summary-jsonstdout with all optional fields; buildSemanticIndexSlice(...)viamodel_validateof the slice; assert every required key is present and every optional key is present when supplied; assertfiles_in_repocounts ONLY.ts/.tsx(plant.jsfile; assert it is NOT counted). - T-06
test_cache_key_sensitivity_via_probe_version_property: AC-2; build a probe stub whoseversionreturns"0.1.0+scip-typescript-1.0.0", computekey_for(probe_a, snapshot, task); rebuild withversionreturning"0.1.0+scip-typescript-2.0.0"; assert keys differ. Then plant two snapshots with different.tscontent and the SAMEversion; assert keys differ (Merkle path viacontent_hash_of_inputs). Both arms of cache-key sensitivity verified againstcache/keys.py:142-148directly (no mock of the cache layer). - T-07
test_timeout_path_emits_typed_error(AC-6): monkeypatchrun_external_clito raisecodegenie.errors.ProbeTimeoutError("simulated 300s timeout", probe_id="scip_index", timeout_s=300); run probe; assert slice containsindexer_errors=1,warnings == ["scip_index.timeout"],confidence="low"; assert.scipblob does NOT exist (deleted); assertscip.jsonIS written; assert no exception escapesrun(). Then loadscip.jsonand feed throughcodegenie.indices.registry.default_freshness_registry.dispatch_one(IndexName("scip"), {IndexName("scip"): json.loads(scip_json.read_bytes())}, head=last_indexed_commit); assert the returnedIndexFreshnessisStalewithisinstance(stale.reason, IndexerError)andstale.reason.message == "indexer_reported_1_errors"— proves the cross-story hand-off works end-to-end. - T-08
test_non_zero_exit_path(AC-7): monkeypatch to returnProcessResult(returncode=2, stdout=b"", stderr=b"bad tsconfig\n"); assertindexer_errors=1,warnings == ["scip_index.exit_nonzero"], slice does NOT containstderrtext; assert structured log capturedstderr_tail="bad tsconfig\n"; assertscip.jsonIS written. - T-09
test_walker_exclusion_invariant(AC-9): tempdir withnode_modules/extra.ts,dist/bar.ts,build/c.ts,.git/d.ts,src/baz.ts,src/quux.js(NOT counted —.js),src/zap.tsx; assert_count_indexable_files(root) == 2(baz.ts+zap.tsx); assert_compute_indexable_merkle(root)is unchanged whennode_modules/extra.tsis added vs. when not (symmetric exclusion proof). Both helpers route through_walk_indexable_files. - T-10
test_tool_missing_path(AC-8): monkeypatchrun_external_clito raisecodegenie.errors.ToolMissingError("scip-typescript"); ALSO ensureresolve_tool_version("scip-typescript")returns"unknown"on the same exception (T-20 covers the resolver-side invariant); assertindexer_version="unknown",warnings == ["scip_index.tool_missing"]; assertscip.jsonIS written; feed through S4-01'sscip_freshnessand assertStale(IndexerError("indexer_reported_1_errors")). - T-11
test_raw_artifact_dir_unwritable(AC-4): tempdir with<output_dir>/rawas a file (not dir);mkdir(parents=True, exist_ok=True)raisesFileExistsError; probe emits AC-4's short-circuit slice; norun_external_cliinvocation occurs (assert via spy that the stub was not called). - T-12
test_warning_ids_match_adr_0007(AC-13): iterate_WARNING_IDS; assert every member matches the Phase 1 ADR-0007 regex. - T-12b
test_run_only_emits_declared_warning_ids(AC-13): AST-walkrun(); assert every string literal passed towarnings.append(...)is a member of_WARNING_IDS. - T-13
test_registry_membership_heaviness_heavy(AC-14). - T-19
test_scip_json_keys_match_b2_consumer(AC-16): run probe against a real-ish stubbed input set; load<output_dir>/raw/scip.json; assert the parsed dict has at minimum keys{"last_indexed_commit", "files_indexed", "files_in_repo", "indexer_errors", "last_indexed_at"}(B2's required set); feed throughscip_freshness(slice, head=last_indexed_commit)and assertisinstance(result, Fresh)on a healthy run; vary one key at a time (droplast_indexed_commit, setindexer_errors=1, setfiles_indexed=0, setlast_indexed_commit="<other-sha>") and assert the expectedStale(reason=...)shape each time. This is the structural guarantee of the cross-story B2 hand-off. - T-Sj-2
test_warm_cache_replays_scip_json(AC-17): two consecutiveawait probe.run(repo, ctx)invocations with the same inputs; assertscip.jsonis present after the second AND its bytes equal the first run's bytes; assertscip.jsonis inProbeOutput.raw_artifacts(both runs). - T-19b
test_slice_envelope_and_scip_json_share_one_model(AC-18): after a run, assertschema_slice["semantic_index"]andjson.loads(scip_json.read_bytes())produce structurally identical dicts modulo nothing (single source of truth). - T-20
test_resolve_tool_version_single_subprocess_per_process(AC-19): spy onrun_external_cli; callawait resolve_tool_version("scip-typescript")twice; assert the spy was called exactly once. Thenclear_for_tests(); call again; assert the spy was called exactly twice total. Also: stubrun_external_clito raiseToolMissingError; callawait resolve_tool_version("missing-binary"); assert it returns"unknown"(does NOT raise — the probe'sversionproperty must be safe to read on tool-missing). - T-21
test_grammars_lock_load_and_verify_happy_and_mismatch(AC-20): tempdir with a validtools/grammars.lock+ matching vendored binary;load_and_verify(root)returns aGrammarLockFile. Tamper one byte of the vendored binary;load_and_verifyraisesGrammarLoadRefusedwith regex on the language name.
For tools/grammars.lock:
- T-14 test_grammars_lock_schema_and_blake3 (AC-10): parse tools/grammars.lock via GrammarLockFile.model_validate_json(...) (uses AC-20's typed loader); recompute BLAKE3 over each file; assert match.
- T-15 test_grammars_lock_lists_typescript_and_javascript (AC-10): assert both languages are in the parsed grammars list.
- T-16 test_regenerate_grammars_lock_idempotent (AC-11): run the script twice; second invocation produces byte-identical output.
- T-17 test_regenerate_grammars_lock_refuses_missing_binary (AC-11): temp copy with one binary deleted; script exits 1 with stderr "missing".
- T-18 test_grammars_so_binary_attributes (AC-12): .gitattributes contains tools/grammars/*.so binary.
GREEN¶
Implement per outline. Vendor real grammar binaries (download from tree-sitter-typescript / tree-sitter-javascript releases at the version pinned). Compute BLAKE3 locally; commit.
REFACTOR¶
- Extract
_ScipSummaryPydantic model to a private nested class (or top-level if it grows) — keepscip_index.py≤ 300 LOC. - Confirm
mypy --strictpasses; thePydanticmodel needs explicitOptional[...]for unmentioned fields. - Confirm the structured-log event includes
tool_version,files_indexed,files_in_repo,duration_sfor ops observability (Rule 12 — fail loud, succeed verbose-enough-to-debug).
Files to touch¶
Create:
- src/codegenie/probes/layer_b/__init__.py
- src/codegenie/probes/layer_b/scip_index.py
- src/codegenie/probes/layer_b/scip_slice.py — SemanticIndexSlice Pydantic smart constructor (AC-18).
- src/codegenie/exec/tool_versions.py — process-wide tool-version cache (AC-19). Note: src/codegenie/exec.py is currently a module; promoting it to a package (src/codegenie/exec/__init__.py re-exporting current public API) is the cleanest path. Implementer chooses module-vs-package; the cache must live in codegenie.exec.tool_versions.
- src/codegenie/grammars/__init__.py
- src/codegenie/grammars/lock.py — typed grammars-lock loader + GrammarLoadRefused (AC-20).
- tests/unit/probes/layer_b/__init__.py
- tests/unit/probes/layer_b/test_scip_index.py
- tests/unit/exec/test_tool_versions.py
- tests/unit/grammars/__init__.py
- tests/unit/grammars/test_lock.py
- tools/grammars.lock (data)
- tools/grammars/typescript.so (vendored binary — see AC-12)
- tools/grammars/javascript.so (vendored binary — see AC-12)
- tools/regenerate_grammars_lock.sh (executable)
- tests/unit/tools/__init__.py
- tests/unit/tools/test_grammars_lock.py
Edit (additive):
- src/codegenie/probes/__init__.py — additive import line for ScipIndexProbe.
- .gitattributes — tools/grammars/*.so binary, tools/grammars/*.dylib binary.
- pyproject.toml — confirm blake3 is in Phase 0 deps (it is per Phase 0 ADR — no edit needed); confirm mypy.overrides for the new modules if needed.
Out of scope¶
- Parsing the
.scipblob. Phase 2 emits only; Phase 3'sScipAdapterdecides consumption shape (ADR-0002 §Consequences —msgpack/scip-python/projection format all rejected). TreeSitterImportGraphProbe. S4-04 consumestools/grammars.lock(which this story lands); the probe itself ships there.- Sub-schema for
semantic_index. S4-07 lands all seven Layer-B sub-schemas withadditionalProperties: falseand ADR-0007 ID-pattern constraints. - Real
scip-typescriptinvocation in unit tests. Unit tests monkeypatchrun_external_cli; the real invocation is exercised in the portfolio sweep (S7-05) and integration job (S8-03integrationjob). - Bench overhead for SCIP. S8-03's
bench_portfolio_walltimecovers cold + warm p50; this story's perf envelope (~10 s cold per phase-arch-design.md §"Component design" #12 — though that's tree-sitter; SCIP is more like 8–30 s on minimal-ts) is informational. scip-python/ cross-language SCIP. Phase 2 is TypeScript/JavaScript only viascip-typescript. Phase 8+ adds Python / Java / Go via ADR-amendments.
Notes for the implementer¶
- Why this story owns
grammars.lockinstead of S4-04. S4-04'sTreeSitterImportGraphProbeis the first consumer of the lock — it does a BLAKE3 verification at grammar load time. The lock must exist on disk before that probe can load. Splitting "lock file" and "lock-file consumer" across separate stories would create an awkward sequencing where S4-04 cannot land green without S4-03's artifacts. Bundling them here means the dependency in the manifest (S4-04 depends on S4-03) is real — not just narrative. - Vendoring grammars is a PR-reviewable step. ADR-0002 §Tradeoffs — "Grammar regeneration is a PR with a binary diff." The PR description for this story must include: (a) the upstream tree-sitter-typescript / tree-sitter-javascript release tags, (b) the local BLAKE3 the implementer computed, (c) confirmation that
tools/regenerate_grammars_lock.shproduces the matchingtools/grammars.lockbyte-for-byte. A reviewer can re-run the regen script to verify. .dylibvs.so. The CI runner is Linux-canonical;.sois committed. Developers on macOS canregenerate_grammars_lock.shagainst a.dylibthey vendor locally — but the committed lock file pins.so. If a macOS dev workflow becomes load-bearing, ADR-0002's wheel-matrix discussion applies; today, Linux is the canonical PR-validation environment.run_external_cli, notrun_allowlisteddirect. AC-3 is load-bearing — Layer B/G external CLIs all route throughrun_external_cli(phase-arch-design.md §"Component design" #3, phase-arch-design.md §"Goals" G6). The exception is Layer C (docker,strace) which goes throughrun_allowlisteddirectly.gitis a Phase 0/1 binary and predates the chokepoint; in this probe,git rev-parse HEADis admissible viarun_allowlisted(one-line precedent from S2-02 — same allowlist entry, same env-strip path).- Cache key correctness is load-bearing. A wrong cache key means stale SCIP blobs survive across
scip-typescriptupgrades — and B2 would seelast_indexed_commitmatch HEAD (cache hit), reportFresh, but the BLOB is from the old indexer. The Merkle + tool-version anchor (AC-2) is what prevents this. Test T-06 exercises both arms. - Don't preserve corrupt blobs. AC-6 / AC-7 delete the
.scipfile on timeout / non-zero exit. A partial blob would mislead Phase 3'sScipAdapter(it would try to parse and fail in mysterious ways). The "no blob is better than a bad blob" discipline (Rule 12 — fail loud) — but theindexer_errors=1signal is what makes B2'sStale(IndexerError)real for the renderer. - Rule 9 — tests verify intent. T-06 (cache key sensitivity) encodes the WHY of
declared_inputs. T-07 (timeout path) encodes the WHY of typed error reporting + B2 hand-off. T-09 (count consistency) encodes the WHY of helper-sharing between Merkle and count. None of them check "the code returns a value" — every one checks a load-bearing invariant. - The optional
--summary-jsonfields.scip-typescript's--summary-jsonis a relatively new feature; if the installed version doesn't support it, fall back gracefully (AC-5 — derivefiles_indexedby counting; emitsummary_json_unavailablewarning; leave optional fields unset). Do NOT make--summary-jsonrequired — it would bind Phase 2 to ascip-typescriptminimum version that's not in any ADR. - Why
probe.versioncarries the tool version instead of ascip-typescript-version:<resolved>declared-input token (AC-2 + AC-19). The performance lens originally proposed the special-token shape (matching ADR-0004'simage-digest:mechanism). The problem: master'scache/keys.py::declared_inputs_fordoessnapshot.root.rglob(pattern)and silently drops anything that doesn't resolve to a file path; ADR-0004's token-recognizer dispatch on the cache layer is unimplemented (S1-09 — Done — added only theProbeContext.image_digest_resolverfield, not theCache._resolve_declared_inputsdispatch arm). The_OUTPUT_NAMESPACE = ".codegenie"filter further strips any version-stamp file written under that namespace. So the token would be silently invariant on master. The chokepoint that already exists isprobe.version— it's in the cache-key tuple atcache/keys.py:146. Routing the resolved tool version throughprobe.version(e.g.,f"0.1.0+scip-typescript-{resolved}") closes the gap with zero new mechanism, zero ADR amendment, and zero cache-layer edit. The pattern is smart attribute + process-wide memo; it composes with thetool_versionskernel (AC-19) so the subprocess fires lazily once per process. Future probes follow the same shape: declareversionas a@property, route throughresolve_tool_version. - Why
tool_versionsis a separate module instead of in-probe (AC-19). Three+ Phase-2 probes need the same tool-version resolution (scip-typescript,tree-sitter, then in Phase 2 Layer Ggrype/syft/semgrep/gitleaks). Rule-of-three is crossed already. Keeping the memo inscip_index.pywould (a) hide global state (a module-level_resolved_version: str | None) — fragile under pytest reordering and impossible to reset between tests, (b) make the next probe copy-paste the pattern, and (c) make the "one subprocess per binary per process" guarantee untestable from outside. Lifting tocodegenie.exec.tool_versionswithclear_for_tests()mirrors S1-02'sunregister_for_testsprecedent and makes the extension-by-addition guarantee mechanically verifiable. - Why
SemanticIndexSliceis its own sibling module (AC-18). Two consumers of the slice shape exist in this story alone: therepo-context.yamlenvelope and<raw>/scip.json. A third consumer (S4-07's sub-schema generator) will callSemanticIndexSlice.model_json_schema()to emit the JSON Schema. A fourth consumer (Phase 3'sScipAdapter) will deserializescip.jsonand want the same type. Lifting the model intoscip_slice.pylets every consumer import the type without a circular dependency on the probe module. Mirrors S3-02'sRedactedSlicesmart-constructor precedent; the pattern is smart constructor at the writer boundary (validated at construction; immutable after). - Why
codegenie.grammars.lockis its own module (AC-20). This story writestools/grammars.lockand tests it; S4-04 readstools/grammars.lockat grammar-load time and refuses on BLAKE3 mismatch (GrammarLoadRefused, phase-arch row 10). Two consumers across two adjacent stories crossing the rule-of-three when countingtools/regenerate_grammars_lock.sh— extract now so the shape can't diverge. The pattern is typed boundary for vendored data (Pydanticfrozen=True+extra="forbid"so a manual edit to the lock file fails parse, not deserialization at use). scip.jsonis the load-bearing cross-story hand-off, not the.scipbinary. B2'sread_raw_slicesreads<output_dir>/raw/<index_name>.jsonfiles — never the binary. The.scipblob is Phase 3'sScipAdapterconcern (Phase 2 is opaque). If you're tempted to skip writingscip.jsonbecause "the probe already wrote a blob," remember: B2 cannot parse SCIP; it reads the JSON sidecar. Per AC-16, the JSON sidecar MUST be written on every code path including timeout (AC-6), non-zero exit (AC-7), and tool-missing (AC-8) — otherwise B2 sees the sibling-missing path and firesStale(IndexerError("upstream_scip_unavailable")), which is the wrong typed signal for "the indexer ran but failed."- Empty-repo layer agreement (F-Cov-4 + F-Cov-6). On a TypeScript repo with zero
.ts/.tsxfiles: probe-sideconfidence="low"(the index is not informative); B2-sideFresh(...)(the index matches HEAD —0 == 0). The two layers disagree intentionally; both are correct in their own dimension. Do NOT add a "no .ts files → don't write scip.json" shortcut — that would make B2 fireupstream_scip_unavailable, which is wrong (the indexer did run; it just had no work). SecretRedactorinteraction (S3-03 → S4-03). Thescip.jsoncontent is generated fromSemanticIndexSlice.model_dump_json(...)BEFORE the writer chokepoint appliesSecretRedactor. Per S3-03, every slice that flows through the writer is redacted.scip.jsoncontains only path counts, commit SHAs, and a version string — none of these are secrets — but the redactor MUST still see it (defense-in-depth). The probe writesscip.jsontoctx.output_dir / "raw"; the writer's chokepoint (S3-03) is the one that processes it before publication. Do NOT bypass the chokepoint.