Finding code that traces to no need

CLI MCP VS Code Any LSP editor Alpha

Codelinks traces your needs into the source code. This guide asks the opposite question: which functions and methods of your codelinks source files trace to no need? Each one is a candidate for extraneous code, code that no requirement asks for, and a question for review: trace it to the need it implements, justify why it needs none, or remove it.

ubCode answers from a syntactic inventory of each source file: it reads the definitions a parser sees, without compiling or preprocessing the code. Every report also says what that inventory could not see (its blind spots), so a coverage percentage is never read as more than it is. An untraced function is never proof of dead code, and a traced one is not proof that it is tested.

The report is computed when you ask for it: by ubc codelinks coverage, by the MCP tool untraced_symbols, and by the ubc agent commands that compute the gap list when gap = true (step 4). It parses the files of the selected codelinks projects with their symbol inventory, reusing what the last index pass cached. An index pass (a build, ubc check, the language server’s index, ubc agent) computes inventories only for a project that writes a [codelinks_coverage] table, so a project without one pays nothing for them. The editor shows the same report, as of the last index pass: diagnostics on the untraced functions of an open source file, and in VS Code a coverage view (See it in the editor).

Step 1: choose what counts

The policy is the [codelinks_coverage] table of ubproject.toml; an empty table is enough. ubc codelinks coverage also runs without one: it applies the defaults for itself, so you can run the report first and write the table afterwards. It then indexes the project into a cache directory of its own (the table is part of the configuration’s hash), and its universe line says that the defaults applied. The MCP tool and ubc agent need the table: without it the tool answers a hint saying so.

[codelinks_coverage]
projects = ["firmware"]         # unset: every [codelinks.projects.*]
exclude = ["src/vendor/**"]     # files left out, relative to this ubproject.toml
exclude_names = ["^detail::"]   # regular expressions searched in the qualified name
visibility = "public"           # unset: "all"

By default, every function, method, constructor, destructor and operator with a body is a unit (a conversion operator such as operator bool included), test code and generated files are left out, and a marker on a class, namespace or file traces the units inside it. Coverage policy ([codelinks_coverage]) describes the table, and [codelinks_coverage] lists every key with its type and default.

Step 2: run the report

$ ubc codelinks coverage

The project is indexed first. The table then lists, per codelinks project and file, the traced and traced-or-justified percentages, and below each file its untraced and unknown units, for example:

Project firmware: 4 units: 2 traced (1 direct, 1 inherited), 1 justified, 0 unknown, 1 untraced; traced 50.0%, traced or justified 75.0%
   traced   +just.  units  file
    50.0%    75.0%      4  src/brake.cpp
      untraced  cpp:src/brake.cpp#brake::Controller::trim(int)  method  lines 40-52
Total: 4 units: 2 traced (1 direct, 1 inherited), 1 justified, 0 unknown, 1 untraced; traced 50.0%, traced or justified 75.0%

After the totals come the universe the report was computed over (the policy in effect, and how many files each rule left out), the blind spots, the findings, and one line of provenance (see Reference).

  • --codelinks-project NAME (repeatable) reports only the named codelinks projects.

  • --status untraced|unknown|justified|traced (repeatable) chooses which units are listed, and with -f json which units’ rows the links table keeps; the counts always cover every unit.

  • -f json prints the whole report as one JSON object, every unit included, and the links table of every need that traces each unit (see The trace link table).

  • -f lcov prints an lcov tracefile for a coverage service or an editor extension (see Export the report to a coverage service); --status is refused with it.

  • --changed-since REF lists only the units whose code changed since the merge base of HEAD and REF (see List only the code a change touches).

The command exits 0 whenever it produced a report, whatever the report says: it shows the state, and step 4 is the gate. All options are on the CLI reference.

An AI assistant gets the same report through the MCP tool untraced_symbols, which lists the untraced units by default and narrows by codelinks project, path, and the units a change touched (changed_since, as --changed-since), for a project that writes the table. A path that names no discovered source file is refused, naming the nearest directories (or files, when the path names a file) when any is close, and an empty listing says why it is empty.

Step 3: trace or justify each untraced unit

Trace it when it implements a need: put a need reference on it, as for any other codelinks marker (see Marker types).

// @need-ids: REQ_BRAKE_3
void Controller::trim(int offset) {
    // ...
}

Trace it from the requirement when the code should not carry the marker (third-party or generated code, or code you would rather not edit for a link): name the code in a field of the need. Declare the field in [needs.fields] and name it in symbol_fields:

[needs.fields.implemented_by_code]
description = "The code that implements this need"
schema = { type = "array", items = { type = "string" } }
parse_variants = false
parse_dynamic_functions = false

[codelinks_coverage]
symbol_fields = ["implemented_by_code"]

Then give each need the code it traces to, one value per item:

.. req:: The controller trims its output
   :id: REQ_BRAKE_3
   :implemented_by_code: cpp:src/brake.cpp#brake::Controller::trim; c:src/hal/uart.c

A value is a unit’s symbol ID (see Symbol IDs), the ID without its signature for every overload, a class’s, namespace’s or module’s ID for the units inside it, <language>:<path> for the units of a file, or a regular expression between two / searched in every unit’s and declaration’s ID. The report credits each one as a marker on that code would: an ID or an ID without its signature traces its units directly, and a class, file or regular expression traces its units as traced_inherited. A value that names nothing, or is no selector, is a finding at the need. Sphinx-Needs sees an ordinary field, and only the coverage report reads the values: builds, ubc check and the editor do not check them. The rules, and what a list field’s separators rule out, are under Requirement links.

Justify it in the source when it needs no need: a comment with codelinks-exclude:, a category and a reason.

// codelinks-exclude: debug: dumps the controller state over the debug UART
void Controller::dump() const {
    // ...
}

Like @need-ids:, the marker is found anywhere in a comment, not only at its start. A justification attaches to code as a need reference does: written on the lines before a definition, after code on its own line, inside its body, or as its documentation comment. On a class or namespace it justifies the units inside it (unless inheritance = "strict"). The category is one of the fixed categories, and the reason is required. A marker followed by no category, an unknown one, or no reason justifies nothing. The coverage report lists it as a malformed_justification finding, and a build that reads the file through src-trace reports it as the exclusion_category or exclusion_reason warning (codelinks.exclusion_category, codelinks.exclusion_reason; see Real-time diagnostics). justification_markers sets other markers. Builds, ubc check and the editor read the markers only for a project that writes the table; without it the comment is read as any other line, except by ubc codelinks coverage, which reads the default marker when it applies the defaults.

Justify it in a ledger when the code cannot carry a comment, such as third-party or generated sources. Name a TOML file with ledger:

[codelinks_coverage]
ledger = "coverage-justifications.toml"

and key each justification by the unit’s symbol ID (see Symbol IDs):

["c:src/hal/uart.c#uart_debug_dump"]
category = "debug"
reason = "Diagnostic dump, compiled into debug builds only."
author = "J. Doe"     # optional
date = 2026-10-27     # optional: a TOML date, or a string

An optional hash records the unit’s content when you justified it: copy one of the unit’s content_hashes from the JSON report (32 hexadecimal digits, in either case), and the report says when the unit has changed since (a justification_changed finding). The hash is taken over the unit’s tokens, leaving out its comments and its own name: editing a comment or a marker, reformatting the code or renaming the unit leaves it unchanged, and any other change to the unit’s code changes it. ubc diff compares the same hash between two revisions for every unit a need traces, and lists the needs whose code changed (Code changes since linked). A ledger that cannot be read or parsed, or an entry with an empty reason or a hash that is not 32 hexadecimal digits, is an error.

Step 4: gate a release on it

To make untraced code block a release, opt in:

[codelinks_coverage]
gap = true

ubc agent gaps then lists each untraced unit as a code_untraced gap, whose id is the unit’s symbol ID, type its kind and link its codelinks project, and ubc agent release-check fails while any is open (see Gating and coverage). An unknown unit, whose definition holds a parse error, is never a gap, whatever its markers: the blind spots’ unknown_units count is how many units the gate cannot see this way. To gate only new untraced code, accept the current units in a --baseline file, a JSON object whose keys are <symbol ID>|code_untraced|<codelinks project>, each with the value 0:

{
  "cpp:src/brake.cpp#brake::Controller::trim(int)|code_untraced|firmware": 0
}

gap is off by default because the inventory is syntactic: a definition it does not see is in no gap at all, so a green gate should be something your project chose.

List only the code a change touches

In a code base that is mostly untraced, the question for a change is narrower: did this change add or edit code that traces to no need? --changed-since answers it:

$ ubc codelinks coverage --changed-since origin/main

Only the units whose code changed are listed, and a line after the totals counts them:

Total: 31 units: 22 traced (19 direct, 3 inherited), 2 justified, 0 unknown, 7 untraced; traced 71.0%, traced or justified 77.4%
Changed since origin/main (merge base 1a2b3c4d5e): 2 files, 3 units: 0 traced (0 direct, 0 inherited), 0 justified, 0 unknown, 3 untraced; traced 0.0%, traced or justified 0.0%

The change is the working tree (staged and unstaged edits, and untracked files) against the merge base of HEAD and the reference, the commit your branch started from: commits the reference gained since then are not part of your change. On the reference’s own branch, only the uncommitted edits are in scope.

A unit is in scope when a changed line falls in its definition or in the lines just above it, where its markers, documentation and justification sit, so deleting the marker above a function puts that function in scope. The other counts (totals, projects, files) still describe every unit, and -f json adds a changed_since object with the reference, the merge base and the scope’s counts. With -f lcov the tracefile holds only the units in scope: the coverage of the change, for a review. The change scope lists what is in scope and what is not.

It needs git and the merge base in the clone. A CI checkout is often shallow and lacks it: fetch the history first (fetch-depth: 0 with actions/checkout), or the command stops with an error saying so. A reference that names no commit, or a project outside a git work tree, is an error too (exit 2).

The release gate (step 4) is not narrowed by it: to gate only new untraced code there, use the --baseline of step 4. The MCP tool takes the same scope as its changed_since parameter.

Export the report to a coverage service

-f lcov prints the report as an lcov tracefile, the format coverage services and editor coverage extensions read:

$ ubc codelinks coverage -f lcov > trace.lcov

Each unit is one function of the tracefile, named by its symbol ID, and it is hit when it is traced or justified: a file’s function share is its traced-or-justified percentage. The hit count of a traced unit is the number of needs tracing it, so a coverage viewer that prints counts as hits reads “traced by 2 needs”. Each unit also has one line record at the first line of each of its definitions, since most readers show line records only. The record rules are in The lcov tracefile.

The tracefile holds every unit of every counted file of the selected codelinks projects (--codelinks-project narrows them, and --status is refused, since the totals are the denominators). It is a projection of the report, not the report: files without units, the blind spots and the findings have no lcov record, so read -f json or the table for those.

Its paths are the report’s own, relative to the directory of ubproject.toml (../fw/src/a.c for a source directory beside it), the same base as the symbol IDs. Run a reader from that directory, or tell it the base:

  • genhtml (lcov): genhtml -o trace-html trace.lcov, run from the directory of ubproject.toml, which it reads the sources from. A report with no unit (or, with --changed-since, a change that touched none) gives an empty tracefile, on which genhtml fails (lcov 2.0 still does with --ignore-errors empty): skip the step when the file is empty.

  • Codecov: upload the tracefile under a flag of its own (flags: trace in the upload step, or -F trace to the uploader), so its percentage never merges into your test coverage. Do not merge it into a test tracefile with lcov -a either: its records keep their own test name, but the merged totals add both. Codecov matches the relative paths against the repository’s files; for a project in a sub-directory, a fixes: entry in codecov.yml may be needed.

  • Editor extensions that paint lcov, such as Coverage Gutters, show the line records, one per definition, at the line where it starts.

  • SonarQube reads lcov for JavaScript, TypeScript, Dart and Rust only, and imports it as test coverage, where it would mix with your tests’ figures: keep it out of Sonar, or give it a project key of its own.

See it in the editor

For a project that writes the [codelinks_coverage] table, the language server shows the report in the editor, with an active license (without one it shows nothing, and the VS Code command says a license is needed).

Diagnostics

In an open source file of a codelinks project (one under the directory of the project’s ubproject.toml, see the limit below), each untraced unit has an Information diagnostic on the first line of each of its definitions, naming it and saying how to trace or justify it, and each unknown unit has a Hint. Traced and justified units have none. Their source is ubcode-coverage and their code is the status (untraced or unknown): a status, not a lint code, so lint.ignore does not hide them; the table is what turns them on.

They describe the project as of the last index pass, not the text as you type: they change after you save (with index_on_save, the default, see Server), so a function you have just traced keeps its diagnostic until the save. An edit of the justification ledger (ledger) also shows only after the next save. In VS Code, saving ubproject.toml is enough for an edit of it to show: for a project that writes the table, with an active license and index_on_save, the save starts a full index pass of the project (which every index-backed view then reflects), and the open source files get the new diagnostics when it ends, keeping their current ones until then; removing the table drops them at once.

Two edits can go unnoticed until the next change: a source file rewritten twice within one modification-time tick at the same size, on a file system with a coarse clock (FAT, HFS+, some network drives), and a change to a header the preprocessor-aware engine reads but that is not itself a discovered source file.

VS Code lists the Information diagnostics in the Problems panel and underlines them in the editor; it does not list Hints in the Problems panel, so an unknown unit shows only in the editor, as faint dots under the start of its first line. The editor moves a diagnostic with the text as you type; the Problems panel keeps the line of the last index pass until the next save. Another editor shows them when its client supports pull diagnostics (textDocument/diagnostic, Language Server Protocol 3.17) and starts ubc serve lsp for the source files’ languages too, not only for reStructuredText and Markdown (see Use ubCode from any editor).

The trace coverage view (VS Code)

Run ubCode: Show Code Trace Coverage from the Command Palette in a file of the project, or with the project pinned. ubCode shows the report through VS Code’s test coverage, in a run named “ubCode trace coverage”:

  • the Test Coverage view lists each file with its percentage, and its functions;

  • the editor gutter marks each definition’s lines as covered (traced or justified) or not;

  • the explorer shows each file’s percentage.

Where VS Code says “test coverage” or “statements”, read trace coverage and definitions: a statement is one definition of a unit and a function is a unit, so a file’s function percentage is its traced-or-justified percentage. The first time you run the command, ubCode adds a test controller, so the Testing view appears in the activity bar.

VS Code shows one coverage at a time: a test run with coverage replaces the trace coverage, and a test run without coverage closes it. Run the command again to bring it back. Once you have run the command, an index pass that changed the trace coverage of the project shown shows it again (one more “ubCode trace coverage” entry in Test Results each time), which also replaces a test run’s coverage, even when you had closed the trace coverage. When the trace coverage can no longer be computed (the table removed, no license), ubCode stops following passes but leaves the coverage shown: VS Code closes the shown coverage only by showing another run, whichever coverage that is, a test run’s included, so clear it yourself with Clear Coverage in the Test Coverage view.

Source files outside the project directory

The language server finds a file’s project by walking up from the file to the nearest ubproject.toml, for the coverage diagnostics as for the codelinks marker warnings. A codelinks project whose src_dir lies outside the directory of its ubproject.toml (../src, an absolute path, or a symbolic link to a tree elsewhere) therefore gets no coverage diagnostics in its source files, and the command run from one of them says no ubproject.toml was found. The trace coverage view still shows those files when you pin the project or run the command from a file inside the project directory.

Reference

Units

A unit is a definition with a body whose kind is in kinds (function, method, constructor, destructor, operator) in a counted file of a selected codelinks project.

  • A file counts unless an exclude glob matches it, exclude_generated leaves it out (it is generated, see below), or exclude_tests does (a directory named test or tests, a file named test_* or *_test.*, or Python’s conftest.py and tests.py; for a TypeScript or JavaScript file only, also a name with a .test. or .spec. part such as brake.test.ts, or a directory named __tests__ or __mocks__; ignoring ASCII case). An excluded file is still read for markers, so the needs it defines are unaffected. The report names every file a rule left out, with the first rule that did (universe.excluded_files in the JSON, its reason exclude, generated or test_path), and its universe line counts them per rule.

  • Discovery itself cuts too, and the report says so: with gitignore on (the default), the files .gitignore, .ignore, the global ignore file and the repository’s exclude file leave out are never read (counted as ignored on the universe line and in universe.excluded_counts, listed in universe.ignored_files), and an entry the walk cannot read (permission denied, a dangling symbolic link it follows, a loop) is counted as a walk error and listed in universe.walk_errors.

  • A file is generated when its leading comments, every comment before anything else in the file (a shebang, a coding line or a licence block may come first; an #include or a docstring ends them), describe the file as generated, in any case: they hold @generated; or one line holds generated together with do not edit or do not modify; or a line holds automatically generated, auto-generated or autogenerated followed by by, from or file (Auto-generated from schema.json, AUTOGENERATED FILE), or this file is or this file was followed by generated, auto-generated or automatically generated, or a line holds changes made in this file will be lost (the banner of Qt’s moc and uic). Each rule reads one line: Generated by regtool. with DO NOT EDIT. on the next line is not matched. A comment after the first line of code does not count, nor does one that only mentions generated things, such as the auto-generated register map. A line such as Handles the generated reports. Do not edit thresholds. still matches the second rule.

  • A definition is left out when the language marks it, or a class or module around it, as a test (exclude_tests: a function named after a test macro such as TEST or TEST_F in C and C++, #[test] or a #[cfg(test)] module in Rust, test_* functions and Test* classes in Python; TypeScript and JavaScript mark no test in their syntax, so only the test paths above apply), when visibility = "public" and it is not visible outside its file or type (static in C, no pub in Rust, a leading underscore in Python, a non-public member in C++, where a member defined outside its class takes the access of its declaration in the class; in TypeScript and JavaScript, a private, protected or #name member, where every other member of a class, interface or object literal is public whether or not its container is exported, or a definition that is no member and is neither exported with export nor assigned to module.exports or exports.…), or when its qualified name matches an exclude_names expression.

  • Classes, structs (a typedef struct too), unions, enums, traits, impls, modules and namespaces are never units: they group the units inside them.

  • A function defined inside another (a lambda, a local function) is not a unit of its own: it rolls up into the outermost one, and a marker on it traces that one.

  • Definitions with one symbol ID are one unit with several locations.

  • A marker on a declaration without a body (a prototype in a header) traces the definitions with the same qualified name in the same codelinks project, and the same signature where both have one (a C header is read with the C++ grammar, which gives its prototypes a signature, and a .c file is not): those in its own file when there are any, otherwise those in every file. The IDs’ language does not enter this match, so a prototype in brake.h (whose IDs start cpp:) traces its definition in brake.c (c:). When no unit matches, the marker traces nothing; when no definition matches either, not even one the universe leaves out, it is reported as a declaration_without_definition finding, which lists the units with the same name and another signature. A Rust trait’s required method (fn apply(&self);) has no body either: mark the method in the impl, or the impl block; a marker on the trait block traces the trait’s provided methods only.

Statuses

Each unit has one status, the first that applies:

Status

When

traced_direct

A marker traces the unit itself, a function nested in it, or a declaration of it; or a need’s symbol field names one of these by its ID, or by its ID without the signature.

traced_inherited

A marker on an enclosing class, namespace or module, or a file-level marker of its file, traces it (with inheritance = "containment", the default), or a need’s symbol field names that container or file, or holds a regular expression matching the unit’s ID; via names that container, file, lines <first>-<last> for an unnamed container the inventory does not list, or the regular expression.

justified

A justification covers it: in the source, on an enclosing container, or in the ledger.

unknown

Its definition holds a parse error, so whether a marker belongs to it is not known.

untraced

None of the above.

A marker traces a unit when it defines a need (a one-line or multi-line need) or references at least one need that exists; a reference whose IDs all name no need traces nothing. A need is named by the ID the build gives it: the marker’s own, or, for a marker written without one, the ID generated from its title or body (see markers without an ID), so the report and the needs index name the same need. A one-line need whose ID is empty, or names no need of the index (its file is read by no src-trace) and does not match [needs] id_regex, traces nothing either, and nor does a marker the build makes no need of: a multi-line need with a blank :id:, or one without an ID whose type is unknown or whose project sets [needs] id_required. Each is reported as an invalid_need_marker finding: with the default @ start sequence, documentation prose such as a Doxygen @param a, b line reads as one. ubc codelinks coverage prints the codelinks configuration’s warnings on stderr, such as the one about that start sequence.

Where a marker lands is where the codelinks attachment puts it (see Tracing source code with Codelinks for its rules):

  • On a function, method or container, joined to the inventory by the end of the entity’s name. An unnamed container (an anonymous namespace) is joined by its own lines, and traces the units inside them.

  • At file level, on no entity, for exactly three kinds of comment: the file’s header comment run (the comments before its first item, a blank line before that item; a documentation block only when it says it documents the file, as @file does), a Rust inner doc comment (//!, /*!) outside any module, and a Python module docstring (a string that is the module’s first statement; a string anywhere else at the top level is on nothing). Such a marker traces every unit of the file.

  • On nothing, for every other comment: one before a variable, a constant, a macro or a type with no definitions in it, after the last definition, above an extern "C" block, or after code on a row with no definition. Such a marker traces nothing and is reported as an attachment_without_entity finding at its line (a justification as justification_without_entity).

  • A trailing marker on a data member (int speed_; // @need-ids: REQ_1) attaches to the enclosing class, so it traces the class’s methods (with inheritance = "containment").

  • In a .c file, the units come from the C grammar, while the attachment reads the file with the C++ grammar (with the tree-sitter engine; see below for the other). Where the two disagree (a K&R definition, a C++ keyword such as template used as a name, a statement macro the C++ grammar misreads), the report re-associates the marker with the C definition it is next to: the first one after its comment, with nothing between but comments, blank lines, attributes and the start of its own declaration (no ;, brace or preprocessor line), the one ending on its row before it, or else the innermost one around it. A marker next to no definition is reported as above: it never traces its file or a neighbouring definition. This is the report’s join only: the needs keep the entity the attachment gave them.

  • With the preprocessor-aware engine (analyse.preprocessor), libclang reads the markers, from the active arm of each #if only, and attaches none of them, as sphinx-codelinks does. The report attaches each one itself, by the rules above, on the syntax tree the file’s units come from (for a .c file the C grammar’s, then checked as above), each comment matched to the tree’s comment that starts at the same place. A comment the syntax tree does not read as one (for example a // comment at the end of a #define line, or one whose two slashes a backslash-newline splits, which only the compiler joins) is on nothing, and reported as above. The marker of an inactive arm is not read at all, so it traces nothing and is not reported, while a function defined there is still a unit (see Known limits). This too is the report’s join only: the needs keep no entity.

The percentages count every unit: traced is traced direct plus traced inherited, traced or justified adds the justified units, and both are empty when there are no units.

Symbol IDs

A unit’s symbol ID is <language>:<path>#<qualified name>, followed by <signature> when the language gives the definition a signature, for example cpp:src/brake.cpp#ns::Widget::draw(int) or cpp:src/brake.cpp#ns::Widget::size() const.

  • <language> is the file’s language as the entity’s scope records it (see What is recorded about the entity): c for a .c file, cpp for every other C and C++ file (headers included), rust, python, javascript for a .js, .jsx, .mjs or .cjs file, and typescript for every other TypeScript file (.tsx included).

  • <path> is the file’s path relative to the directory of ubproject.toml, with / separators.

  • <qualified name> is the name within that file (ns::Widget::draw, Shape.area): the path already qualifies it, so it holds no module or package prefix of its own.

  • <signature> is what tells overloads apart in a definition read with the C++ grammar: its parameter types in parentheses, normalised, then the const, volatile, & and && qualifiers of a member function as written ((int, const T&), () const, () &&). noexcept, override and a trailing return type are not part of it. Every C and C++ file has one but a .c file, which the C grammar reads and whose IDs have none (c:src/hal/uart.c#uart_debug_dump). Each type is normalised, so that these spellings of one function give one ID: comments, attributes and parameter names are left out, (void) is (), a const or volatile on the parameter itself is dropped (const int is int, int* const is int*; const int*, int* const* and int* const& keep theirs, which qualify what the parameter points or refers to), any other const and volatile of the type are written first (int const* is const int*), an integer type is spelled as the compiler spells it (unsigned is unsigned int, long int is long, while char, signed char and unsigned char stay three types), an array or a function parameter is a pointer (int[3] is int*, int (int) is int (*)(int)), and struct S is S (class, union and enum alike). Everything else is compared as written: a typedef or an alias (size_t and unsigned long) differs, and so does a qualifier order inside a pointer declarator (int* const volatile* and int* volatile const*).

An ID never holds a line number, so it survives edits elsewhere in the file; it changes when the file, the name or the signature does. A ledger entry for the old ID then no longer matches: the report shows a stale_justification finding, with the new ID as a suggestion when exactly one unit has the content hash the entry recorded. A --baseline key for the old ID no longer matches either: the renamed unit, if untraced, is a new code_untraced gap, which ubc agent release-check reports.

Justification categories

Category

For

deactivated

Code present in the source but switched off in the product.

configuration

Code that only some configurations of the product use.

defensive

Defensive code that cannot be reached when the inputs are valid.

generated

Generated code.

third_party

Third-party code, or software of unknown pedigree.

test_support

Code that supports tests.

debug

Debugging aids.

other

Any other reason, which the reason text states.

Category names are case-sensitive, in a comment and in the ledger: Debug is not a category. The ledger takes the same categories. Its entries are category and reason (required), and author, date and hash (optional); any other key is an error.

Findings

Findings point at markers, ledger entries and needs’ symbol fields worth fixing. No finding changes a status, and findings about markers are reported for counted files only.

Finding

Meaning

stale_justification

A ledger entry names no function of any discovered source file of any codelinks project (renamed, moved or removed code). An entry naming one that the report leaves out (another project, a filter) is not stale.

justification_changed

A ledger entry’s hash differs from the unit’s current content hash: review the justification.

redundant_justification

A justification covers a unit that is traced anyway.

justification_without_entity

A justification comment is attached to no unit or container.

attachment_without_entity

A marker is attached to no definition, or to one the inventory does not have, so it traces no unit.

dangling_reference

A need reference names no existing need, so it traces nothing.

declaration_without_definition

A marker is on a function declaration without a body (a prototype, a trait’s required method) that no file of its codelinks project defines, so it traces no unit; or a need’s symbol field names such a declaration by its ID (reported at the need).

invalid_need_marker

A one-line need’s ID is empty, or names no need and does not match [needs] id_regex (prose read as a need), or the build makes no need of the marker (a blank :id:, or no ID with an unknown type or id_required), so it traces nothing.

source_unreadable

A .c file could not be read again to join its markers to its C inventory, so its markers are joined as the C++ attachment placed them.

malformed_justification

A justification marker is followed by no category, an unknown one, or no reason, so it justifies nothing.

unresolved_symbol_link

A value of a need’s symbol field names no code of any discovered source file of any codelinks project, or is a regular expression matching no function of the codelinks projects counted, so it traces nothing. It is reported at the need, with a suggestion when one is near: the function of the same file whose signature is spelt otherwise (f() for f(void)), else the same name in another file or language (moved code), else the nearest ID; never the value itself.

malformed_symbol_link

A value of a need’s symbol field is no selector: not <language>:<path>, optionally followed by #<name>, nor /<regular expression>/; a language a symbol ID never has; unbalanced parentheses or angle brackets (a signature split by a list separator, see Requirement links); or a regular expression that does not compile. It is reported at the need, and traces nothing.

Blind spots

Every report counts what its inventory could not see:

  • Files without a symbol pack. Inventories exist for C, C++, Python, Rust, TypeScript and JavaScript; a counted file in another codelinks language has no units and is listed instead.

  • Macro invocations at the level of definitions, which may generate functions the inventory cannot see.

  • Suspect statement macros: statement- or loop-like macros the parser mistook for a definition, dropped from the units.

  • Definitions with parse errors, whose units are unknown.

  • Match limits exceeded: files whose inventory stopped early, so it may be incomplete.

  • Anonymous functions (TypeScript and JavaScript only): a function the inventory cannot name (a callback such as a describe or it body, an IIFE, export default () => {}, the value of an object literal’s key: function), or a class’s static { … } block, that no listed function holds, so its code is in no unit. The JSON report and the MCP answer list each one in blind_spots.items, with its line and the source line it starts on; the table prints the count. One inside a listed function (a callback in a method) is part of that function’s unit instead. The count (anonymous_functions) is in the report only when its universe counts a TypeScript or JavaScript file (one the exclude, generated-file and test-path rules leave in); then it is there even at zero.

  • Unknown kinds: a blind spot of a kind this report does not know is kept as unknown_kind, its text naming the kind, never dropped.

Provenance

The JSON report and the MCP answer carry a provenance object, and the table prints it on one line:

  • ubc_version: the ubCode that computed the report;

  • packs: the symbol packs that made its inventories, each with its tree-sitter grammar and that grammar’s version (the TypeScript family has two, both of tree-sitter-typescript: typescript for .ts, .mts and .cts files, tsx for every other extension, JavaScript included);

  • config_hash: a hash of the resolved [codelinks] and [codelinks_coverage] configuration, every path written relative to the project as the report writes paths (../fw/src for one beside it), except one that shares only the filesystem root with the project (/usr/include), which stays absolute, so two checkouts of one commit hash alike (the rest of ubproject.toml is not part of it);

  • id_scheme: the version of the symbol-ID scheme (1): IDs, ledger keys and baseline keys compare only under one scheme;

  • git_head: the commit checked out in the git repository holding the project, null outside git;

  • worktree_dirty: always null: the report does not inspect the work tree, so it cannot say whether uncommitted changes are in it.

It holds no time and no absolute path, so a report computed twice from the same inputs is the same, byte for byte; in another checkout of the same commit only git_head can differ (when one is not a git repository), or config_hash when the configuration names a path that is not at the same place relative to the project.

The lcov tracefile

-f lcov writes one record per file with at least one unit, sorted by path, each in this shape (from lcov’s geninfo tracefile format):

TN:codelinks_trace
SF:src/brake.cpp
FN:12,cpp:src/brake.cpp#brake::Controller::Controller()
FN:40,cpp:src/brake.cpp#brake::Controller::trim(int)
FNDA:1,cpp:src/brake.cpp#brake::Controller::Controller()
FNDA:0,cpp:src/brake.cpp#brake::Controller::trim(int)
FNF:2
FNH:1
DA:12,1
DA:40,0
LF:2
LH:1
end_of_record
  • TN is always codelinks_trace, so a tracefile merged with your tests’ tracefiles (lcov -a) keeps its own test name.

  • SF is the file’s path, relative to the directory of ubproject.toml.

  • FN:<line>,<symbol ID>: one per unit, at the first line of its first definition, sorted by line, then ID. It has no end line, which some readers would read as the name. The name is the symbol ID, exactly; a reader that splits names at : (Coverage Gutters’ parser) shows only its first part, and still paints the line records correctly.

  • FNDA:<count>,<symbol ID>, in the same order. The count is the number of needs tracing a traced unit (traced_direct or traced_inherited), 1 for a justified unit, and 0 for an unknown or untraced one.

  • FNF counts the units of the file, and FNH those with a count above 0, so FNH/FNF is the file’s traced-or-justified percentage.

  • DA:<line>,<count>: one per definition of each unit, at its first line, with the unit’s count, sorted by line; two units that start on one line share its record, with the sum of their counts, so the line is hit when either unit is (FNDA still shows the other as missed). LF and LH count these records; with one definition per unit and no two units starting on one line, they equal FNF and FNH.

  • No branch records, no version record, and no checksums.

A unit claimed by two codelinks projects is one function of its file’s one record, with its best status, as the totals count it. A line break in a path or symbol ID, which lcov cannot hold, is written as a space.

The change scope

--changed-since REF (and the MCP tool’s changed_since) compares the working tree with the merge base of HEAD and REF, through a zero-context git diff that ignores line-ending changes, the untracked files, and the index:

  • A changed line in a definition, or in its leading lines, puts the unit in scope. The leading lines run from the line after the end of the nearest definition or declaration before it (or after the first line of its class or namespace) to the line before it starts. A change on the last line of the previous function is not in the next one’s leading lines.

  • Deleted lines put a unit in scope when they were between two of its lines or just above it, never when they followed its last line.

  • A new, untracked or binary file, or one whose mode changed with no content change, is wholly in scope.

  • A file git does not compare is wholly in scope: an ignored, untracked file (counted when a codelinks project sets gitignore = false), and a tracked file marked assume-unchanged or skip-worktree, whose edits git diff does not show (even when it is unchanged, or when a commit since the merge base changed it).

  • A conflicted file (unmerged, during a merge or rebase) is in scope where the diff shows its conflict hunks, or wholly when the diff does not name it.

  • A rename git records (staged or committed) is in scope only where its content changed. Its symbol IDs changed with its path, so a --baseline misses them: the baseline is the gate for that. A file moved without git mv and not staged is a deleted file plus an untracked one, so it is wholly in scope.

  • A line-ending change alone changes nothing. A file holding a lone carriage return (an old Mac line ending), whose lines git counts differently, is wholly in scope when it changed at all.

  • Files outside the git work tree, in a submodule or in a nested repository are not compared: the scope lists them as outside_repository (the table prints the first ten), and none of their units is in scope.

  • Not in scope: a change outside the unit’s own lines that changes its status, namely a marker removed from an enclosing class, namespace or file header (only the units whose own lines changed are), a marker on a declaration (a prototype in the same file or another one), a justification ledger entry removed or edited, a need deleted or renamed so that a marker’s reference dangles, a need’s symbol field edited in the documentation, and a change to the [codelinks_coverage] table. The --baseline of step 4 catches what these leave untraced, as a new gap.

Known limits

  • The inventory is syntactic: names are resolved as written, with no type information.

  • Generated-file detection reads one comment line at a time: a Generated by X. line followed by DO NOT EDIT. on the next line leaves the file counted, and a hand-written line such as Handles the generated reports. Do not edit thresholds. leaves it out.

  • Code that a macro generates is not seen; its invocation is a blind spot.

  • Both arms of an #if are read, as are inactive preprocessor branches: a function defined in either arm is a unit, and two definitions with one symbol ID are one unit with two locations. The inventory has no preprocessor-aware tier: with the libclang engine (analyse.preprocessor), the markers come from libclang, which drops those of the inactive arms, but the inventory, and where each marker is attached, still come from the syntax tree of the file as written, before preprocessing, as with the tree-sitter engine. So a function defined in an inactive arm is a unit, untraced unless a marker elsewhere traces it. And where the arms of an #if split a function’s header (#if and #else around its signature), the tree builds one function from one arm’s header and the shared body (the #else arm’s, in the shape tested): it is listed under that arm’s name even when that arm is inactive, a marker in its body is credited to it, and the other arm’s function is not listed. Where the arms leave a function’s braces unbalanced, the function can be missing from the units, with no blind spot.

  • In C++, a free function defined with a namespace qualifier (void ns::f() {}) reads as a method.

  • In TypeScript and JavaScript, a function with no name of its own (a callback, an IIFE, export default () => {}) is no unit: it is counted as an anonymous-function blind spot, never dropped silently. A method of an object literal is named on its own (start, not api.start), and so is a method of a class held by a class field, named by the classes around it (Registry.describe for class Registry { static Entry = class { describe() {} } }), so two such methods with one name in a file (start in two object literals, or describe in Entry and in Registry itself) are one unit, and a marker on either traces both; a getter and setter pair is one unit with two locations, and a name exported later (export { f }, module.exports = { f }) reads as not exported, so visibility = "public" leaves it out.

  • In Rust, items a macro generates (macro_rules!, derives, attribute macros) are not seen; in Python, functions created at run time (setattr, metaclasses) are not.

  • A unit is judged by its markers only: the report knows nothing of calls, tests or reachability.

The inventory is measured against the compilers in CI, on 18 third-party files pinned to a release commit each (clang for C and C++, rustdoc for Rust, CPython for Python). Two figures are measured, and they answer different questions:

  • Found: of the functions the compiler defines, how many the inventory lists as a definition at the compiler’s line under the compiler’s own name for the function, its last name segment (escape, operator==, Widget for a constructor). This is whether a function is seen at all.

  • Named as the compiler names it: precision and recall on the full in-file name (nlohmann::detail::escape, ns::Widget::Widget), which is what a symbol ID is built from. A function found under a wrongly qualified name counts as one miss and one false positive here.

On that corpus, at those commits:

Language

Files

Found

Named as the compiler names it: precision

Recall

C

FreeRTOS-Kernel V11.3.3, lwIP 2.2.1 (9 files)

98.1 % (368 of 375)

98.9 % (368 of 372)

98.1 % (368 of 375)

C++

googletest v1.18.0 (3 files)

96.6 % (450 of 466)

99.8 % (450 of 451)

96.6 % (450 of 466)

C++

nlohmann/json v3.12.0 json.hpp

92.7 % (732 of 790)

0 % (0 of 741)

0 % (0 of 790)

Rust

anyhow 1.0.104, regex-syntax 0.8.11 (2 files)

100 % (140 of 140)

100 % (140 of 140)

100 % (140 of 140)

Python

click 8.5.0, attrs 26.1.0, CPython 3.13.17 argparse (3 files)

100 % (389 of 389)

100 % (401 of 401)

100 % (401 of 401)

In no figure: 183 definitions the inventory lists sit in preprocessor branches the compiler skipped (every arm is read, as above), 17 functions are generated by a macro, and 63 Rust test or nested functions are not compared. 14 functions the compiler defines are listed as declarations, which recall counts as misses.

json.hpp opens its namespaces with a macro, which the inventory does not see, and past that macro it also loses some class and namespace qualifiers: it finds most of that file’s functions but names none of them as the compiler does. Python’s precision and recall also count click’s 12 @overload stubs, which the compiler records as declarations; Found counts definitions only, so it reads 389 where the other two read 401. Other code can differ: these are measurements on this corpus, not a guarantee.

Where a marker is attached is measured on the same corpus, for markers in documentation comments, whichever mechanism attaches them (most are attached as doc_comment). Every documentation comment the compiler gives to a declaration (clang’s comment owner for C and C++, the item rustdoc gives the comment’s text to for Rust, the module, class or function a docstring belongs to for Python) gets a reference marker on its first line, and the entity the marker is attached to is compared with that declaration, by the line of its name. 17 comments are left out, in json.hpp and googletest: clang reads them as documentation, but ////, /*** and /**/ open plain comments here. Some declarations are no unit, such as a data member, a variable, a type alias or a class’s forward declaration; a marker above one is meant for the entity around it.

  • Precision is, of the markers attached to an entity or to the file, how many are on the compiler’s declaration. A marker meant for the entity around its declaration and attached there counts in neither figure; attached to any other entity, it counts against precision.

  • Recall is, of the comments whose declaration is a unit, a class with a body, a namespace or the file, how many reach it. A marker attached nowhere counts against recall only.

Language

Files

Doc comments

Attached to the compiler’s owner: precision

Recall

C

FreeRTOS-Kernel V11.3.3, lwIP 2.2.1 (9 files)

190

100 % (124 of 124)

98.4 % (124 of 126)

C++

googletest v1.18.0 (3 files)

0

C++

nlohmann/json v3.12.0 json.hpp

669

91.3 % (418 of 458)

83.4 % (418 of 501)

Rust

anyhow 1.0.104, regex-syntax 0.8.11 (2 files)

121

100 % (94 of 94)

100 % (94 of 94)

Python

click 8.5.0, attrs 26.1.0, CPython 3.13.17 argparse (3 files)

186

100 % (186 of 186)

100 % (186 of 186)

259 of the comments document a declaration no unit is made of, such as a data member, a variable, an enumerator, a type alias, a class’s forward declaration, or a Rust field or variant: 212 of their markers are attached to the entity around it or to the file (in neither figure), 0 to another entity (counted against precision), and 47 to none (in neither figure).

googletest’s three files hold no documentation comment. Of the 83 comments json.hpp loses, 40 are attached to another entity, 39 of them as enclosing, inside an entity around the declaration (a namespace, a class or a function); the other 43 are attached to nothing (templated friend operators, constexpr members and conversion operators among them). The gate’s snapshot lists each one. Plain comments, the input of the leading, trailing and enclosing mechanisms, have no compiler to compare with, and are held by fixed examples instead.