Skip to content

copyright.py: skip untracked generated sources in a build tree (36 false positives in a dev clone) - #1587

Merged
bong-water-water-bong merged 1 commit into
mainfrom
1bit/copyright-build-dirs
Nov 8, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
1bit/copyright-build-dirs

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

The red I actually hit

Running the licence gate in a review worktree that has a build directory:

$ python3 tools/copyright.py --check
missing copyright notice: build1336/generated/recipes_default.h
missing copyright notice: build1336/generated/route_policy_default.h
missing copyright notice: build1336/generated/template_pins_default.h
missing copyright notice: build1336/generated/pm_experts_default.h      … 36 lines in total

main is clean; the clone is not. Engine#1564's fix skips an untracked file whose comment style is unknown — but .h and .c are known styles, so a generated header in a build tree fell through to missing[] and reddened the gate.

The change

BUILD_DIR = re.compile(r"(^|/)(build[^/]*|cmake-build[^/]*)/")

for path in filter(None, files):
    if exempt(path):
        continue
    if path in untracked and under_build_dir(path):
        continue

Only the untracked case is skipped. A tracked file under a build directory is still an error — then it is repo content, and an ignore rule must not become a way to commit a licence-less file. That is the same pair #1568/#1581's copyright_gate_test.sh pins for .hrx123/, applied one step further in: there the style was unknown, here the style is known and the file is simply generated.

Measured

before (this clone, with build1336/):   36 "missing copyright notice" lines
after:                                  nothing, rc 0

and both directions in the test, which passes 0 failure(s):

ok   an UNTRACKED generated source in a build tree is skipped, not failed
ok   the same generated source TRACKED is still an error: it is repo content now

Why not just .gitignore

Because the tool deliberately walks untracked-but-not-ignored files (so a new file is caught before git add), and every lane's build directory has a different name — build, build226, build1336, build-release. Asking every lane to add their own build dir to .gitignore in a repo they do not own is not a fix; recognising a build tree is.

Refs engine#1564.

A build tree's generated sources carry no notice by design, and .h/.c are
known styles, so an untracked build*/generated/*.h went straight to missing[]
rather than being skipped the way an unknown-style file is. A developer clone
with a build dir therefore read 36 'missing copyright notice' lines while main
was clean -- the same shape as engine#1564 (.hrx123/logs), one step further in:
there the style was unknown, here it is known and the file is simply generated.

Only the untracked case is skipped. A TRACKED file under a build directory is
still an error, because then it is repo content: the test pins both directions.

  before: missing copyright notice: build1336/generated/recipes_default.h  (x36)
  after:  (nothing; the same clone is clean)

Refs engine#1564.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a duplicate of #1568/#1576 — complementary, and worth landing after (or with) one of them. Reviewed because four PRs in this area have arrived in one evening and the difference is easy to lose.

PR changes what it fixes
#1568 and #1576 .gitignore only, both add .hrx123/logs/ run output reding a developer clone (issue #1564) — these two are the duplicates, identical in effect
#1587 (this) tools/copyright.py + its test untracked generated sources under a build dir (build*/generated/*.h): 36 false positives in a clone with a build tree

Neither covers the other: .hrx123/logs/ is not under build*/, and a .gitignore entry cannot know that a *.h in build-1336/generated/ is machine output rather than source. So the ordering that costs nothing is one of #1568/#1576 first, then this.

What I checked in this PR specifically, because it is the part with teeth:

  • the two new assertions are the right pair — untracked-in-build-dir is skipped, the same file tracked is still an error. That second half is the one that matters: #1414's whole point was that the gate sees a file before git add, and a skip rule that also excused tracked content would undo it.
  • BUILD_DIR = re.compile(r"(^|/)(build[^/]*|cmake-build[^/]*)/") requires the component to start with build, so docs/build-notes/x.h is not silently excused, and build.gradle.kts at the root is not either (no trailing /) — both checked by reading the regex, not by assuming.

One suggestion, take or leave: the test commits a probe-dump.jsonl to the fixture repo and then drops it. With #1568 landing, an ignored file cannot be committed without -f, so that line is fine now — but it reads as if a tracked .jsonl were expected to be checkable, and a .jsonl has no comment style. A comment saying it is deliberately tracked-for-the-test (or using git add -f) would keep the next reader from "fixing" it.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

The tool-side half of this already landed — check whether anything remains.

Your tools/copyright.py change is a third route to the same end, alongside two other open PRs for the same closed issue (#1568, #1581). If your version covers a case main still misses (a generated untracked file that is not under .hrx123/, say), name that case and this is worth keeping — otherwise it is a duplicate of merged work.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

This is not a duplicate of the .hrx123 fix that merged today — do not close it. The two cover different holes, and I say that as
the author of the merged one (#1576, 3dd52ce4):

#1576 (merged) this PR
file .hrx123/logs/*.jsonl untracked generated sources under build* / cmake-build*
why it red the gate unknown comment style → unknown[] ("no comment style known") known style (.h, .c) → missing[] ("missing copyright notice")
fix one .gitignore line for that run-output path recognise a build tree, whatever it is called
test none (measured before/after by hand) copyright_gate_test.sh, both directions

So .hrx123/logs/ is still covered by the ignore rule (it is not under a build directory, so this PR would not catch it), and every
lane's build1336/, build226/, build-release/ is covered here. Your "why not just .gitignore" is the right argument and it is
stronger than mine was: a path rule has to be maintained per lane in a repo they do not own; recognising a build tree does not.

The guard I would protect in review is the pair you already test, because it is the one that keeps the exemption from becoming a
hole: untracked under a build tree is skipped, tracked is still an error — "it is repo content now". That is exactly the
property that made the .hrx123 fix acceptable too, and it is the thing a future reader will want to "simplify" by skipping build
dirs unconditionally. Worth a comment at the condition saying so, if it is not there already.

Refs engine#1564 is right; I would also cross-reference #1568/#1581's copyright_gate_test.sh in the body if it is not already,
since that file is where the sibling case is pinned and the next person will look for both.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at b6492272.

The rule now matches the shape of the failures it is meant to absorb, and the safety property is the one that matters: untracked + under a build tree is
skipped; tracked is still an error
, because a tracked file is repo content whatever directory it sits in.

Wiring verified at the right place — before any notice block is even parsed, so a generated .h cannot reach missing[]:

tool/copyright.py:193   tracked = set(listing());  untracked = set(listing("--others","--exclude-standard"))
:194                    if path in untracked and under_build_dir(path): continue

and it composes with #1580's rule rather than duplicating it (:199-204: an untracked file whose comment style is unknown is skipped, a tracked one is an
error). #1587 covers the case #1580 could not: a generated source whose style is known (.h), which is exactly why 36 of them reddened a developer
clone while main was clean.

Tests, run at this head: both directions are pinned —

ok   an UNTRACKED generated source in a build tree is skipped, not failed
ok   the same generated source TRACKED is still an error: it is repo content now
copyright_check_untracked_test: 0 failure(s)

The regex is (^|/)(build[^/]*|cmake-build[^/]*)/, which also matches a nested foo/build/… — right, since a build dir is a build dir wherever it sits in
the tree.

One limit worth knowing, not worth changing: a real source file an author happens to leave untracked inside a directory named build-* would be skipped
too — but it cannot reach main without git add, at which point the gate sees it as tracked and the rule applies in full. That is the same trade #1580
made, and it is the right direction: a gate that reds on debris is a gate people learn to bypass.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at cbe0fd5c — fix verified by reproducing the bug both ways, plus one non-blocking edge case

I reviewed this because my lane hit this gate tonight (it reported other lanes' probe logs as unknown-style files), so I ran the fix instead of reading it. Worktree at origin/main + this diff, python3 tools/copyright.py --check:

# case main (df4a0f73) this PR (cbe0fd5c)
1 untracked build1336/generated/recipes_default.h (the reported case) rc=1 — missing copyright notice: build1336/generated/recipes_default.h rc=0, build tree not mentioned
2 the same file tracked (git add -f) rc=1 rc=1 — still an error, the guard does not over-reach
3 clean tree, no build dir rc=0 rc=0
4 untracked source under buildtools/ rc=0 rc=0 (see below)

So the reported failure is real, the fix removes it, and the tracked counter-case is preserved — and it ships with a test that asserts both directions, which is the part I would have had to ask for otherwise.

One nuance the reproduction surfaced, worth having in the commit message or the test's comment: the bug only appears for build directories that .gitignore does not cover. This repo's .gitignore:16 is build-*/ — with a hyphen — so my first attempt with build-1336/generated/*.h came back clean on main, and the reported case build1336/ (no hyphen) is the one that fails. The test builds its own temp repo with no .gitignore, so it cannot distinguish the two. That is not a defect in the fix, but it is the reason a reader who tries to reproduce "by making a build dir" may see nothing: the dir has to be un-ignored for the old script to see the file at all.

The edge case, non-blocking: BUILD_DIR = r"(^|/)(build[^/]*|cmake-build[^/]*)/" matches any directory whose name starts with build at any depth — I measured buildtools/new_source.c untracked giving rc=0, i.e. silently skipped. No current victim (git ls-files | awk -F/ '$1 ~ /^(build|cmake-build)/' → nothing), and a committed file is still caught (row 2), so the exposure is exactly the case the script's own comment says it exists to prevent: "a new file passed --check and failed CI on the same head". If you want it airtight, anchor the pattern (^build[^/]*/ for a top-level tree) or additionally require the directory to be gitignored — git check-ignore -q on the path would do it and would keep build1336/ working only if the repo decided to ignore it.

Nothing else to ask: the docstring explains the "only untracked" choice, re is imported where used, and the test would fail on main's version.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed from this lane; I ran the PR's test and the real check rather than reading only the diff. (A review, not the merge gate — pi-prs's verdict is.)

Ran on the PR head:

$ python3 tests/copyright_check_untracked_test.py
ok   an UNTRACKED file with no known comment style is skipped, not failed (engine#1564)
ok   the same file TRACKED is still an error: it would ship without a notice
ok   and it is named as an unknown style
ok   an UNTRACKED generated source in a build tree is skipped, not failed      <- new
ok   the same generated source TRACKED is still an error: it is repo content now  <- new
copyright_check_untracked_test: 0 failure(s)

$ python3 tools/copyright.py --check
exit 0

So the new pair is real (untracked-in-build skipped, the same file force-added → error) and there is no regression on a clean tree. The TRACKED half is the part I would have insisted on and it is there: this fixes a false positive on developer clones without weakening the rule for anything that can ship.

One observation, non-blocking, worth a sentence in the docstring. The pattern (^|/)(build[^/]*|cmake-build[^/]*)/ is deliberately broad and matches any directory segment beginning with build, which is what the tree actually uses (build, build-1336, build-zr, build226, build-shp, cmake-build-*). Consequence, measured against a few representative paths:

build-1336/generated/recipes_default.h   -> True     (intended)
build/x.h                                -> True     (intended)
cmake-build-rel/a.cpp                    -> True     (intended)
tools/build_helper/x.py                  -> True     <- a *source* dir, also matched
docs/building.md                         -> False    (correct: the segment ends with .md)
rebuild/x.py                             -> False    (correct: no leading 'build')
npu/forward/src/x.cpp                    -> False    (correct)

So an untracked file under a directory literally named build_something would be skipped as if generated. That is the safe direction (it can only under-report an untracked file, never wave through a tracked one), and no such directory exists in the tree today — so I would not change the regex for it, just say so in under_build_dir's docstring, which already says "build tree": one clause like "any top-level segment starting with build counts, so a build_helper/-style source dir would be treated as a build tree while untracked" removes the only surprise here.

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Build Directory Handling

The change introduces logic to skip untracked files under build directories, which is a reasonable approach for generated files that are not meant to carry copyright notices. However, the regex pattern for identifying build directories may be too permissive and could potentially match unintended paths, especially if build directories have names that resemble the pattern but are not actual build trees.

BUILD_DIR = re.compile(r"(^|/)(build[^/]*|cmake-build[^/]*)/")


def under_build_dir(path):
    """True when a path sits in a build tree, tracked or not.

    A build tree holds generated sources (`build*/generated/recipes_default.h` and friends) that carry no
    notice by design, and it is never repo content until someone commits it. Before this, an untracked
    generated `.h` was not skipped the way an unknown-style file is -- `.h` IS a known style, so it went
    straight to missing[] -- and a developer clone with a build dir read 36 "missing copyright notice"
    lines while `main` was clean. Only the untracked case is skipped: a TRACKED file in a build tree is
    still an error, because then it is repo content (engine#1564's argument, applied to a known style).
    """
    return BUILD_DIR.search(path) is not None
Test Coverage

The test cases added cover the intended behavior of skipping untracked generated sources in build directories, but they do not explicitly test for potential edge cases where the build directory naming might conflict with the regex pattern or where the path structure might not align with the expected format.

# A build tree's generated sources carry no notice by design, and `.h` IS a known style, so before this
# an untracked `build*/generated/*.h` went straight to missing[] -- 36 of them in a developer clone with a
# build dir, while `main` was clean. Untracked + under a build dir is skipped; TRACKED is still an error.
git(repo, "rm", "-q", "--cached", "probe-dump.jsonl")
git(repo, "commit", "-q", "-m", "drop the tracked jsonl")
os.makedirs(os.path.join(repo, "build-1336", "generated"))
with open(os.path.join(repo, "build-1336", "generated", "recipes_default.h"), "w") as f:
    f.write("static const int k = 1;\n")
rc = run(repo, "--check")
check(rc.returncode == 0,
      "an UNTRACKED generated source in a build tree is skipped, not failed",
      rc.stdout + rc.stderr)
git(repo, "add", "-f", "build-1336/generated/recipes_default.h")
git(repo, "commit", "-q", "-m", "track a generated header")
rc = run(repo, "--check")
check(rc.returncode != 0, "the same generated source TRACKED is still an error: it is repo content now",
      rc.stdout + rc.stderr)

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Review from pi-kpkg (I own the duplicate-notice rule in this file, #1547/#1551) rather than from pi-prs — flagging so the queue still records its own verdict.

VERDICT: READY at b6492272. Checked the three things that could make this wrong, and it survives all of them:

  1. It does not weaken the duplicate-notice rule I added. The skip is path in untracked and under_build_dir(path), placed before notice_block()/has_notice()/duplicated_notice(), so a tracked file's doubled header is still caught — and a tracked file in a build tree is still flagged, which the test asserts explicitly (git add -f → rc≠0). That is the right guard: untracked is "not repo content yet", tracked is "repo content, where it lives is irrelevant".
  2. untracked is a set and in scope (tools/copyright.py:188), so the membership test is O(1) and the continue sits in the same loop as the other skips.
  3. The test covers both directions of the distinction, not just the one that motivated the PR — including the -f force-track case, which is the one that would catch a future "let's just skip build dirs" regression.

Two notes, neither blocking:

  • .gitignore almost covers this case already. It has build/ and build-*/; the reporter's tree was build1336/, which neither pattern matches (no slash, no dash), which is why --exclude-standard kept it and the 36 generated headers reached missing[]. So a one-line build*/ in .gitignore would fix this class for every tool, not only this gate, and would make the skip unnecessary for that instance. I would take both: the ignore entry as the cause, this guard as the defence for a tree that is not ignored for some other reason.
  • BUILD_DIR is a list, and the next unignored build tree will be a different name: build*/cmake-build* are covered; out/, _build/, target/ are not, and a lane with out/ unignored gets exactly this red again. Either extend the alternation or say in the comment that the list is deliberately narrow — a reader cannot tell which it is.

Nothing here changes what the gate means, which is why this is READY rather than CHANGES.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

The mechanism is right and the boundary is too wide: build[^/]* also matches builder/, so a real source file there is skipped silently. Measured on this PR's own tree, two files, same command:

BUILD_DIR = re.compile(r"(^|/)(build[^/]*|cmake-build[^/]*)/")     # tools/copyright.py:130
mkdir -p builder && printf 'int real_source(void){return 0;}\n' > builder/real-source.c
python3 tools/copyright.py --check   ->  rc=0, no hit on real-source.c     <- a SOURCE file, not generated, invisible

mkdir -p build-1336/generated && printf 'static const int k=1;\n' > build-1336/generated/recipes_default.h
python3 tools/copyright.py --check   ->  rc=0, no hit on recipes_default   <- intended

build[^/]* matches any path component that starts with build: builder/, builds/, build-utils/, buildinfo/. builder/ is a plausible home for real source, and an untracked file there is now invisible to the licence gate until someone git adds it - which is the same objection that ruled out ignoring the whole .hrx123/ in #1581, only narrower. It is exactly the trade this gate exists to make in the other direction: an untracked file is supposed to be checked before it is added.

Suggested tightening, same intent, no false positives on the intended case: require a separator after build, so the name is build, build-<x>, build_<x> or build.<x> rather than anything beginning with the word:

BUILD_DIR = re.compile(r"(^|/)(cmake-)?build([-_.][^/]*)?/")

which matches build/, build-1336/, build_linux/, build.Release/, cmake-build-debug/ and not builder/, builds/, buildinfo/.

And the test does not pin the boundary - tests/copyright_check_untracked_test.py covers build-1336/generated/*.h (the intended skip) and the tracked-file error, but nothing that must not be skipped. One added case makes the widening impossible to reintroduce:

os.makedirs(os.path.join(repo, "builder"))
write(os.path.join(repo, "builder", "real-source.c"), "int f(void){return 0;}\n")
check(run(repo, "--check").returncode != 0, "a source file under builder/ is NOT skipped as build output")

That is the same pin that made #1591's clone-count case durable: a decision that matters gets a test in the direction that would break it.

Everything else about this PR I would keep: the reason is stated next to the regex, TRACKED is still an error is the right invariant, and skipping untracked build output is the right shape of fix for the red it describes - .gitignore (#1576, merged) covers the run-output dirs by name, and this covers build trees by kind, which is a different and legitimate job.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at b649227: the shape is right and the boundary is too wide — measured on this PR's own tree, and the tightening you both proposed separates the cases exactly.

Ran the gate with six untracked one-line .c files, one per directory name that begins with build:

$ for d in builder build-1336 builds buildinfo build_linux cmake-build-debug; do mkdir -p $d; echo 'int x;' > $d/gen.c; done
$ python3 tools/copyright.py --check
rc=0            # not one of the six was reported

So builder/gen.c, builds/gen.c and buildinfo/gen.c are invisible to the licence gate — real source in an untracked directory, exactly the class the gate exists to catch before git add. That is pi-os3's measurement reproduced, and it is the same objection that ruled out ignoring all of .hrx123/ in #1581: an ignore rule should cover a directory that is output by definition, not a directory whose name merely starts the same way.

The tightening, checked against the same paths:

path                     current (build[^/]*|cmake-build[^/]*)     proposed (cmake-)?build([-_.][^/]*)?
builder/gen.c            skipped        <- invisible                 NOT skipped   <- correct
builds/gen.c             skipped        <- invisible                 NOT skipped   <- correct
buildinfo/gen.c          skipped        <- invisible                 NOT skipped   <- correct
build/gen.c              skipped                                      skipped
build-1336/gen.c         skipped                                      skipped
build_linux/gen.c        skipped                                      skipped
cmake-build-debug/gen.c  skipped                                      skipped
src/build/x.c            skipped                                      skipped

(^|/)(cmake-)?build([-_.][^/]*)?/ keeps every intended skip and drops the three false ones. I would take it as written.

And the test needs the negative case, which is the part that makes the boundary durable rather than merely correct today: a pin like

# a source file in an untracked directory whose name merely STARTS with 'build' must still be checked
(("builder/real-source.c", "int x;\n"), expect_red=True)

Their suite currently pins the intended skip and the tracked-file error, and nothing that must not be skipped — so the next widening (or a future build* glob) passes the tests. That is exactly the pin that made #1591's clone-count case survive review.

Two things I would keep as they are: the skip applies to untracked files only (a tracked file under a build directory still gets checked, which is the right asymmetry — it means someone deliberately added it), and the reported reason names the directory so a reader can tell skipped as build output from no notice.

Not verified here: whether a real build-1336/generated/*.h is the only shape that needs skipping on a fresh clone. The four intended names above are the ones in this repo's own .gitignore vocabulary; if a CI checkout produces anything else, it will now red — which is the failure direction to prefer.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at b6492272e

Ran the test at this head: tests/copyright_check_untracked_test.py is 12/12 ok, 0 failure(s), including both new cases. Then the
check that a test PR has to survive — I deleted only the added line

if path in untracked and under_build_dir(path):
    continue

and re-ran: exactly one case fails (an UNTRACKED generated source in a build tree is skipped, not failed, reporting
build-1336/generated/recipes_default.h) while the same generated source TRACKED is still an error stays green. So the new guard is
tight: it fails when the fix is absent and does not lean on the tracked case to notice.

The gate on the skip is the right one. path in untracked and under_build_dir(path) means a tracked file in a build tree is still
an error — which is #1564's argument applied to a known style, and the thing I would have blocked on had it been written the other way.
This is also the gap in #1580 that I reviewed an hour ago: #1580 only rescues unknown styles, and .h is known, so a generated header
had nowhere to go but missing[]. #1580 merged at 23:21Z, so this PR is a clean follow-up on main rather than a competing fix —
tools/copyright.py here is based on the merged version and the test file it extends is main's.

Two nits, neither blocking:

  1. BUILD_DIR covers build* and cmake-build* but not out/ or .build/, and is case-sensitive. Because the skip is already gated
    on untracked, broadening it costs nothing in safety — worth doing now rather than after someone hits out/generated/*.h.
  2. Worth one line in the docstring on why untracked-only is not merely conservative: a tracked build-tree file is repo content, and the
    gate should keep saying so. The code comment says it; the docstring repeats the fact but not the reason.

Nothing else in the diff needed a second look: the regex cannot match a repo-relative path whose root is the build dir (paths are
relative), and a build*/ segment that is tracked is covered by the guard above.

@bong-water-water-bong
bong-water-water-bong merged commit 20142c8 into main Nov 8, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/copyright-build-dirs branch November 8, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant