← Back to all posts
October 23, 2026·Greta Dolcetti

Tests Pass, Smells Stay: Adding a Quantum Linter to Qiskit IVR

This series has been adding validators to Mellea's Instruct-Validate-Repair (IVR) loop for Qiskit, one gap at a time. The first post used flake8-qiskit-migration to catch deprecated APIs. The second put each benchmark problem's check() test in the loop, so the model could see when its code ran but gave the wrong answer.

This post covers a third gap: code that uses the right APIs, passes its tests, and still contains code smells that might silently impact the program execution.

Code smells that tests don't see

A code smell is code that runs without error but is likely to cause incorrect or unexpected behavior. Python developers have a mature linting culture for these. However, quantum programs have their own class of smells, which classical linters and unit tests are not able to catch: a gate applied after a qubit has already been measured, a register that is allocated and never used, etc.

LintQ is a static analyzer for Qiskit built on CodeQL. It models circuits, registers, and gates as CodeQL abstractions and ships queries for ten quantum-specific smells described in the LintQ paper, including:

LintQ ruleWhat it flags
measure-all-abusemeasure_all() used although a classical register already exists
operation-after-measurementGate applied to a qubit after it was measured
constant-classic-bitA qubit is measured but was never transformed
oversized-circuitRegister with allocated but unused qubits
double-measurementTwo measurements in a row on the same qubit

LintQ is neither sound nor complete, like most linters. But it is deterministic and reproducible, and when it flags something it names the rule and the circuit involved. As the earlier posts showed, that is the property that matters for a repair loop.

Two validators, one requirement

The setup follows the earlier posts: Mellea's m.instruct() with a requirement and a MultiTurnStrategy. The difference is that the requirement's validation function now runs two checks in sequence:

from mellea.stdlib.requirements import req, simple_validate
from validation_helpers import extract_code_from_markdown, validate_correctness

# validate_lintq() is defined in the next snippet


def tests_and_lintq(problem: dict):
    def check(output: str) -> tuple[bool, str]:
        code = extract_code_from_markdown(output)
        passed, reason = validate_correctness(problem, code)
        return validate_lintq(code) if passed else (passed, reason)

    return req(
        "The code must pass the problem's test suite and raise no LintQ warnings",
        validation_fn=simple_validate(check),
    )

simple_validate hands check() the model's latest output as a string, and turns the (passed, reason) tuple it returns into a validation result whose reason becomes the repair feedback.

Correctness goes first, because there's no point linting a program that doesn't run. validate_correctness() executes the code against the problem's own test suite with a 30-second timeout. In order to keep functional correctness as a first order priority, only a program that passes the tests gets linted.

validate_lintq() writes the code to a temporary directory, builds a CodeQL database from it, runs the LintQ-all.qls query suite, and turns the SARIF results into repair feedback, one [rule-id] message line per finding:

import json
import os
import subprocess
import tempfile


def validate_lintq(code: str) -> tuple[bool, str]:
    lintq = os.environ["LINTQ_DIR"]
    with tempfile.TemporaryDirectory() as tmp:
        os.makedirs(f"{tmp}/src")
        with open(f"{tmp}/src/candidate.py", "w") as f:
            f.write(code)
        for cmd in (
            ["database", "create", f"{tmp}/db", "--language=python", f"--source-root={tmp}/src"],
            [
                "database", "analyze", f"{tmp}/db", f"{lintq}/LintQ-all.qls",
                "--format=sarifv2.1.0", f"--output={tmp}/out.sarif",
                f"--additional-packs={lintq}/qlint/codeql/src:{lintq}/qlint/codeql/lib",
            ],
        ):
            subprocess.run(["codeql", *cmd], check=True, capture_output=True)
        with open(f"{tmp}/out.sarif") as f:
            results = [r for run in json.load(f)["runs"] for r in run["results"]]

    if not results:
        return True, ""
    findings = "\n".join(f"[{r['ruleId']}] {r['message']['text']}" for r in results)
    return False, f"LintQ warnings:\n{findings}"

Wiring it into Mellea is the same m.instruct() call as before. Each problem gets its own session with a fresh ChatContext, so one task's repair conversation never leaks into the next:

from mellea import start_session
from mellea.stdlib.context import ChatContext
from mellea.stdlib.sampling import MultiTurnStrategy

with start_session(backend_name="<backend>", model_id="<model-id>", ctx=ChatContext()) as m:
    result = m.instruct(
        problem["prompt"],
        requirements=[tests_and_lintq(problem)],
        strategy=MultiTurnStrategy(loop_budget=3),
        return_sampling_results=True,
    )

Results

We ran gpt-oss-120b on the hard variant of Qiskit HumanEval, where the prompt is a natural-language instruction with no code stub, so the model also has to write the function signature. We also ran it on the full Quantum Katas set. The loop budget was three attempts: the first generation plus up to two repairs. "First pass" is generation 0 on its own; "post repair" is the outcome of the whole loop.

To reproduce these numbers, this is the setup we used:

ComponentVersion or setting
Modelgpt-oss-120b, served through an OpenAI-compatible endpoint and called with Mellea's OpenAIBackend
StrategyMultiTurnStrategy(loop_budget=3)
Python3.12.8
Mellea0.7.0
Qiskitqiskit 2.4.2, qiskit-aer 0.17.2, qiskit-ibm-runtime 0.45.1, qiskit-ibm-transpiler 0.18.0
CodeQL CLI2.26.3
IBM Quantum accountSaved using (QiskitRuntimeService.save_account(...))

Note: LintQ only runs on programs that pass their tests. If the "Flagged by LintQ" or "LintQ warnings raised" count goes up after repair, it's only because the loop fixed correctness for more programs, so more of them reached LintQ. It doesn't mean repair made the code smellier.

Qiskit HumanEval (hard), 151 tasks

MetricFirst passPost repair
Passed both validators24/151 (16%)70/151 (46%)
Failed127/151 (84%)81/151 (54%)
Failed the test suite11377
Flagged by LintQ144
LintQ warnings raised174
Fixed by the repair loop—46

Qiskit Quantum Katas, 350 tasks

MetricFirst passPost repair
Passed both validators25/350 (7%)163/350 (47%)
Failed325/350 (93%)187/350 (53%)
Failed the test suite297148
Flagged by LintQ2839
LintQ warnings raised3047
Fixed by the repair loop—138

With a budget of three attempts, the share of tasks that are both correct and smell-free went from 16% to 46% on Qiskit HumanEval (hard) and from 7% to 47% on Quantum Katas.

Takeaways

The three posts in this series use three kinds of validator, and each one catches problems the others miss:

ValidatorCatchesMisses
flake8-qiskit-migrationDeprecated and removed APIsBehavior
Functional tests (check())Wrong answers, crashesQuantum-specific smells
LintQQuantum code smellsBehavior, and intent

None of them required changes to Mellea. Each is a function that returns (is_valid, error_message), wrapped in a requirement. The loop takes care of turning failures into repair prompts. The more validators you stack, the more aspects are checked and enforced during the generation.

A few practical notes if you want to try LintQ in your own loop:

  • Check correctness first. LintQ builds a CodeQL database for every candidate, which takes a few seconds. Skipping that for programs that fail their tests saves time and keeps the repair feedback focused on one problem at a time.
  • Keep the rule ID in the message. [ql-unmeasurable-qubits] Circuit 'qc' has more qubits (1) than classical bits (0) tells the model which rule fired and on which circuit.

Try it

LintQ is a set of CodeQL queries, so it needs the CodeQL CLI and a checkout of the LintQ repository:

  1. Install the CodeQL CLI and put codeql on your PATH (brew install codeql works on macOS). Check it with codeql version.

  2. Clone LintQ and install its query pack dependencies:

    git clone --depth 1 https://github.com/sola-st/LintQ.git
    (cd LintQ/qlint/codeql/src && codeql pack install)
    
  3. Point LINTQ_DIR at the checkout:

    export LINTQ_DIR="$(pwd)/LintQ"
    

This blog post is built as an extension of the Mellea IVR example. The full reproduction code, including the LintQ validator, will be released in the IBM open-source project CodeHelm. Adding LintQ to it means adding one more validation function with the code shown above.