Files
Akshay K 304c81a8ce static-analysis: make the codeql skill's guards real and add tests (#225)
* fix(codeql): make the skill's guards real, add tests, trim the prose

Every verification step piped its command to a formatter and used the
pipeline's exit status, which without pipefail belongs to the formatter,
not the command. Nine sites. The sharpest was the arm64e detection:
`EXIT_CODE=$?` after `| tee` compared against 137, a value it could
never hold, so the check underpinning Essential Principle #5 and three
Rationalizations could not fire.

The build workflow now runs check_db_quality.py as its Step 4 exit
condition rather than describing metrics it never compared to a
threshold, and suite generation aborts on zero resolved queries. Two
blocks that were invalid bash — an else branch containing only comments
— are fixed.

Log helpers move to scripts/build_log.sh, sourced by the workflow and by
the three reference docs that use run_logged. They were previously
defined in one markdown file and used from three others, so those blocks
failed standalone. As the skill's first .sh file it is also the first
thing here that `make shell` lints. Sourcing it now checks the log is
writable: under pipefail an unwritable log made tee's failure the
pipeline's, so run_logged returned 1 for a build that succeeded and the
method ladder walked to --build-mode=none blaming CodeQL.

Suite generation moves to scripts/generate_suite.sh, which takes the
mode as its argument. Both modes had been copy-pasted into two reference
docs sharing some 25 lines of identical scaffolding — guards, the
third-party pack loop, the excludes, the verification call — none of it
lintable where it sat. The tests now run the script instead of
extracting bash from markdown, and one of them fails if either doc
inlines a generation block again.

check_db_quality.py counts project files under the source root recorded
in codeql-database.yml instead of against a hardcoded prefix list that
only knew where macOS keeps its toolchain. src.zip stores each file at
its absolute path minus the leading separator, so the recorded root is a
prefix of the project's entries and of nothing else — verified against a
database built by CodeQL 2.25.6. It also resolves the summary-versus-
severity preference per extractor: a single `extractor-failures: 0` used
to suppress the fallback for every other language in the database.

run-analysis.md had its own database discovery loop, which SKILL.md
states the workflows do not restate. It also lacked SKILL.md's
`codeql resolve database` filter, so a marker file left by a failed
build could be selected as a database. It now uses the canonical block.

Twenty-odd snippets across language-details, threat-models, and
performance-tuning created and analysed a literal `codeql.db` in the
working directory — the exact shortcut SKILL.md lists as a
Rationalization to Reject, and one its Success Criteria forbid. They use
"$DB_NAME" under $OUTPUT_DIR.

Adds six hermetic test suites needing no CodeQL install, plus the first
tests that execute build_log.sh rather than reading it. They cover the
shell in every markdown block, both suite generators, the quality
thresholds, the .qls templates, and the exit-status claim the build
ladder rests on.

Corrects the run-all coverage figures against cpp-queries 1.8.0: the
pack holds 515 alert queries, not 510, and run-all leaves 307 of them
unrun, not 302 — 13 of those are Security/CWE queries, which the prose
described as harmless refactoring metrics.

Trims the skill's markdown from 2867 lines to 2748, and SKILL.md from
269 to 257. Out: the When NOT to Use section that #216 dropped
repo-wide, five Essential Principles that restated their own
Rationalization, two literal prompt mock-ups, three Reference Index rows
duplicating the workflow table above them, three identical qlpack.yml
blocks, and code fences in performance-tuning and threat-models that
each carried a single flag.

Replaces three approval prompts with one confirmation gate, and drops
allowed-tools entries for tools that no longer exist.

Bumps static-analysis to 1.3.0.

* fix(codeql): address review findings on shell-block scope and suite claims

Each markdown block runs in a fresh shell, so scalars and sourced functions
cross it no better than arrays do. build-database.md now says to re-source
build_log.sh and re-set DB_NAME in every block using run_logged: without it
run_logged exits 127, the method ladder reads that as a failed build method,
and it walks to --build-mode=none having never invoked CodeQL. run-analysis.md
Step 4 re-establishes DB_NAME, RAW_DIR and SUITE_FILE for the same reason --
under set -u it aborted with "unbound variable" before analysis started.

create-data-extensions.md was the third caller of database discovery and still
used a bare find for codeql-database.yml, which selects a marker left by a
build killed mid-run; the queries then return nothing and Step 3 reports
coverage as adequate. It now uses find_databases.sh like the other two.

quality-assessment.md sources build_log.sh in the Collect Metrics block, so a
failed quality gate is recorded rather than silently dropped by a
command-not-found, and the raised-threshold override logs inside the if -- it
previously wrote "raised to 15%" even when the re-run still failed.

generate_suite.sh no longer describes the run-all suite as every security,
experimental and quality query: it imports two suites totalling 219 of the
pack's 515 alert queries, as run-all-suite.md documents. Changed in the doc
template too, which test_generation_scripts.py pins to the script's output.

The two remaining review findings were already fixed on this branch and needed
no change: quality-assessment.md assigns ERROR_RATIO from the script's JSON
before reading it, and extractor_error_count() resolves the summary-versus-
severity preference per extractor rather than once for the tree.

* style(codeql): trim comments in the shared shell scripts

build_log.sh carried more comment than code. The consequence of an
unwritable log -- the ladder walking to --build-mode=none after a build
that succeeded -- was stated twice within ten lines; it is stated once
now. find_databases.sh and generate_suite.sh get the same treatment.

Comments only: the diff contains no code lines. 435 tests pass unchanged.

* feat(codeql): ship /static-analysis:codeql-build as a dynamic workflow

The build is the part of this skill with real judgement and no user in it:
try a method, read the failure, apply a fix, retry, escalate. That loop now
runs unattended as workflows/codeql-build.js, beside the four workflows
already on main.

Three phases. Detect resolves the output directory and profiles the language,
build system and macOS arm64e state. Build walks the ladder. Assess runs
check_db_quality.py and applies the improvements from quality-assessment.md
before re-running it.

The ladder is deterministic and lives in the script; diagnose-fix-retry for a
single rung lives in that rung's agent, where the build output it has to read
already is. The agent is told not to escalate itself -- the caller owns the
order, so a rung that fails is a result rather than a licence to try something
else. Go and Swift never reach Method 4, which they reject outright; an
arm64e Mac starts at 2m rather than spending two rungs to reach the same
SIGKILL; an interpreted language does one extraction and no ladder at all.

Nothing is asked. Every method failing returns no-method-succeeded, and a
database that built but sits below the quality threshold returns
built-below-threshold with its metrics. Whether the remaining extractor errors
are confined to code nobody needs analysed is the caller's call, so the assess
phase is told not to raise --max-error-ratio to make its own gate pass.

A build command exiting 0 is not a database: every rung is confirmed with
codeql resolve database before it counts, because finalize after a failed
trace-command leaves one that resolves and holds nothing.

tests/codeql_build_harness.js compiles the workflow with stubbed agents and
asserts the ladder and the guards; --self-test mutates it six ways and requires
every mutation to turn a scenario red. run_codeql_build_tests.sh wraps both so
CI's existing shell-suite discovery runs them, with no workflow file changes.

SKILL.md documents it as the unattended alternative and keeps the manual path.
Database selection, analysis planning and data extensions stay in the session.

* refactor(codeql): stop the docs recomputing what check_db_quality.py reports

Collect Metrics parsed baseline-info.json with an inline python3 -c into
BASELINE_LOC, then read .baseline_loc out of the checker's JSON into DB_LOC
in the same block -- the same number, computed two ways, logged twice under
two labels. The block's own comment said "Everything downstream reads
$QUALITY_JSON rather than recomputing" while the recount sat sixteen lines
above it. The inline parse and the print-baseline call before it are gone;
the checker is the only thing that counts now, and the Quality Criteria table
cites it rather than a command the doc no longer runs.

Log Assessment read five variables out of Collect Metrics' shell. It runs in
its own, so they expanded to empty and the log recorded "Baseline LoC:" with
no number -- the same shape as the dangling ERROR_RATIO the first review
found. It re-sources the helpers and re-reads the metrics, and no longer
prints an expected-file count it cannot see.

test_shell_blocks.py's embedded-python guard required three matches and there
are two now, which is the guard working: removing the last sample of a
construct must fail rather than silently leave the extractor untested. The
floor moves to two, with the reason recorded.

* test(codeql): drop test_suite_resolution.py, which never ran in CI

Its six tests needed the CodeQL CLI and codeql/cpp-queries; CI installs
neither, so every one of them reported as a skip on every run. Confirmed by
reproducing CI's environment locally -- with codeql off PATH the directory
gives 428 passed, 135 skipped, matching the job log exactly. Removing it
leaves 428 passed, 129 skipped, and the remaining skips are per-block
parametrisations of test_shell_blocks.py whose test functions do run for
other blocks.

What goes with it: the only check that resolved the suite templates against
a real CodeQL rather than a fake one. run-all-suite.md's coverage claims --
that run-all is not the whole pack, that important-only reaches queries
run-all does not -- are now prose nobody verifies, so the doc carries the
command to re-derive them instead of pointing at a test file that is gone.
test_generation_scripts.py's docstring no longer claims a sibling covers
real-CLI resolution.

* fix(codeql): Rust supports --build-mode=none; say so

The language table left Rust as "check your CLI — not listed either way" and
the Overview's three categories did not cover it at all, so a reader had no
route for a Rust project and codeql-build.js resolved the ambiguity silently
by putting Method 4 on its ladder.

Settled against CodeQL 2.25.6 rather than the help text: `codeql database
create --language=rust --build-mode=none` exits 0 and writes a database with
`finalised: true`. Go, for contrast, fails immediately with "Go does not
support the none build mode". So `--help` omits Rust the same way it omits
C/C++, which the Overview already warned about; Rust joins that category and
the ladder in codeql-build.js was right.

* refactor(codeql): one prose copy of the quality-gate exit codes, not two

check_db_quality.py's exit contract was written out four times: the script's
own docstring, codeql-build.js's assess prompt, build-database.md Steps 4-5,
and quality-assessment.md's Enforce the Thresholds. The first two earn it --
one is the source of truth, the other is an agent prompt that cannot read a
docstring. The two prose copies are one too many.

build-database.md keeps the part a reader needs at that moment (exit 1 is not
a judgement call, exit 3 is) and defers the table to quality-assessment.md,
which it already links and which is where someone looks for gate detail. The
raised-threshold rationale there loses two lines it did not need.

* refactor(codeql): assess phase reads the exit-code table instead of copying it

The Assess prompt spelled out all four exit codes while its sibling phases
point at a file -- Select says to read rulesets.md rather than choose from
memory, and each build rung is sent to build-fixes.md. It now reads "Enforce
the Thresholds" in quality-assessment.md the same way, keeping inline only the
two facts that decide what it does: exit 1 is not overridable, exit 3 is a
heuristic. That leaves one prose description of the contract instead of two,
and a change to the script's exits reaches the agent without a second edit.

Also aligns the ladder comment with the Rust finding: --help omits C/C++ and
Rust, not just C/C++.

* fix(codeql): pass --format=json, the flag check_db_quality.py defines

The workflow's Assess phase ran `check_db_quality.py --json`. The script takes
--format {text,json}, so argparse exited 2 before reading the database, and 2 is
the one exit code ASSESS_SCHEMA does not describe.

test_script_flags.py checks the class: it reads each script's accepted flags from
its own --help and verifies every invocation across the plugin's .md and .js. The
bug shipped because test_shell_blocks.py scans the skill tree and codeql-build.js
sits outside it.

* test(codeql): scan every block in one test instead of parametrizing over all of them

Seven tests parametrized over all 65 bash blocks, which is 455 cases for seven
assertions, and three of them skipped the blocks that did not qualify. That was
129 skips, and it hid a check that matched no block at all: the unpreserved-pipeline
assertion had never run against the skill.

Each now scans in one pass and lists every offending file:line, so a run reports
all offenders rather than the first. 557 cases down to 118, none skipped. The array
collector's empty case is an assertion rather than a skip.

* refactor(codeql): point the workflow at build-database.md instead of restating it

codeql-build.js carried its own copy of the build procedure: the arm64e detection
block verbatim, the build-system command table, every method's invocation, and the
output-directory logic. Both copies were live, which is how the workflow came to pass
check_db_quality.py a --json flag while the doc had --format=json. Each rung now names
its section of build-database.md, the way Method 2m already pointed at
macos-arm64e-workaround.md.

quality-assessment.md called the checker three times and re-derived its numbers with
jq, unzip and grep. check_db_quality.py now reports archive_files and finalised from
the two files it already reads, so one call covers the whole assessment.

The fresh-shell rule was stated in five places; SKILL.md holds it once and the
workflows link to it with the consequence specific to their site.

test_section_pointers.py checks what this trade depends on: every "Section" in file.md
pointer and every #anchor link must land on a real heading. The repo validator resolves
paths, not section names, so a renamed heading would leave the pointers aimed at
nothing with every check still green.

Prose 1128 -> 1084 lines, codeql-build.js 390 -> 340.

* fix(codeql): address PR review findings on skill paths and database selection

codeql-build.js hardcoded plugins/static-analysis/skills/codeql, which only
resolves in a checkout of this repo. Installed, the first build block sourced a
build_log.sh that was not there and exited 127, so every rung of the ladder
reported a build failure for a project that would have built. The Detect phase now
resolves the directory at runtime from $CLAUDE_PLUGIN_ROOT, $CODEX_PLUGIN_ROOT, or
a find over ~/.claude and ~/.codex, accepting a candidate only when
scripts/build_log.sh exists. skillDir is a required schema field validated as
absolute, so an unresolved path stops the run before the ladder starts.

SKILL.md built FOUND_DBS in one bash fence and looped over it in the next. Each
fence is a separate shell, so the metadata loop iterated zero times and the
selection prompt had no language or creation time to show. The two are now one
block.

run-analysis.md Step 1 branched only on the zero-database case and fell through to
FOUND_DBS[0] for any other count, analysing whichever database find returned first
without telling the user there was a choice. It now uses the elif/else shape from
create-data-extensions.md and exits with an error when DB_NAME is still unset.

Two new checks cover the class rather than the instance. test_shell_blocks.py
fails when a block reads an array it does not build. test_section_pointers.py
fails on a repo-relative plugins/ path in the workflow, and resolves ${SKILL_DIR}
pointers against the skill root so the workflow's file references stay checked:
nine of them now, up from two.

The rest of the review: run-all-suite.md no longer claims total coverage, which
its own measurement section refutes; the analyze block expands optional flags as
${ARR[@]+"${ARR[@]}"}, since bash 3.2 treats an empty array under set -u as
unbound; find_databases.sh exits 2 when codeql is absent instead of printing
nothing, which auto-detection reads as "no databases, rebuild"; make -j$(nproc)
falls back to sysctl -n hw.ncpu on macOS; every . build_log.sh site is || exit 1,
as none of those blocks set -e; the Reference Index lists find_databases.sh and
generate_suite.sh.

* fix(codeql): source build_log.sh in every block that uses its helpers

Each fenced block is its own Bash call, so a helper defined in an earlier
one is undefined and exits 127. The build ladder reads that as a failed
method and walks to the next one, reporting failure for a build that was
never attempted.

test_shell_blocks.py now fails any block that uses run_logged, log_step,
log_cmd, log_result or LOG_FILE without sourcing build_log.sh, with
fixtures pinning the detector in both directions.

* fix(codeql): gate each step of the Method 3 multi-step build

build_log.sh does not set -e, so the four run_logged calls ran regardless of
each other: a failed trace-command still reached finalize, and the resulting
database resolves while holding nothing, which the ladder reads as success.
The steps now chain through if/elif, as 2m-a already does.

test_shell_blocks.py fails an ungated `codeql database finalize`.

* fix(codeql): stop database discovery reporting none when it found some

find_databases.sh resolves each root to an absolute path, so the old
-not -path '*/.*' exclusion also matched dotted ancestors: a checkout
under ~/.cache or ~/.local had every database filtered out, the script
printed nothing and exited 0, and the caller rebuilt from scratch. Prune
dotted directories by name instead, with -mindepth 1 so a root that is
itself dotted still searches.

All three callers read the script through a process substitution, whose
exit status is unobservable. Exit 2 (no codeql on this shell's PATH, a
fresh shell per block) arrived as an empty list and was reported as "No
CodeQL database found" for a project with several. Read it with command
substitution and check the status.

Tests cover the dotted-ancestor case, the dot-directory-below-root case
the fix must not widen into, and a block scanner rejecting a process
substitution around the script.

* chore(static-analysis): bump version to 1.3.1

#231 bumped the plugin to 1.3.0 on main after this branch had already
done the same, so merging main left HEAD and the merge base equal and
the version-increment check failed.

---------

Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
2026-08-11 14:57:14 -04:00

399 lines
19 KiB
JavaScript

// Ships as /static-analysis:codeql-build. Plugin workflows are namespaced by the plugin's
// `name`, so the prefix is always `static-analysis:`; meta.name supplies the rest.
export const meta = {
name: 'codeql-build',
description:
'Build a CodeQL database: detect the language, walk the build-method ladder applying fixes between rungs, and enforce the quality gate',
whenToUse:
'When a CodeQL database needs building and the build may not succeed first try — the ladder escalates autobuild → custom command → multi-step → no-build, diagnosing and retrying at each rung. Pass args as a JSON OBJECT: {"target": "/abs/path", "out": "/abs/path", "lang": "cpp"}. target defaults to cwd; lang is detected when omitted. Returns a status rather than asking anything, so the caller decides what to do when every method fails or the database is below the quality threshold. Database selection and analysis planning stay in the codeql SKILL.md.',
phases: [
{ title: 'Detect', detail: 'language, build system, macOS arm64e, exclusion config' },
{ title: 'Build', detail: 'walk the method ladder, diagnosing and retrying at each rung' },
{ title: 'Assess', detail: 'quality gate, apply improvements, re-run' },
],
}
// The skill directory is resolved at runtime by the Detect phase, not written down here. A
// workflow script cannot read $CLAUDE_PLUGIN_ROOT — there is no environment access — and the
// literal 'plugins/static-analysis/skills/codeql' this used to hold only resolves inside a
// checkout of the marketplace repo. For an installed plugin, under ~/.claude/plugins/…, every
// build block would source a build_log.sh that is not there, and the 127 that follows reads as
// a failed build: four rungs fail for a project that would have built. The probe below is the
// same shape c-review's SKILL.md uses — try the env var, fall back to a find, and validate the
// candidate against a marker file rather than trusting it.
const SKILL_DIR_PROBE = [
'1. Resolve SKILL_DIR: the codeql skill directory, the one holding SKILL.md, workflows/ and',
' scripts/. Every step below and both phases after this one read files under it. Try in',
' order, first hit wins, and accept a candidate only when $SKILL_DIR/scripts/build_log.sh',
' is a file:',
' a. "$CLAUDE_PLUGIN_ROOT/skills/codeql"',
' b. "$CODEX_PLUGIN_ROOT/skills/codeql"',
" c. find ~/.claude ~/.codex . -path '*/static-analysis/skills/codeql/scripts/build_log.sh' -print -quit 2>/dev/null,",
' then strip the trailing /scripts/build_log.sh. The home directories are searched',
' before . so an installed copy wins over one vendored into the project being scanned.',
' Report it as an absolute path. If none resolves, stop with ok=false naming what you',
' searched. Do not guess: a wrong SKILL_DIR fails every rung of the ladder on a missing',
' file, which reads as a project that cannot be built.',
].join('\n')
// args = { target, out, lang }. Prose is parsed too, since a caller passing a string would
// otherwise kill the run on the first line with nothing to show for it.
const parseArgs = (raw) => {
if (!raw) return {}
if (typeof raw === 'object') return raw
if (typeof raw !== 'string') return {}
const text = raw.trim()
if (text.startsWith('{')) {
try {
return JSON.parse(text)
} catch {
// Fall through to key: value parsing rather than dying on a malformed brace.
}
}
const KEYS = ['target', 'out', 'lang']
const out = {}
let key = null
for (const part of text.split(/;\s*|\n/)) {
const m = part.match(/^\s*(\w+)\s*:\s*([\s\S]*)$/)
if (m && KEYS.includes(m[1].toLowerCase())) {
key = m[1].toLowerCase()
out[key] = m[2].trim()
} else if (key && part.trim()) {
out[key] = `${out[key]} ${part.trim()}`.trim()
}
}
return out
}
const input = parseArgs(args)
// What matters is which build modes a language accepts, not whether it is interpreted. Go is
// compiled and has no `none` mode; C# and Java are compiled and do. CodeQL's own `--help` list
// omits C/C++ and Rust, both of which do support `none` (checked on 2.25.6).
const NO_BUILD = new Set(['python', 'javascript', 'typescript', 'ruby'])
const NO_NONE_FALLBACK = new Set(['go', 'swift'])
// The ladder for a compiled language. Method 2m replaces 1 and 2 on an affected Mac: the system
// toolchain is arm64e, CodeQL's libtrace.dylib is not, and macOS SIGKILLs the process (137), so
// trying them first spends two rungs to arrive at the same failure.
function ladderFor(lang, isMacosArm64e) {
if (NO_BUILD.has(lang)) return ['interpreted']
const rungs = isMacosArm64e ? ['2m'] : ['1', '2']
rungs.push('3')
// Go and Swift reject --build-mode=none outright, so Method 4 is not a fallback for them —
// it is a wasted cycle ending in the same "could not create a database".
if (!NO_NONE_FALLBACK.has(lang)) rungs.push('4')
return rungs
}
// Each rung names its section of build-database.md rather than restating the commands.
// Both copies were live before, and they had already drifted: the workflow was passing
// check_db_quality.py a --json flag the script does not define while the doc had it right.
//
// `detail` takes the resolved skill directory rather than closing over a constant, so the
// paths handed to an agent are the ones that exist on the machine the run is on. Each writes
// its target path out in full: `${SKILL_DIR}/workflows/build-database.md` is a pointer
// test_section_pointers.py can resolve and check, and a shared BUILD_DB constant was not.
const METHODS = {
interpreted: {
label: 'interpreted extraction',
detail: (SKILL_DIR) =>
`Run the command under "For Interpreted Languages" in ${SKILL_DIR}/workflows/build-database.md: one \`codeql database create\`, with --codescanning-config pointing at the exclusion config written in the Detect phase.`,
},
1: {
label: 'Method 1: autobuild',
detail: (SKILL_DIR) => `Follow "Method 1: Autobuild" in ${SKILL_DIR}/workflows/build-database.md.`,
},
2: {
label: 'Method 2: custom command',
detail: (SKILL_DIR) =>
`Follow "Method 2: Custom Command" in ${SKILL_DIR}/workflows/build-database.md, using BUILD_CMD from the Detect phase. Mind the quoting note there.`,
},
'2m': {
label: 'Method 2m: macOS arm64 toolchain',
detail: (SKILL_DIR) =>
[
`Follow the sub-method sequence in ${SKILL_DIR}/references/macos-arm64e-workaround.md:`,
'2m-a Homebrew compiler with multi-step tracing, then 2m-b Rosetta x86_64, then 2m-c the',
'system compiler. Stop at 2m-c: 2m-d is "ask the user", and this run reports back instead.',
].join('\n'),
},
3: {
label: 'Method 3: multi-step build',
detail: (SKILL_DIR) =>
`Follow "Method 3: Multi-step Build" in ${SKILL_DIR}/workflows/build-database.md. Every step must succeed before the next runs.`,
},
4: {
label: 'Method 4: no-build fallback',
detail: (SKILL_DIR) =>
`Follow "Method 4: No-Build Fallback" in ${SKILL_DIR}/workflows/build-database.md. A database built this way often fails the quality gate, and that is the honest result rather than a problem to work around.`,
},
}
const DETECT_SCHEMA = {
type: 'object',
required: ['ok', 'skillDir', 'lang', 'target', 'outputDir', 'dbPath', 'isMacosArm64e'],
additionalProperties: false,
properties: {
ok: {
type: 'boolean',
description: 'false when the skill directory did not resolve, CodeQL is missing, or no language could be detected',
},
skillDir: {
type: 'string',
description:
'absolute path of the codeql skill directory, validated by $SKILL_DIR/scripts/build_log.sh existing. Every later prompt is built from it, so a guess fails the whole ladder',
},
lang: { type: 'string', description: 'CodeQL language identifier, lowercase: cpp, java, python, go, …' },
target: { type: 'string', description: 'absolute path of the source root' },
outputDir: { type: 'string', description: 'absolute path of the created output directory' },
dbPath: { type: 'string', description: 'absolute path the database will be built at' },
isMacosArm64e: {
type: 'boolean',
description:
'true only on an arm64 Mac whose libtrace.dylib lacks arm64e while /usr/bin/make has it — the combination macOS SIGKILLs with exit 137',
},
buildSystem: { type: 'string', description: 'make, cmake, gradle, maven, cargo, dotnet, or "" when none was found' },
buildCommand: { type: 'string', description: 'the command Method 2 should pass to --command, or "" when unknown' },
error: { type: 'string', description: 'why ok is false, else ""' },
},
}
const BUILD_SCHEMA = {
type: 'object',
required: ['ok', 'resolved', 'attempts', 'error'],
additionalProperties: false,
properties: {
ok: {
type: 'boolean',
description:
'true only when `codeql resolve database` succeeds afterwards, for the reason given in the prompt. The build command exiting 0 is not enough.',
},
resolved: { type: 'boolean', description: 'the literal result of running codeql resolve database' },
attempts: { type: 'integer', description: 'how many times this method was run, including retries after a fix' },
fixesApplied: {
type: 'array',
description: 'fixes from build-fixes.md that were applied before a retry',
items: { type: 'string' },
},
error: { type: 'string', description: 'the failure, with the last lines of stderr, when ok is false; else ""' },
},
}
const ASSESS_SCHEMA = {
type: 'object',
required: ['exitCode', 'passed'],
additionalProperties: false,
properties: {
exitCode: {
type: 'integer',
description:
'final exit of check_db_quality.py. 0 pass; 1 nothing to analyse, not overridable; 3 error ratio above threshold; 4 diagnostics format changed',
},
passed: { type: 'boolean', description: 'true only when the final exit was 0' },
baselineLoc: { type: 'integer', description: 'from the checker JSON, or -1 if unavailable' },
projectFiles: { type: 'integer', description: 'from the checker JSON, or -1 if unavailable' },
errorRatio: { type: 'number', description: 'from the checker JSON, or -1 if unavailable' },
improvements: {
type: 'array',
description: 'quality improvements applied between runs of the checker',
items: { type: 'string' },
},
},
}
phase('Detect')
const detected = await agent(
[
'Prepare a CodeQL build: locate the skill, resolve where it writes, confirm the tool, and',
'profile the source.',
'',
`Source root: ${(input.target || '').trim() || 'the current working directory'}`,
(input.out || '').trim() ? `Output directory: ${input.out.trim()}` : 'Output directory: choose it as described below.',
(input.lang || '').trim() ? `Language: ${input.lang.trim()} — use it, do not re-detect.` : 'Language: detect it.',
'',
SKILL_DIR_PROBE,
'',
'2. Resolve the source root to an absolute path. Fail with ok=false if it is not a directory.',
'3. Confirm `codeql --version` succeeds. If not, stop with ok=false — there is nothing to',
' profile if the database cannot be built.',
(input.out || '').trim()
? '4. Use the output directory given above. mkdir -p it and resolve it absolutely.'
: '4. Pick the output directory with the block under "Output Directory" in $SKILL_DIR/SKILL.md, leaving USER_SPECIFIED_DIR unset so it auto-increments. mkdir -p and resolve it absolutely.',
' Set dbPath to <outputDir>/codeql.db.',
'',
'5. Detect the language by counting source files, and report the CodeQL identifier for it',
' (cpp, csharp, go, java, javascript, python, ruby, swift). CodeQL builds one language',
' per database. $SKILL_DIR/references/language-details.md has the mapping.',
'',
// Section name and path stay on one line in each of these: test_section_pointers.py reads
// `"Section" in <path>.md` out of the source, and a line break between the two hides the
// pointer from the check that the section still exists.
'6. macOS arm64e check. Run the block under "Step 2a: macOS arm64e Detection" in $SKILL_DIR/workflows/build-database.md',
' exactly as written and report its IS_MACOS_ARM64E. This is the only thing deciding',
' whether the ladder starts at Method 1 or jumps to 2m, so do not approximate it.',
'',
'7. For a compiled language, identify the build system and the command Method 2 should run,',
' from the detection table under "Method 2: Custom Command" in $SKILL_DIR/workflows/build-database.md.',
' Also look for build.sh, compile.sh and the README.',
'',
'8. For python, javascript, typescript or ruby only, write the exclusion config to',
' <outputDir>/codeql-config.yml. Compiled languages do not support it — everything traced',
' is extracted — so do not write one for them.',
].join('\n'),
{ label: 'detect', schema: DETECT_SCHEMA },
)
if (!detected || !detected.ok) {
const why = (detected && detected.error) || 'the detect phase returned nothing'
return { status: 'detect-failed', error: why, dbPath: '', method: '', attempts: [] }
}
const lang = String(detected.lang || '').trim().toLowerCase()
if (!lang) return { status: 'detect-failed', error: 'no language was detected', dbPath: '', method: '', attempts: [] }
// skillDir is in this list for the same reason as the rest: a relative path here resolves
// only when the run happens to start in the right directory, which is the failure the probe
// exists to prevent, and it would surface as four unexplained build failures rather than as
// a missing skill.
for (const [name, value] of [
['skillDir', detected.skillDir],
['target', detected.target],
['outputDir', detected.outputDir],
['dbPath', detected.dbPath],
]) {
if (!value || !String(value).startsWith('/')) {
return { status: 'detect-failed', error: `detect returned a non-absolute ${name}: ${JSON.stringify(value)}`, dbPath: '', method: '', attempts: [] }
}
}
const SKILL_DIR = String(detected.skillDir)
const ladder = ladderFor(lang, Boolean(detected.isMacosArm64e))
log(
`${lang} at ${detected.target}, database ${detected.dbPath}` +
`${detected.isMacosArm64e ? ', macOS arm64e — starting at Method 2m' : ''}` +
`${detected.buildSystem ? `, build system ${detected.buildSystem}` : ''}`,
)
log(`ladder: ${ladder.map((r) => METHODS[r].label).join(' → ')}`)
phase('Build')
// The ladder is deterministic and lives here; the diagnose-fix-retry loop for one rung lives
// inside that rung's agent, where the build output it has to read already is.
const attempts = []
let built = null
for (const rung of ladder) {
const method = METHODS[rung]
const result = await agent(
[
`Build the CodeQL database with ${method.label}.`,
'',
` OUTPUT_DIR=${detected.outputDir}`,
` DB_NAME=${detected.dbPath}`,
` CODEQL_LANG=${lang}`,
detected.buildCommand ? ` BUILD_CMD=${detected.buildCommand}` : '',
'',
'Each Bash call is a fresh shell, so start every block by re-establishing those and',
`re-sourcing the helpers: . "${SKILL_DIR}/scripts/build_log.sh" || exit 1. Without them`,
'run_logged is "command not found" and exits 127, which reads as a failed build rather',
'than a missing function — and the `|| exit 1` matters for the same reason, since the',
'helpers are not defined when an unwritable log makes the source fail.',
'',
method.detail(SKILL_DIR),
'',
'If it fails, diagnose before giving up. Read the build output, then consult',
`${SKILL_DIR}/references/build-fixes.md — clean existing state, clean the build cache,`,
'install missing dependencies, handle private registries — apply the ones the output',
'actually points to, and retry this method once. Do not move to a different build method:',
'the caller owns the ladder and will escalate.',
'',
'Then verify: `codeql resolve database -- "$DB_NAME"`. Report ok=true only when that',
'succeeds. A build command exiting 0 is not sufficient on its own — finalize after a',
'failed trace-command leaves a database that resolves and holds nothing, so if you ran',
'Method 3 confirm each step succeeded too.',
'',
'Report the fixes you applied and the last lines of stderr on failure. A method that',
'failed is a result to return, not a problem to work around.',
]
.filter(Boolean)
.join('\n'),
{ label: `build:${rung}`, schema: BUILD_SCHEMA },
)
const ok = Boolean(result && result.ok && result.resolved)
attempts.push({
method: method.label,
ok,
attempts: (result && result.attempts) || 0,
fixesApplied: (result && result.fixesApplied) || [],
error: ok ? '' : (result && result.error) || 'the build agent returned nothing',
})
log(`${method.label}: ${ok ? 'database resolves' : 'failed'}`)
if (ok) {
built = rung
break
}
}
if (!built) {
// Every rung failed. Returned rather than asked: the caller decides whether to change the
// build, install a toolchain, or stop.
return {
status: 'no-method-succeeded',
lang,
target: detected.target,
outputDir: detected.outputDir,
dbPath: '',
method: '',
attempts,
error: `every build method failed for ${lang}`,
}
}
phase('Assess')
const assessed = await agent(
[
'Run the quality gate on the database that was just built, and improve it if it fails.',
'',
` DB_NAME=${detected.dbPath}`,
` OUTPUT_DIR=${detected.outputDir}`,
'',
` uv run ${SKILL_DIR}/scripts/check_db_quality.py "$DB_NAME" --format=json`,
'',
`Read "Enforce the Thresholds" in ${SKILL_DIR}/references/quality-assessment.md for what`,
'each exit code calls for, rather than working it out from the number. The two that decide',
'what you do here: exit 1 is nothing to analyse and is not overridable, and exit 3 is a',
'heuristic that legitimate partial extraction can exceed.',
'',
'On exit 3 only, try the improvements further down that same file — adjust the source root,',
'force a clean rebuild if the build was cached, install type stubs, set extractor options —',
'then re-run the checker once and report the final exit code, with every improvement applied.',
'',
'Do not raise --max-error-ratio to make the gate pass. Whether the remaining errors are',
'confined to code that does not need analysing is the caller\'s call, not yours; report the',
'ratio and let them decide.',
'',
'Read baseline_loc, project_files and error_ratio from the checker\'s JSON. Use -1 for any',
'the JSON did not carry rather than guessing a number.',
].join('\n'),
{ label: 'assess', schema: ASSESS_SCHEMA },
)
const passed = Boolean(assessed && assessed.passed && assessed.exitCode === 0)
log(
passed
? `quality gate passed (${METHODS[built].label})`
: `quality gate failed with exit ${(assessed && assessed.exitCode) ?? '?'} — the database exists but is below threshold`,
)
return {
// Two success statuses, because a database that built but did not pass is a real outcome the
// caller has to decide about rather than a failure to hide or a pass to claim.
status: passed ? 'built' : 'built-below-threshold',
lang,
target: detected.target,
outputDir: detected.outputDir,
dbPath: detected.dbPath,
method: METHODS[built].label,
attempts,
quality: assessed || null,
logFile: `${detected.outputDir}/build.log`,
}