#!/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()
Summary
openant scan -l <other>(andscan -l autowhen the pinned language falls below thelanguage-selection threshold) writes another language's scan into the PINNED language's
directory — replacing that scan's
dataset.jsonandresults.jsonin place — while thesha-level
meta.jsonstill records the pinned language andstatus: success. The #274closing comment called the directory aspect "one cosmetic wart, not correctness" and
named the hatch verbatim:
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
resolveProjectcomputesScanDirfrom the project's pinned language(
cmd/resolve.go:38-47→internal/config/config.go:450-457).cmd/scan.go:213-217:scanOutput = ctx.ScanDirandscanLanguage = ctx.Languageare each applied only when unset — a
-lflag overrides the scan language but NOTthe output dir; the
--languagepass-through is atcmd/scan.go:277-278.cmd/scan.go:448-456: the sha-level meta recordsctx.Project.Language(the pinnedlanguage) regardless of the
-loverride.-l <other>, the other language's units replace the pinned dir's dataset andresults outright. With
-l auto, the effective selection decides: above theselection threshold the multi-language path writes per-language dirs and merges
(
cli.py:340-361→parser_adapter.py:282); belowDEFAULT_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'sartifacts exactly like
-l <other>.analyze_checkpoints/holds bothlanguages' 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, thenscan -l auto --fullat the same sha (this repo has one file per language — below theselection threshold — so
-l autoselects go only, the below-threshold arm of thehatch; 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):
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
Why the guards miss it
-lhas exactly one consumer on the output side (cmd/scan.go:277-278passes--languageto Python) and none on the path side:ScanDiris resolved fromproject.Languageatcmd/resolve.go:38-47before the flag is consulted, and themeta's language field is written from
ctx.Project.Languageatcmd/scan.go:452. Notest in
cmd/combines a-l/scanLanguageoverride with a pinned project(
rg scanLanguage cmd/*_test.go→ 0 hits; the scan tests arescan_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-codeslanguage="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 (
-lvs--languagesreporting) is selection-surface only.The fix direction: resolve
ScanDirfrom the EFFECTIVE scan language (the flag winsover the pinned project language) — noting that this alone leaves no-arg step verbs
(
report,serve,build-output, viacmd/resolve.go:70-82'sresolveFileArg→pinnedScanDir) resolving the OLD dir, so the step verbs must honourthe override too — or refuse a
-lthat differs from the pin. Post-hoc relocationbreaks 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).