Skip to content

scan -l auto on a language-pinned project replaces the pinned scan dir's artifacts in place (dataset.json, results.json) while meta keeps the pinned language — recoverable by re-scan; the #274 cosmetic-wart ruling's correctness counter-evidence #667

Description

@gadievron

Summary

openant scan -l <other> (and scan -l auto when the pinned language falls below the
language-selection threshold) writes another language's scan into the PINNED language's
directory — replacing that scan's dataset.json and results.json in place — while the
sha-level meta.json still records the pinned language and status: success. The #274
closing comment called the directory aspect "one cosmetic wart, not correctness" and
named the hatch verbatim:

"One cosmetic wart, not correctness: scan -l auto on a pinned project still writes
output under the pinned language's dir, because the output path was resolved from the
stored context."

The stake is state, not destruction: the pinned dir's contents are silently another
language's scan under a record that says otherwise, and the pinned scan's file-level
output is gone until a re-scan rebuilds it. The damage is recoverable — a pinned re-scan
re-adopts the surviving per-unit checkpoints at no LLM cost (executed below as the
recovery control) — so this is a labelling/state defect whose cost is a full re-scan and
a lying record, not lost paid work.

Mechanism

  • resolveProject computes ScanDir from the project's pinned language
    (cmd/resolve.go:38-47internal/config/config.go:450-457).
  • cmd/scan.go:213-217: scanOutput = ctx.ScanDir and scanLanguage = ctx.Language
    are each applied only when unset — a -l flag overrides the scan language but NOT
    the output dir; the --language pass-through is at cmd/scan.go:277-278.
  • cmd/scan.go:448-456: the sha-level meta records ctx.Project.Language (the pinned
    language) regardless of the -l override.
  • With -l <other>, the other language's units replace the pinned dir's dataset and
    results outright. With -l auto, the effective selection decides: above the
    selection threshold the multi-language path writes per-language dirs and merges
    (cli.py:340-361parser_adapter.py:282); below DEFAULT_MIN_FILES = 5
    (core/language_selection.py:26) the tie breaks to a single language
    (core/parser_adapter.py:98) and that language's scan replaces the pinned dir's
    artifacts exactly like -l <other>.
  • Checkpoints accumulate per-unit and survive (analyze_checkpoints/ holds both
    languages' rows); a pinned re-scan adopts its own rows again — which is why the
    recovery is free.

Executed at ad2bb7e

A scratch project (init --full -l python), one successful python scan, then
scan -l auto --full at the same sha (this repo has one file per language — below the
selection threshold — so -l auto selects go only, the below-threshold arm of the
hatch; zero LLM spend, the successful scans talk to a mock 127.0.0.1 server returning
one fixed safe verdict; the full script is below):

$ python3 lang_override_repro.py
[1/7] init --full -l python; python scan (mock LLM) ...
      scan exit=0   meta: language=python status=success
[2/7] the python scan's artifacts, in scans/<sha>/python/:
      dataset.json units:   ['app.py:f']
      results.json unit_ids: ['app.py:f']
[3/7] scan -l auto --full, same sha, same project (below the selection
      threshold: one file per language, so auto selects go only) ...
      scan exit=0   meta: language=python status=success
[4/7] the SAME directory's artifacts after the -l auto scan:
      dataset.json units:   ['main.go:main']     <- the python unit is gone
      results.json unit_ids: ['main.go:main']    <- the python results, replaced
[5/7] checkpoint files accumulate (both languages' rows persist):
      analyze_checkpoints: ['_fingerprint.json', '_summary.json', 'app.py_f.json', 'main.go_main.json']
[6/7] 0 results__*.json archive(s) exist - the replacement left no archive
[7/7] RECOVERY CONTROL: a pinned re-scan (scan --full, mock LLM) ...
      units restored: ['app.py:f']   results.json unit_ids: ['app.py:f']
      analyze POSTs this run: 1 (the overhead call) - the unit itself was re-adopted

Step 4 is the answer to #274's "cosmetic wart": the pinned dir's dataset and results
belong to another language while every status surface says python/success. Step 7 is
the honest scope: the pinned scan rebuilds from its surviving checkpoints — the python
unit is re-adopted, not re-paid — so the cost of the bug is the silent state corruption
and a re-scan, not destroyed LLM spend.

lang_override_repro.py — full script
#!/usr/bin/env python3
"""-l overrides the scan language but not the pinned scan dir - the pinned
language's dataset.json and results.json are replaced in place while meta
keeps saying the pinned language. The recovery control (step 7) shows the
pinned re-scan re-adopting its checkpoints at no per-unit LLM cost.

Run from the repository root (the parent of apps/). Needs: go, git, python3,
network for pip (first run installs the environment). No LLM spend: the
successful scans talk to a mock server started by this script.
"""
import json
import os
import shutil
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

ROOT = os.path.abspath(os.getcwd())
assert os.path.isfile(os.path.join(ROOT, "libs", "openant-core", "pyproject.toml")), \
    "run this from the repository root"

SCRATCH = tempfile.mkdtemp(prefix="langoverride_repro_")
REPO = os.path.join(SCRATCH, "repo")
HOME = os.path.join(SCRATCH, "home")
XDG = os.path.join(SCRATCH, "config")
os.makedirs(REPO); os.makedirs(HOME); os.makedirs(os.path.join(XDG, "openant"))

with open(os.path.join(REPO, "app.py"), "w") as f:
    f.write("def f(x):\n    return eval(x)\n")
with open(os.path.join(REPO, "main.go"), "w") as f:
    f.write("package main\n\nfunc main() {}\n")
for c in (["git", "init", "-q"], ["git", "config", "user.email", "r@r"],
          ["git", "config", "user.name", "r"], ["git", "add", "-A"],
          ["git", "commit", "-qm", "c0"]):
    subprocess.run(c, cwd=REPO, check=True)

POSTS = [0]
VERDICT = json.dumps({"function_analyzed": "def f(x)", "finding": "safe",
                      "reasoning": "repro", "severity": None, "attack_vector": None,
                      "confidence": 0.9, "cwe_id": 0, "cwe_name": None})
class Mock(BaseHTTPRequestHandler):
    def do_POST(self):
        POSTS[0] += 1
        body = json.dumps({"id": "m", "type": "message", "role": "assistant",
                           "model": "c", "content": [{"type": "text", "text": VERDICT}],
                           "stop_reason": "end_turn", "stop_sequence": None,
                           "usage": {"input_tokens": 10, "output_tokens": 10}}).encode()
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
    def log_message(self, *a):
        pass
srv = HTTPServer(("127.0.0.1", 0), Mock)
PORT = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()

CFG = {"$schema_version": 2, "default_llm": "probe",
       "llm_providers": {"probe": {"type": "anthropic",
                                   "api_key": "sk-ant-repro000000000",
                                   "base_url": "http://127.0.0.1:%d" % PORT}},
       "llm_configs": {"probe": {p: {"provider": "probe", "model": "c"} for p in
                                 ("analyze", "enhance", "verify", "report",
                                  "dynamic_test", "llm_reach", "app_context")}}}

def run(args):
    # read-modify-write: init stores active_project in this same file
    cp = os.path.join(XDG, "openant", "config.json")
    cfg = dict(CFG)
    if os.path.exists(cp):
        with open(cp) as f:
            cfg.update(json.load(f))
        cfg.update(CFG)
    with open(cp, "w") as f:
        json.dump(cfg, f)
    env = dict(os.environ, HOME=HOME, XDG_CONFIG_HOME=XDG,
               OPENANT_CORE_PATH=os.path.join(ROOT, "libs", "openant-core"))
    return subprocess.run([EXE] + args, cwd=ROOT, env=env, stdin=subprocess.DEVNULL,
                          capture_output=True, text=True)

def py_dir():
    scans = os.path.join(HOME, ".openant", "projects", "repo", "scans")
    return os.path.join(scans, os.listdir(scans)[0], "python")

def meta():
    scans = os.path.join(HOME, ".openant", "projects", "repo", "scans")
    with open(os.path.join(scans, os.listdir(scans)[0], "meta.json")) as f:
        return json.load(f)

def units():
    return [u["id"] for u in json.load(open(os.path.join(py_dir(),
               "dataset.json")))["units"]]

def result_ids():
    r = json.load(open(os.path.join(py_dir(), "results.json")))
    rows = r.get("results", r) if isinstance(r, dict) else r
    return [row.get("unit_id") for row in rows]

def archives():
    return [f for f in os.listdir(py_dir()) if f.startswith("results__")]

try:
    print("[1/7] init --full -l python; python scan (mock LLM) ...", flush=True)
    EXE = os.path.join(SCRATCH, "openant")
    subprocess.run(["go", "build", "-o", EXE, "."],
                   cwd=os.path.join(ROOT, "apps", "openant-cli"), check=True,
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    run(["init", REPO, "--full", "-l", "python"])
    p1 = run(["scan", "--skip-dynamic-test", "--no-enhance", "--no-report",
              "--no-context"])
    print(f"      scan exit={p1.returncode}   meta: language={meta()['language']} "
          f"status={meta()['status']}")
    print("[2/7] the python scan's artifacts, in scans/<sha>/python/:")
    print(f"      dataset.json units:   {units()}")
    print(f"      results.json unit_ids: {result_ids()}")
    print("[3/7] scan -l auto --full, same sha, same project (below the selection")
    print("      threshold: one file per language, so auto selects go only) ...",
          flush=True)
    p2 = run(["scan", "-l", "auto", "--full", "--skip-dynamic-test", "--no-enhance",
              "--no-report", "--no-context"])
    print(f"      scan exit={p2.returncode}   meta: language={meta()['language']} "
          f"status={meta()['status']}")
    print("[4/7] the SAME directory's artifacts after the -l auto scan:")
    print(f"      dataset.json units:   {units()}     <- the python unit is gone")
    print(f"      results.json unit_ids: {result_ids()}    <- the python results, replaced")
    print("[5/7] checkpoint files accumulate (both languages' rows persist):")
    ck = sorted(os.listdir(os.path.join(py_dir(), "analyze_checkpoints")))
    print(f"      analyze_checkpoints: {ck}")
    print(f"[6/7] {len(archives())} results__*.json archive(s) exist - "
          "the replacement left no archive")
    print("[7/7] RECOVERY CONTROL: a pinned re-scan (scan --full, mock LLM) ...",
          flush=True)
    POSTS[0] = 0
    p3 = run(["scan", "--full", "--skip-dynamic-test", "--no-enhance", "--no-report",
              "--no-context"])
    print(f"      units restored: {units()}   results.json unit_ids: {result_ids()}")
    print(f"      analyze POSTs this run: {POSTS[0]} (the overhead call) - "
          "the unit itself was re-adopted")
finally:
    shutil.rmtree(SCRATCH, ignore_errors=True)
    srv.shutdown()

Why the guards miss it

-l has exactly one consumer on the output side (cmd/scan.go:277-278 passes
--language to Python) and none on the path side: ScanDir is resolved from
project.Language at cmd/resolve.go:38-47 before the flag is consulted, and the
meta's language field is written from ctx.Project.Language at cmd/scan.go:452. No
test in cmd/ combines a -l/scanLanguage override with a pinned project
(rg scanLanguage cmd/*_test.go → 0 hits; the scan tests are
scan_repo_metadata_test.go, scan_repo_name_tier_test.go, scan_repo_url_test.go).
The stale-results archive's fingerprint (core/analyzer.py:573) hard-codes
language="code", so it does not see a language swap either.

Prior art

#274 was closed working-as-intended on 2026-09-06 with the comment quoted above — no fix
landed for the wart (the pinned-language SELECTION fix was PR #406 for #308). This issue
is the executed counter-evidence to the "not correctness" ruling: the hatch silently
replaces the pinned scan's artifacts under a record that keeps its language. #664 (the
sha-level meta clobber) is the sibling that made the record's language-blindness
visible — the two compose: #664 destroys the RECORD, this corrupts the ARTIFACTS'
state. #308 (-l vs --languages reporting) is selection-surface only.

The fix direction: resolve ScanDir from the EFFECTIVE scan language (the flag wins
over the pinned project language) — noting that this alone leaves no-arg step verbs
(report, serve, build-output, via cmd/resolve.go:70-82's
resolveFileArg→pinned ScanDir) resolving the OLD dir, so the step verbs must honour
the override too — or refuse a -l that differs from the pin. Post-hoc relocation
breaks the same step verbs; the archive fingerprint learning its language would make
the replacement at least leave an archive.

Fix-direction: neither — a state/labelling fix: the pinned dir's contents stop being
replaced in place; what the scanner reports is unchanged (the pinned results are
recoverable today by a re-scan that re-adopts the surviving checkpoints).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions