diff --git a/.agents/skills/openultrasast-scan/SKILL.md b/.agents/skills/openultrasast-scan/SKILL.md index 3be60ac..c69e2ff 100644 --- a/.agents/skills/openultrasast-scan/SKILL.md +++ b/.agents/skills/openultrasast-scan/SKILL.md @@ -29,7 +29,6 @@ uv run ousast scan --mode quick uv run ousast scan --mode standard --fail-on verified uv run ousast scan --mode deep --fail-on worth-fixing uv run ousast scan --mode quick --config openultrasast.toml -uv run ousast index # chunk the checkout for the embedding index uv run ousast mcp # the MCP server over stdio, for agent hosts ``` diff --git a/.env.example b/.env.example index 118e634..6274c0e 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,8 @@ DEEPSEEK_API_KEY= # DEEPSEEK_BASE_URL=https://api.deepseek.com -# Embeddings only: OpenAI text-embedding-3-small through OpenRouter. +# Embeddings only (`ousast learn memory embed`, default openai/text-embedding-3-small through OpenRouter). OPENROUTER_API_KEY= -OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # Plane memory store. Default is a local FileStore under ~/ousast-results/plane/memory. # For an S3-compatible server (RustFS is the tested one): OUSAST_MEMORY=s3://[/], or diff --git a/.kiro/specs/ai-service-plane/spec.json b/.kiro/specs/ai-service-plane/spec.json index affcf20..42aacdb 100644 --- a/.kiro/specs/ai-service-plane/spec.json +++ b/.kiro/specs/ai-service-plane/spec.json @@ -1,9 +1,9 @@ { "feature_name": "ai-service-plane", "created_at": "2026-09-29T15:40:00Z", - "updated_at": "2026-09-29T17:55:00Z", + "updated_at": "2026-10-02T00:00:00Z", "language": "en", - "phase": "implementation", + "phase": "implementation-complete", "approvals": { "requirements": { "generated": true, diff --git a/.kiro/specs/contributor-scan/requirements.md b/.kiro/specs/contributor-scan/requirements.md index 95132a6..0b56e0a 100644 --- a/.kiro/specs/contributor-scan/requirements.md +++ b/.kiro/specs/contributor-scan/requirements.md @@ -1,5 +1,7 @@ # Requirements Document: contributor-scan +> **Status 2026-10-02:** paused since 2026-09-23 (its last change). 27 of 51 tasks done: the MAP integration was delivered and `v1.2.0-alpha.1` rolled out NO-GO (`census-repair-verification.md`); the remaining tasks wait on `pre-push-safety-net` M2/M3 and nothing here moved after the ax plane landed. Not active. + ## Introduction `model-grounded-detection` built an arbiter and measured it: 41.3% of the corpus reaches `model_entailed` diff --git a/.kiro/specs/finding-feedback-loop/requirements.md b/.kiro/specs/finding-feedback-loop/requirements.md index 28bed51..14f1f54 100644 --- a/.kiro/specs/finding-feedback-loop/requirements.md +++ b/.kiro/specs/finding-feedback-loop/requirements.md @@ -1,5 +1,7 @@ # Requirements Document: finding-feedback-loop +> **Status 2026-10-02:** dormant. Requirements approved and design generated on 2026-09-09; the design was never approved and no tasks exist. It depends on `contributor-scan`, which is paused; reopen it with that spec. + ## Introduction Nine engine defects were found in one session against two repositories. **Three were the same bug in three diff --git a/.kiro/steering/overview.md b/.kiro/steering/overview.md index a430965..93230be 100644 --- a/.kiro/steering/overview.md +++ b/.kiro/steering/overview.md @@ -200,10 +200,10 @@ Counted 2026-10-02 from each spec's `tasks.md` checkboxes and `spec.json`. |---|---|---| | `learned-decision-engine` | implementation | 11 of 17 tasks done | | `pre-push-safety-net` | implementation | 67 of 79 tasks done | -| `contributor-scan` | implementation | 27 of 51 tasks done | -| `ai-service-plane` | implementation | 11 of 11 tasks done; the ax plane is the agentic path | +| `contributor-scan` | paused | 27 of 51 tasks done; paused since 2026-09-23 (`v1.2.0-alpha.1` NO-GO), waits on pre-push M2/M3 | +| `ai-service-plane` | complete | 11 of 11; the ax plane is the agentic path | | `harnessx-removal` | complete | 8 of 8; HarnessX removed 2026-09-30 | -| `finding-feedback-loop` | design | requirements approved, design awaiting approval | +| `finding-feedback-loop` | dormant | requirements approved, design unapproved since 2026-09-09; depends on `contributor-scan` | | `model-grounded-detection` | complete | 16 of 16; built the arbiter this all rests on | | `authorization-obligations` | dormant | tasks approved, 0 of 24 started; superseded in practice by the dominance arbiter | | `reachability-flow-model` | dormant | 23 tasks generated, nothing approved; largely delivered by adopting Joern | @@ -214,6 +214,5 @@ Counted 2026-10-02 from each spec's `tasks.md` checkboxes and `spec.json`. > **Retired 2026-09-30:** HarnessX was removed by `harnessx-removal`; the agentic plane is ax (`ai-service-plane`), > and evolutionary proposals come from the plane memory. The HarnessX specs in the table above are history. -As of 2026-09-09: 97 source modules, 90 load-bearing and 7 standalone capabilities; 814 tests. 151 source modules, 146 load-bearing and 5 standalone capabilities, 0 orphaned -(`benchmarks/measurements/2026-09-08-module-audit.json`, regenerated 2026-10-02); 2,009 tests collected. +(`benchmarks/measurements/2026-09-08-module-audit.json`, regenerated 2026-10-02); 2,023 tests collected. diff --git a/README.md b/README.md index 4ff4eaa..6a8ba5f 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,7 @@ Keys and store settings live in `.env` in the working directory (gitignored, nev ```bash DEEPSEEK_API_KEY=... # every LLM call: model layer, tool hunter, plane tasks, decision engine -OPENROUTER_API_KEY=... # embeddings only -OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small +OPENROUTER_API_KEY=... # embeddings only (`ousast learn memory embed`) # Plane memory store (maintainers). Unset: a local file store under ~/ousast-results/plane/memory. OUSAST_MEMORY=s3:// # or s3:///; needs the s3 extra @@ -278,6 +277,5 @@ uv run python dagger/ci.py # containerized CI pipeline ``` Maintainer commands not covered above: `ousast repos` (pinned known-vulnerable checkouts), -`ousast index` (chunk a repository for embedding-index construction), `ousast model candidates` -(what the candidate enumerator can reach) and `ousast learn` (the decision engine's data; see +`ousast model candidates` (what the candidate enumerator can reach) and `ousast learn` (the decision engine's data; see [docs/decision-engine.md](docs/decision-engine.md)). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 338ef76..746de2d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -8,8 +8,15 @@ within-pair 0.72-0.93, BLOCK offered nowhere, canary below 0.9 for three families) and that the engine is not adopted. The model layer's residual question is documented at `model/pipeline.py` (`model/judge.py` was deleted); a missing provider key is described as it behaves (no question asked, entailed findings reported), - not as a recorded degradation. The `.env` reference lists `OPENROUTER_EMBEDDING_MODEL` and the memory-store - variables; `ousast plane memory-normalise` and `ousast index` are listed. + not as a recorded degradation. The `.env` reference lists the memory-store variables; `ousast plane memory-normalise` is listed. +- **Removed: the unused vector index, `ousast index` and `[embeddings]`** (legacy cleanup, 2026-10-02). `index.py` + keeps only the text chunker the skill router uses; the `VectorIndex` half had no caller, and nothing read the + `chunks.json` the subcommand wrote. `[embeddings] model`/`store` fail to load with the reason, like the other + retired keys; `OPENROUTER_EMBEDDING_MODEL` is no longer read (`ousast learn memory embed --model` names the model). +- **Retired three config keys nothing read** (2026-10-02): `[sandbox] network`, `[sandbox] workspace_readonly` and + `[complexity] top_k` now fail to load naming why. The sandbox always ran with `--network none` and a read-only + workspace regardless of the first two; no stage read `top_k`. `memory_mb`, `timeout_seconds`, `pids_limit` and + `max_hunter_hotspots` are read and stay. # v2.0.0 (2026-10-02) diff --git a/benchmarks/archive/README.md b/benchmarks/archive/README.md index 357341f..2b72129 100644 --- a/benchmarks/archive/README.md +++ b/benchmarks/archive/README.md @@ -16,7 +16,7 @@ maps each old path to the new one. The records themselves are not rewritten. | `model_sinks.py` | Stage-1 source-only model classification of sinks. Moved into the package as `plane/tasks/roles.py`. | `independent/selection-v2-model-sinks.json`, `measurements/2026-10-01-decision-engine-harvest/record.json`, both `2026-10-01-decision-engine-leak-audit` records | `05d5699` (named in `selection-v2-model-sinks.json`) | | `sink_candidates.py` | Diagnostic: vocabulary-declared sink candidates per case. Its declaration patterns live on in `plane/tasks/repo_facts.py`. | `independent/results-v2-model-pipeline.json` ("vocabulary sink-first: 2/17") | no commit recorded | | `hunt.py` | The v2 tool-hunter run over the population (protocol `hunter-protocol-v2.md`, including its `FAMILY_WORDS` table). Replaced by the plane's `verify` / `agree` tasks. | `independent/hunter-protocol-v2.md`, `independent/results-v2-hunter.json` (a label source in `learn/sources.toml`) | `6bfb05f`, the commit that introduced the protocol, which pins the runner | -| `harnessx_removal_equality.py` | One-off equality proof for `harnessx-removal` (record and compare deterministic outputs). The spec is closed. `tests/test_harnessx_removal_equality.py` still checks that the comparison detects differences. | `measurements/2026-09-30-harnessx-removal-baseline/`, `measurements/2026-09-30-harnessx-removal-equality/` | baseline `ca5caf7`, candidate `b9afb66` | +| `harnessx_removal_equality.py` | One-off equality proof for `harnessx-removal` (record and compare deterministic outputs). The spec is closed; its unit test was dropped on 2026-10-02 so the archived copy is reference only like the rest. | `measurements/2026-09-30-harnessx-removal-baseline/`, `measurements/2026-09-30-harnessx-removal-equality/` | baseline `ca5caf7`, candidate `b9afb66` | The six `independent` scripts still read the population, `evaluate.py` and the results from `benchmarks/independent/`. Their `HERE` points there, so outputs land where they always did. diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index 56cfd90..8ce6a1d 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -338,7 +338,6 @@ "classification": "load_bearing", "reason": "reachable from gate, map_gate, pair_gate, cli", "importers": [ - "cli", "skills" ] }, @@ -530,6 +529,7 @@ "importers": [ "learn.compile", "learn.evaluate", + "learn.examples", "learn.features", "learn.leaks", "learn.program", @@ -1119,7 +1119,6 @@ "hunter", "hunter_tools", "improve.evolve", - "index", "learn.features", "learn.labels", "learn.roles", diff --git a/docs/memory-and-detection.md b/docs/memory-and-detection.md index edb2725..3c0a612 100644 --- a/docs/memory-and-detection.md +++ b/docs/memory-and-detection.md @@ -35,7 +35,8 @@ Each instrument contributes one block to a candidate's feature record (`learn/sc `INSTRUMENTS`): `language`, `quick` (quick rules), `engine` (the Joern model layer), `facts` (repo facts, such as callers and function span), `source`, `entry_points`, `roles` (model source, sink and sanitizer roles), `delta`, `verify` and `agree` (the plane's verify passes and their -agreement), and `model_sinks`. A candidate is `(path, function, family)` of one repository and pin +agreement), and `model_sinks` (the archived stage-1 classifier, `benchmarks/archive/model_sinks.py`, whose +logic now lives in `plane/tasks/roles.py`). A candidate is `(path, function, family)` of one repository and pin where at least one instrument emitted a signal of that family (`learn/features.py`). An instrument that did not run, had no coverage or failed records that state (`none`, `failed`, diff --git a/src/openultrasast/cli.py b/src/openultrasast/cli.py index bdf70bc..294b9db 100644 --- a/src/openultrasast/cli.py +++ b/src/openultrasast/cli.py @@ -43,7 +43,6 @@ from .gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR from .harness import HarnessRuntime, HarnessTraceWriter, write_harness_config from .hunter import run_hunter_pool, write_hunter_trajectories from .improve import RoundOutcome, run_improvement -from .index import build_code_chunks from .learn.cli import add_commands as add_learn_commands from .learn.cli import run as run_learn from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints @@ -173,11 +172,6 @@ def _main(argv: list[str] | None) -> int: scan.add_argument("--config", type=Path, default=Path("openultrasast.toml")) scan.add_argument("--fail-on", choices=("never", "findings", "verified", "worth-fixing"), default="never") - index = subparsers.add_parser("index", help="chunk a local repository for embedding index construction") - index.add_argument("path", type=Path) - index.add_argument("--config", type=Path, default=Path("openultrasast.toml")) - index.add_argument("--chunk-lines", type=int, default=80) - benchmark = subparsers.add_parser("benchmark", help="run a benchmark manifest and write scoreboard artifacts") benchmark.add_argument("manifest", type=Path) benchmark.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick") @@ -426,8 +420,6 @@ def _main(argv: list[str] | None) -> int: return delivery.exit_code if args.command == "scan": return _scan(args.path, args.config, args.mode, args.fail_on) - if args.command == "index": - return _index(args.path, args.config, args.chunk_lines) if args.command == "benchmark": return _benchmark(args.manifest, args.config, args.mode) if args.command == "improve": @@ -1564,30 +1556,5 @@ def _model_candidates(args: argparse.Namespace) -> int: return 0 -def _index(path: Path, config_path: Path, chunk_lines: int) -> int: - if not path.exists() or not path.is_dir(): - raise SystemExit(f"index path is not a directory: {path}") - config = load_config(config_path if config_path.exists() else None) - run = create_scan_run(path, config) - snapshot, targets = preprocess_repository(run.target, run.root / "preprocess" / "file_targets.json") - chunks = build_code_chunks(run.target, targets, max_lines=chunk_lines) - payload = { - "store": config.embeddings.store or "json-local", - "embedding_model": config.embeddings.model, - "repo_root": snapshot.root, - "repo_commit": snapshot.commit, - "chunk_count": len(chunks), - "chunks": [chunk.__dict__ for chunk in chunks], - } - path_out = run.root / "index" / "chunks.json" - path_out.parent.mkdir(parents=True, exist_ok=True) - path_out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") - print(f"scan_id={run.scan_id}") - print(f"run_dir={run.root}") - print(f"chunks={len(chunks)}") - print(f"index_artifact={path_out}") - return 0 - - if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/openultrasast/config.py b/src/openultrasast/config.py index db06bde..6e0d038 100644 --- a/src/openultrasast/config.py +++ b/src/openultrasast/config.py @@ -10,8 +10,6 @@ from typing import Literal from openultrasast.contracts import Contract -DEFAULT_VECTOR_STORE = "json-local" - # Retirement notes (harnessx-removal Req 1.3, 3.3). A key that asked for a retired LLM capability fails and names # the replacement, never a silent downgrade; the old budget section only warns. Every line below that names the # removed plane carries its retirement date, which the reference-search test holds the rest of the tree to. @@ -26,6 +24,15 @@ _UNREAD_ON = "retired 2026-10-02 because nothing ever read it" _UNREAD_DYNAMIC = "no dynamic or deep tier exists; deep mode will define its own keys when it is built" _UNREAD_EVIDENCE = "evidence tiers come from the program model's ladder (model/ladder.py), not from configuration" _UNREAD_VARIANTS = "the structural variant search it configured was deleted with semantic/variants.py" +_UNREAD_SANDBOX = ( + "the sandbox always runs with `--network none` and a read-only workspace (sandbox/runner.py); that is an " + "invariant the runner enforces, not a knob" +) +_UNREAD_TOP_K = "no stage read it; `[complexity] max_hunter_hotspots` is the one complexity key the scan reads" +_UNREAD_EMBEDDINGS = ( + "the json-local vector index it configured and `ousast index` were deleted; the decision engine embeds through " + "`ousast learn memory embed --model` (learn/embeddings.py)" +) RETIRED_KEYS: dict[tuple[str, str], str] = { ("models", "verifier"): f"{_RETIRED_ON}: LLM verification runs on the plane (`verify` + `agree`, {_PLANE_RUN})", ("fusion", "panel_model"): f"{_RETIRED_ON}: {_RETIRED_PANELS}", @@ -38,6 +45,11 @@ RETIRED_KEYS: dict[tuple[str, str], str] = { ("evidence", "minimum_patch"): f"{_UNREAD_ON}: {_UNREAD_EVIDENCE}", ("variants", "enabled"): f"{_UNREAD_ON}: {_UNREAD_VARIANTS}", ("variants", "max_mechanisms"): f"{_UNREAD_ON}: {_UNREAD_VARIANTS}", + ("embeddings", "model"): f"{_UNREAD_ON}: {_UNREAD_EMBEDDINGS}", + ("embeddings", "store"): f"{_UNREAD_ON}: {_UNREAD_EMBEDDINGS}", + ("sandbox", "network"): f"{_UNREAD_ON}: {_UNREAD_SANDBOX}", + ("sandbox", "workspace_readonly"): f"{_UNREAD_ON}: {_UNREAD_SANDBOX}", + ("complexity", "top_k"): f"{_UNREAD_ON}: {_UNREAD_TOP_K}", } RETIRED_SECTION = "harnessx" # retired 2026-09-30: loads with one warning and is otherwise ignored (Req 3.3) RETIRED_SECTION_WARNING = ( @@ -94,16 +106,8 @@ class ModelConfig: thinking: bool = False -@dataclass(frozen=True) -class EmbeddingConfig: - model: str | None = None - store: str = DEFAULT_VECTOR_STORE - - @dataclass(frozen=True) class SandboxConfig: - network: bool = False - workspace_readonly: bool = True memory_mb: int = 2048 timeout_seconds: int = 300 pids_limit: int = 512 @@ -148,8 +152,7 @@ class FusionConfig: @dataclass(frozen=True) class ComplexityConfig: - # Stage-2 map size and hunter attention. Small defaults keep PR/nightly map work cheap. - top_k: int = 20 + # Hunter attention. A small default keeps PR/nightly map work cheap. max_hunter_hotspots: int = 8 @@ -205,7 +208,6 @@ class PushConfig(Contract): @dataclass(frozen=True) class ResolvedConfig: models: ModelConfig = ModelConfig() - embeddings: EmbeddingConfig = EmbeddingConfig() sandbox: SandboxConfig = SandboxConfig() static_analysis: StaticAnalysisConfig = StaticAnalysisConfig() score: ScoreConfig = ScoreConfig() @@ -231,7 +233,6 @@ def load_config(config_path: Path | None = None, *, dotenv: bool = True) -> Reso return ResolvedConfig( models=_load_models(data.get("models", {})), - embeddings=_load_embeddings(data.get("embeddings", {})), sandbox=_load_sandbox(data.get("sandbox", {})), static_analysis=_load_static_analysis(data.get("static_analysis", {})), score=_load_score(data.get("score", {})), @@ -277,19 +278,9 @@ def _load_models(value: object) -> ModelConfig: ) -def _load_embeddings(value: object) -> EmbeddingConfig: - data = _section(value) - return EmbeddingConfig( - model=_string(data.get("model")) or _string(os.environ.get("OPENROUTER_EMBEDDING_MODEL")), - store=_string(data.get("store")) or DEFAULT_VECTOR_STORE, - ) - - def _load_sandbox(value: object) -> SandboxConfig: data = _section(value) return SandboxConfig( - network=bool(data.get("network", False)), - workspace_readonly=bool(data.get("workspace_readonly", True)), memory_mb=_int_value(data.get("memory_mb"), 2048), timeout_seconds=_int_value(data.get("timeout_seconds"), 300), pids_limit=_int_value(data.get("pids_limit"), 512), @@ -349,7 +340,6 @@ def _load_fusion(value: object) -> FusionConfig: def _load_complexity(value: object) -> ComplexityConfig: data = _section(value) return ComplexityConfig( - top_k=_int_value(data.get("top_k"), 20), max_hunter_hotspots=_int_value(data.get("max_hunter_hotspots"), 8), ) diff --git a/src/openultrasast/index.py b/src/openultrasast/index.py index 3e179a1..01eb221 100644 --- a/src/openultrasast/index.py +++ b/src/openultrasast/index.py @@ -1,13 +1,14 @@ +"""Text chunking for the skill router (`skills.py`). + +The vector index that once lived beside the chunker (`VectorIndex`, `build_vector_index`, `query_vector_index`, +`build_retrieval_package`) and the `ousast index` subcommand that fed it were removed on 2026-10-02: nothing ever +read the `chunks.json` they wrote. The decision engine's embeddings live in `learn/embeddings.py`. +""" + from __future__ import annotations import hashlib -import json -import math -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Protocol - -from .preprocess import FileTarget, RepoSnapshot +from dataclasses import dataclass VALID_NAMESPACES = {"repo_code", "docs", "static_findings", "mechanisms", "skills", "traces"} @@ -24,82 +25,6 @@ class CodeChunk: metadata: dict[str, str | int | bool] -@dataclass(frozen=True) -class VectorRecord: - chunk: CodeChunk - embedding: list[float] - - -@dataclass(frozen=True) -class VectorIndex: - store: str - embedding_model: str - repo_root: str - repo_commit: str | None - records: list[VectorRecord] - - -@dataclass(frozen=True) -class RetrievalHit: - score: float - chunk: CodeChunk - - -@dataclass(frozen=True) -class RetrievalPackage: - role: str - hits: list[RetrievalHit] - text: str - truncated: bool - - -class EmbeddingClient(Protocol): - def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]: - raise NotImplementedError - - -def chunk_file(root: Path, target: FileTarget, *, max_lines: int = 80) -> list[CodeChunk]: - if max_lines < 1: - raise ValueError("max_lines must be at least 1") - text = (root / target.path).read_text(errors="ignore") - lines = text.splitlines() - chunks: list[CodeChunk] = [] - for start in range(0, len(lines), max_lines): - chunk_lines = lines[start : start + max_lines] - if not any(line.strip() for line in chunk_lines): - continue - start_line = start + 1 - end_line = start + len(chunk_lines) - chunk_text = "\n".join(chunk_lines) - chunks.append( - CodeChunk( - chunk_id=_chunk_id(target.path, start_line, end_line, chunk_text), - namespace="repo_code", - path=target.path, - language=target.language, - start_line=start_line, - end_line=end_line, - text=chunk_text, - metadata={ - "path": target.path, - "language": target.language, - "start_line": start_line, - "end_line": end_line, - "has_fuzz_entry_point": target.has_fuzz_entry_point, - "tags": ",".join(target.tags), - }, - ) - ) - return chunks - - -def build_code_chunks(root: Path, targets: list[FileTarget], *, max_lines: int = 80) -> list[CodeChunk]: - chunks: list[CodeChunk] = [] - for target in targets: - chunks.extend(chunk_file(root, target, max_lines=max_lines)) - return chunks - - def chunk_text_namespace( *, namespace: str, @@ -144,130 +69,6 @@ def chunk_text_namespace( return chunks -def index_reuse_key(snapshot: RepoSnapshot, embedding_model: str) -> str: - source = { - "root": snapshot.root, - "commit": snapshot.commit, - "file_count": snapshot.file_count, - "languages": snapshot.languages, - "embedding_model": embedding_model, - } - return hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()[:24] - - -def build_vector_index( - *, - snapshot: RepoSnapshot, - chunks: list[CodeChunk], - embedding_model: str, - client: EmbeddingClient, - store: str = "json-local", - batch_size: int = 32, -) -> VectorIndex: - if batch_size < 1: - raise ValueError("batch_size must be at least 1") - records: list[VectorRecord] = [] - for start in range(0, len(chunks), batch_size): - batch = chunks[start : start + batch_size] - vectors = client.embed(model=embedding_model, inputs=[chunk.text for chunk in batch]) - if len(vectors) != len(batch): - raise ValueError("embedding response count did not match chunk count") - records.extend(VectorRecord(chunk=chunk, embedding=vector) for chunk, vector in zip(batch, vectors, strict=True)) - return VectorIndex( - store=store, - embedding_model=embedding_model, - repo_root=snapshot.root, - repo_commit=snapshot.commit, - records=records, - ) - - -def write_vector_index(index: VectorIndex, path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(_index_to_payload(index), indent=2, sort_keys=True) + "\n") - - -def read_vector_index(path: Path) -> VectorIndex: - payload = json.loads(path.read_text()) - return VectorIndex( - store=str(payload["store"]), - embedding_model=str(payload["embedding_model"]), - repo_root=str(payload["repo_root"]), - repo_commit=payload.get("repo_commit"), - records=[ - VectorRecord( - chunk=CodeChunk(**item["chunk"]), - embedding=[float(value) for value in item["embedding"]], - ) - for item in payload.get("records", []) - ], - ) - - -def query_vector_index( - index: VectorIndex, - query_embedding: list[float], - *, - metadata_filter: dict[str, str | int | bool] | None = None, - limit: int = 5, -) -> list[tuple[float, CodeChunk]]: - matches: list[tuple[float, CodeChunk]] = [] - for record in index.records: - if metadata_filter and not _metadata_matches(record.chunk.metadata, metadata_filter): - continue - matches.append((_cosine_similarity(query_embedding, record.embedding), record.chunk)) - return sorted(matches, key=lambda item: item[0], reverse=True)[:limit] - - -def build_retrieval_package( - *, - role: str, - index: VectorIndex, - query_embedding: list[float], - metadata_filter: dict[str, str | int | bool] | None = None, - limit: int = 5, - max_chars: int = 4000, -) -> RetrievalPackage: - hits: list[RetrievalHit] = [] - parts: list[str] = [] - total = 0 - truncated = False - for score, chunk in query_vector_index(index, query_embedding, metadata_filter=metadata_filter, limit=limit): - block = f"[{chunk.namespace}] {chunk.path}:{chunk.start_line}-{chunk.end_line}\n{chunk.text}" - if total + len(block) > max_chars: - truncated = True - break - hits.append(RetrievalHit(score=score, chunk=chunk)) - parts.append(block) - total += len(block) - return RetrievalPackage(role=role, hits=hits, text="\n\n".join(parts), truncated=truncated) - - -def _index_to_payload(index: VectorIndex) -> dict[str, object]: - return { - "store": index.store, - "embedding_model": index.embedding_model, - "repo_root": index.repo_root, - "repo_commit": index.repo_commit, - "records": [{"chunk": asdict(record.chunk), "embedding": record.embedding} for record in index.records], - } - - -def _metadata_matches(metadata: dict[str, str | int | bool], expected: dict[str, str | int | bool]) -> bool: - return all(metadata.get(key) == value for key, value in expected.items()) - - -def _cosine_similarity(left: list[float], right: list[float]) -> float: - if len(left) != len(right): - return 0.0 - numerator = sum(a * b for a, b in zip(left, right, strict=True)) - left_norm = math.sqrt(sum(a * a for a in left)) - right_norm = math.sqrt(sum(b * b for b in right)) - if left_norm == 0 or right_norm == 0: - return 0.0 - return numerator / (left_norm * right_norm) - - def _chunk_id(path: str, start_line: int, end_line: int, text: str) -> str: digest = hashlib.sha256(f"{path}:{start_line}:{end_line}:{text}".encode()).hexdigest()[:16] return f"{path}:{start_line}-{end_line}:{digest}" diff --git a/tests/test_config.py b/tests/test_config.py index 0e7b8e1..3dfacb2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,7 +25,6 @@ def test_load_config_reads_toml(tmp_path: Path) -> None: config = load_config(config_path) assert config.models.ranker == "openrouter/test-ranker" - assert config.embeddings.store == "json-local" assert config.sandbox.memory_mb == 1024 @@ -74,6 +73,11 @@ def test_retired_llm_keys_fail_naming_the_replacement(tmp_path: Path, body: str, ('[evidence]\nminimum_patch = "root_cause_explained"\n', "`[evidence] minimum_patch`"), ("[variants]\nenabled = false\n", "`[variants] enabled`"), ("[variants]\nmax_mechanisms = 10\n", "`[variants] max_mechanisms`"), + ('[embeddings]\nmodel = "openai/text-embedding-3-small"\n', "`[embeddings] model`"), + ('[embeddings]\nstore = "json-local"\n', "`[embeddings] store`"), + ("[sandbox]\nnetwork = true\n", "`[sandbox] network`"), + ("[sandbox]\nworkspace_readonly = false\n", "`[sandbox] workspace_readonly`"), + ("[complexity]\ntop_k = 3\n", "`[complexity] top_k`"), ], ) def test_unread_keys_fail_naming_why(tmp_path: Path, body: str, key: str) -> None: @@ -140,30 +144,27 @@ def test_write_resolved_config_creates_json(tmp_path: Path) -> None: payload = json.loads(output.read_text()) assert payload["sandbox"]["memory_mb"] == 2048 # retired 2026-10-02 (never read): the unread sections are not echoed as if they configured something - assert not {"dynamic", "evidence", "variants"} & set(payload) and "patcher" not in payload["models"] + assert not {"dynamic", "evidence", "variants", "embeddings"} & set(payload) and "patcher" not in payload["models"] def test_complexity_and_regress_defaults_when_section_absent() -> None: config = load_config(None) - assert config.complexity.top_k == 20 assert config.complexity.max_hunter_hotspots == 8 assert config.regress.max_candidates == 5 assert config.regress.images == () - assert config.sandbox.network is False - assert config.sandbox.workspace_readonly is True assert config.sandbox.memory_mb == 2048 assert config.sandbox.timeout_seconds == 300 assert config.sandbox.pids_limit == 512 -def test_override_changes_top_k_and_max_candidates_only(tmp_path: Path) -> None: +def test_override_changes_max_hunter_hotspots_and_max_candidates_only(tmp_path: Path) -> None: config_path = tmp_path / "openultrasast.toml" config_path.write_text( "\n".join( [ "[complexity]", - "top_k = 3", + "max_hunter_hotspots = 3", "[regress]", "max_candidates = 2", ] @@ -172,12 +173,9 @@ def test_override_changes_top_k_and_max_candidates_only(tmp_path: Path) -> None: config = load_config(config_path) - assert config.complexity.top_k == 3 + assert config.complexity.max_hunter_hotspots == 3 assert config.regress.max_candidates == 2 - assert config.complexity.max_hunter_hotspots == 8 assert config.regress.images == () - assert config.sandbox.network is False - assert config.sandbox.workspace_readonly is True assert config.sandbox.memory_mb == 2048 assert config.sandbox.timeout_seconds == 300 assert config.sandbox.pids_limit == 512 @@ -201,24 +199,17 @@ def test_load_config_reads_regress_image_pins(tmp_path: Path) -> None: assert config.regress.max_candidates == 4 assert dict(config.regress.images) == {"python": "python:3.12-slim", "javascript": "node:22-slim"} - assert config.complexity.top_k == 20 assert config.complexity.max_hunter_hotspots == 8 assert config.sandbox.memory_mb == 2048 def test_load_dotenv_sets_missing_keys_only(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] env_file = tmp_path / ".env" - env_file.write_text("OPENROUTER_API_KEY=from-file\nOPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small\n") + env_file.write_text("OPENROUTER_API_KEY=from-file\nS3_REGION=us-east-1\n") monkeypatch.setenv("OPENROUTER_API_KEY", "already-set") - monkeypatch.delenv("OPENROUTER_EMBEDDING_MODEL", raising=False) + monkeypatch.delenv("S3_REGION", raising=False) load_dotenv(env_file, force=True) assert os.environ["OPENROUTER_API_KEY"] == "already-set" - assert os.environ["OPENROUTER_EMBEDDING_MODEL"] == "openai/text-embedding-3-small" - - -def test_embeddings_model_from_env(monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.setenv("OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small") - config = load_config(None) - assert config.embeddings.model == "openai/text-embedding-3-small" + assert os.environ["S3_REGION"] == "us-east-1" diff --git a/tests/test_harnessx_removal_equality.py b/tests/test_harnessx_removal_equality.py deleted file mode 100644 index 55f63bd..0000000 --- a/tests/test_harnessx_removal_equality.py +++ /dev/null @@ -1,65 +0,0 @@ -"""The equality instrument (harnessx-removal design section 3) sees a difference when there is one. - -A comparison that always says "equal" would prove nothing, so these tests feed it records that differ in one -place each and require it to name that place, and records that differ only in excluded fields and require it -to pass. -""" - -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -_SPEC = importlib.util.spec_from_file_location("hxr_equality", ROOT / "benchmarks" / "archive" / "harnessx_removal_equality.py") -assert _SPEC is not None and _SPEC.loader is not None -equality = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(equality) - - -def _record(path: Path, *, findings: object, suite: dict[str, str], stdout: str = "findings=3\n") -> Path: - (path / "scan").mkdir(parents=True) - (path / "suite.json").write_text(equality.dump(suite)) - (path / "scan" / "findings.json").write_text(equality.dump(findings)) - (path / "scan" / "stdout.txt").write_text(stdout) - (path / "env.json").write_text(json.dumps({"tree_ish": str(path)})) - return path - - -def _normalized(raw: dict[str, object], root: str) -> object: - return equality.normalize(raw, root, "/tmp/x") - - -def test_excluded_fields_and_run_ids_do_not_count(tmp_path: Path) -> None: - a = _normalized({"rule": "sqli", "runtime_seconds": 1.2, "path": "/r1/runs/20260930T101010Z-0123abcd/f.py", "t_ms": 4}, "/r1") - b = _normalized({"rule": "sqli", "runtime_seconds": 9.9, "path": "/r2/runs/20261001T111111Z-ffffffff/f.py", "t_ms": 7}, "/r2") - suite = {"tests.test_x::test_a": "passed"} - diffs = equality.compare(_record(tmp_path / "a", findings=a, suite=suite), _record(tmp_path / "b", findings=b, suite=suite), {}) - assert diffs == [] - - -def test_a_changed_finding_is_reported_with_its_key(tmp_path: Path) -> None: - suite = {"tests.test_x::test_a": "passed"} - a = _record(tmp_path / "a", findings=[{"rule": "sqli", "line": 3}], suite=suite) - b = _record(tmp_path / "b", findings=[{"rule": "sqli", "line": 4}], suite=suite) - assert equality.compare(a, b, {}) == ["scan/findings.json: $[0].line: 3 -> 4"] - assert equality.main(["compare", str(a), str(b)]) == 1 - - -def test_a_changed_outcome_or_unmapped_test_is_reported(tmp_path: Path) -> None: - a = _record(tmp_path / "a", findings=[], suite={"t::a": "passed", "t::gone": "passed", "t::old": "skipped"}) - b = _record(tmp_path / "b", findings=[], suite={"t::a": "failure", "t::new": "skipped", "t::extra": "passed"}, stdout="findings=2\n") - diffs = equality.compare(a, b, {"deleted": ["t::gone"], "renamed": {"t::old": "t::new"}}) - assert diffs == [ - "suite.json: t::a passed -> failure", - "suite.json: t::extra new in candidate", - "scan/stdout.txt: first differing line 1: 'findings=3' -> 'findings=2'", - ] - - -def test_a_file_present_on_one_side_only_is_reported(tmp_path: Path) -> None: - a = _record(tmp_path / "a", findings=[], suite={}) - b = _record(tmp_path / "b", findings=[], suite={}) - (b / "scan" / "fusion.json").write_text("[]\n") - assert equality.compare(a, b, {}) == ["scan/fusion.json: present only in candidate"] diff --git a/tests/test_index.py b/tests/test_index.py index 6cbc0ab..a2179ee 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,80 +1,9 @@ -import json -from pathlib import Path - import pytest -from openultrasast.index import ( - CodeChunk, - build_code_chunks, - build_retrieval_package, - build_vector_index, - chunk_text_namespace, - index_reuse_key, - query_vector_index, - read_vector_index, - write_vector_index, -) -from openultrasast.preprocess import RepoSnapshot, preprocess_repository +from openultrasast.index import chunk_text_namespace -class FakeEmbeddingClient: - def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]: - return [[float(len(text)), 1.0] for text in inputs] - - -def test_build_code_chunks_preserves_metadata_filters(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def login(user):\n return user\n") - _, targets = preprocess_repository(repo) - - chunks = build_code_chunks(repo, targets, max_lines=1) - - assert len(chunks) == 2 - assert chunks[0].metadata["path"] == "app.py" - assert chunks[0].metadata["language"] == "python" - assert chunks[0].start_line == 1 - - -def test_json_vector_index_round_trips_and_filters(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def login(user):\n return user\n") - snapshot, targets = preprocess_repository(repo) - chunks = build_code_chunks(repo, targets) - index = build_vector_index(snapshot=snapshot, chunks=chunks, embedding_model="openrouter/embed", client=FakeEmbeddingClient()) - output = tmp_path / "index.json" - - write_vector_index(index, output) - loaded = read_vector_index(output) - matches = query_vector_index(loaded, [20.0, 1.0], metadata_filter={"language": "python"}) - - assert loaded.embedding_model == "openrouter/embed" - assert matches[0][1].path == "app.py" - assert json.loads(output.read_text())["store"] == "json-local" - - -def test_vector_index_rejects_embedding_count_mismatch() -> None: - class EmptyEmbeddingClient: - def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]: - return [] - - chunk = CodeChunk( - chunk_id="chunk-1", - namespace="repo_code", - path="app.py", - language="python", - start_line=1, - end_line=1, - text="print('x')", - metadata={"path": "app.py", "language": "python"}, - ) - snapshot = RepoSnapshot(root="/repo", commit=None, file_count=0, languages={}) - with pytest.raises(ValueError): - build_vector_index(snapshot=snapshot, chunks=[chunk], embedding_model="model", client=EmptyEmbeddingClient()) - - -def test_namespace_chunks_and_reuse_key_are_metadata_scoped() -> None: +def test_namespace_chunks_are_metadata_scoped() -> None: chunks = chunk_text_namespace( namespace="skills", path="semgrep.md", @@ -82,21 +11,23 @@ def test_namespace_chunks_and_reuse_key_are_metadata_scoped() -> None: metadata={"language": "markdown", "vulnerability_class": "mapping"}, max_lines=1, ) - snapshot = RepoSnapshot(root="/repo", commit="abc", file_count=1, languages={"python": 1}) assert len(chunks) == 2 assert chunks[0].namespace == "skills" + assert chunks[0].language == "markdown" assert chunks[0].metadata["vulnerability_class"] == "mapping" - assert index_reuse_key(snapshot, "embed-a") != index_reuse_key(snapshot, "embed-b") + assert (chunks[0].start_line, chunks[0].end_line) == (1, 1) + assert chunks[0].chunk_id != chunks[1].chunk_id -def test_retrieval_package_is_bounded_by_character_budget() -> None: - chunks = chunk_text_namespace(namespace="docs", path="README.md", text="alpha\n" + "beta" * 100, max_lines=1) - snapshot = RepoSnapshot(root="/repo", commit=None, file_count=1, languages={"markdown": 1}) - index = build_vector_index(snapshot=snapshot, chunks=chunks, embedding_model="model", client=FakeEmbeddingClient()) +def test_blank_blocks_are_skipped_and_the_language_defaults_to_text() -> None: + chunks = chunk_text_namespace(namespace="docs", path="README.md", text="alpha\n\n\nbeta\n", max_lines=1) - package = build_retrieval_package(role="verifier", index=index, query_embedding=[4.0, 1.0], max_chars=80) + assert [chunk.text for chunk in chunks] == ["alpha", "beta"] + assert chunks[0].language == "text" - assert package.role == "verifier" - assert package.hits - assert package.truncated is True + +@pytest.mark.parametrize(("namespace", "max_lines"), [("unknown", 80), ("docs", 0)]) +def test_invalid_namespace_or_window_is_rejected(namespace: str, max_lines: int) -> None: + with pytest.raises(ValueError): + chunk_text_namespace(namespace=namespace, path="x", text="y", max_lines=max_lines)