Skip to content

Spell paths once and place build files by source path (#1370) - #1378

Merged
mkarlesky merged 38 commits into
masterfrom
fix/1.2.2-path-handling
Oct 29, 2026
Merged

mkarlesky merged 38 commits into
masterfrom
fix/1.2.2-path-handling

Conversation

@mkarlesky

Copy link
Copy Markdown
Member

Closes #1370.

Problem

A mocked header found below a :paths root such as ../**, beside headers far outside the project (C:/Keil_v5/…), had its mock written outside build/. The build then stopped with "Found no file … in search paths". It is a 1.2.0 regression, and it reproduces on Linux and macOS too.

The cause was wider than mocks. Ceedling held one place in three spellings: relative to the working directory, as written in project.yml, and absolute. Code compared them as text, and asked whether a path's text started with a root's. So .. appeared to contain ../../../Keil_v5/…, and ./src/** failed to match src/**. An audit with probe specs confirmed 21 related gaps, one of them destructive: :build_root: . let clobber delete the project's own test/.

Changes

One spelling, containment by directory. New ProjectPaths spells each configured path once: standardized, ~ expanded, symlinks resolved at the entry's base, relative to the working directory. contains? and subdir_below compare whole directories. PathMirror, collection, lookups, and validation all use them. bin/ shares the module.

Build files placed by their source's path from the project root. New BuildSubpath encodes a directory so it never climbs or goes absolute. .. becomes _up_, another Windows drive goes under _abs_, a UNC share under _unc_, and files of the running Ceedling installation outside the project under _ceedling_. A directory named like a marker is escaped, so decode inverts encode exactly. FilePathUtils#form_build_subpath is the one placement rule for objects, preprocessed files, dependencies files, and plugin objects. DestinationCollision stops a build when two sources would build to one file, as it already did for test names.

Lookups invert placement. FileFinder#mirrored_query decodes an object's place and matches the collection exactly, through an index per collection. Name matching remains only as the fallback.

Mocks stay include-root-relative, now with true containment. A header outside every root mocks flat into the test's mock directory, which fixes #1370. A climbing #include folder places its mock below a _up_ anchor inside the test's mock directory.

Collection. -: removes the directories below the one it names, unless an additive entry names one more closely. A root above the build root (../**, ./**, +:**/*.c) collects nothing from it. The directories Ceedling and its enabled plugins create are never collected. Test files are kept out of the source collection after :files revisions, and out of the release build input. Each place is collected once however many entries or spellings reach it.

Build root. An empty build root, the project directory, or a directory above it is refused. A :paths or :files entry within the build root collects with a warning.

Other gaps. ~ expands everywhere a path is configured. TEST_INCLUDE_PATH() ranks by containment. -L names the directories :paths ↳ :libraries collects. test: tasks accept ./ and a leading ... A -: entry naming nothing warns instead of failing validation. The Unity helper compiles its .c and is read after standardizing. An absolute :release_build ↳ :output names the artifact itself. The Dependencies plugin keeps absolute paths absolute and places objects by source path. The launcher's log path and plugin load paths read Ruby expansion and backslashes.

lib/ceedling/PATH_HANDLING.md documents the design for developers and agents.

Compatibility

These change behavior a project might rely on, and each has a Breaking Changes entry:

  • Objects, preprocessed files, and dependencies files move. The first build after upgrading rebuilds once. Third-party plugins that assumed flat object names need updating.
  • -: in :paths removes the directories below the one it names.
  • :build_root: ., .., or an empty value is refused.
  • -L arguments name collected directories, not entries as written.
  • ConfiguratorValidator's path methods moved to ConfiguratorPathsValidator, without shims.

Docs

Changelog (with a Path handling subsection), Breaking Changes, Known Issues for 1.2.0 and 1.2.1, Release Notes, and the paths, files, project, cmock, libraries, release-build, Which Ceedling, Dependencies, gcov, and conventions references.

Verification

  • Unit and integration: 5,819 examples, 0 failures, 19 pending.
  • Full system suite in madsciencelab-plugins:1.2.1: 406 examples, 0 failures, 28 environment-gated pending.
  • Plugin suites against a built and installed gem (fff, module_generator, dependencies): all pass.
  • Every Phase 0 probe passes with pending: removed, across all three tiers. New specs cover each gap, including Windows drive and UNC examples gated to Windows.
  • No-change test:all of temp_sensor matches master (about 3.4 s).
  • RuboCop: no offenses with the todo file. Without it, 760 offenses, down from master's 804, with 24 todo entries retired.

Windows CI is the first run of the drive-letter, case-folding, and UNC specs on Windows, so it deserves a close look.

🤖 Generated with Claude Code

mkarlesky and others added 30 commits October 28, 2026 15:53
A scenario harness runs a project's paths through configuration,
collection, build-path mapping, and the lookups inverting it, without
compiling. Integration, unit, and system probes record each gap the path
handling audit around #1370 found, as pending examples naming the gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ProjectPaths gives a place one spelling and compares paths by directory.
BuildSubpath encodes a path as a build subpath from the project root
(`_up_`, `_abs_`, `_unc_`, and `_ceedling_` for the running installation),
with an exact inverse. Test names use it. A `.` segment no longer stops a
path matching, and a task name with a leading `..` names a test outside the
project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PathMatcher.resolve_relative splits its anchor check and its per-segment
step into their own methods, retiring the file's backlog entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PathMirror compared a file's directory with each root by text, so `..`
contained any path climbing past it, and a mock or preprocessed header of a
header far outside the project landed outside the build directory (#1370).
Roots and directories now compare as places, however each is spelled, and a
`-:` exclusion is never a root. A `./` or absolute root now mirrors as its
plain spelling does, and finds a header's source beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A path spelled through a symlink was collected as a second file, a `-:`
exclusion dropped only the directory it named, and a root above the build
root collected the build tree, vendored framework headers included. Each
entry now resolves its base to the place it really is, exclusions drop
every directory below them, configuration leaves the build root out, and
`~` reads as the home directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mocks, a header's source by convention, and a Partial's module were each
found below the raw :paths entries, so a glob in the middle of an entry
(`components/*/include`) or a symlinked spelling found nothing. Configuration
now computes the roots once, each canonical, a glob-middle entry rooting at
each directory it names, and a Partial's directory drops its `.` segments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A TEST_INCLUDE_PATH() spelled with `./`, backslashes, or absolutely gave its
headers a second spelling beside the collection's, and never ranked ahead of
other search paths, so a same-named header elsewhere won. Each path is now
recorded canonically, and headers rank by the search path that truly holds
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Objects, preprocessed files and dependency files sit below their build
root at their source's own path from the project root, as test names
already do. A path above the project encodes `..` as `_up_`, another
drive as `_abs_` or `_unc_`, and a file of the running installation
outside the project as `_ceedling_`. Two sources never share a build
file, and none lands outside the build directory. Files below a test's
generated roots (mocks, Partials) and below the build root (runners,
vendored frameworks) keep their placement.

Two sources that would still build to one file stop the build with an
error naming both. A release build's link-only object names are placed
apart from its sources, so CException's joins its compiled object.

Lookups invert the placement exactly: an object's place decodes to its
source's path, matched against the collection by absolute path before
any tail match, and a test's name is the longest known name its
object's directory begins with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No caller passes it, and none of these paths depends on a build context.
Retires file_path_utils.rb from the Lint/UnusedMethodArgument backlog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A gcov or Bullseye untested source compiles outside any test, so its
object and dependencies file now sit at the source's path below the
context's output root, and the plugin makes those directories itself.
Two same-named untested sources no longer share one object.

FilePathUtils#form_test_object_filepath places an object exactly where
a test's build does, so gcov's console summary searches the directory
holding a source's coverage data rather than re-deriving it from the
source roots. A dependencies file follows its object by containment,
with or without a test.

A dependency built by :build_lib places each object at its source's
path below the dependency's source path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MockLocator#placement gives a mock's directory and the folder and root
CMock writes it by. Mock generation, the planner's mock sources, the
stand-ins and the mock search paths all use it, so a mock whose
#include spells more path than its mirror is compiled, stood in for and
searched where it is written. A folder that climbs stays below the
test's mock root, and Generator#generate_mock takes CMock's root
explicitly.

The compiler resolves a test's #include of a mock as written, from the
test file's own directory, where no generated mock can be, so a mock
#include that climbs with `..` never compiled. It now stops the build
with an error naming the test file and a spelling that works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
:cmock ↳ :unity_helper_path names a helper's header, or its path without
an extension. The test build compiled that path itself, a header beside
the helper's source, which now collides with the source's own object.
It compiles the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The linker's -L arguments were built from :paths ↳ :libraries as
written, so a decorated or globbed entry reached it verbatim. They now
come from LibraryResolver#search_path_arguments: the directories those
paths name, decorated and globbed as configured, with those a plugin
adds, collected once at the first link. Test and release links share
it, as library file resolution already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConfiguratorPathsValidator takes the :paths and :files entry checks and
the checks of paths named singly from ConfiguratorValidator, which keeps
the checks of required entries, tools and matchers. Behavior is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clobber empties the build root's test and release trees, so a build
root of `.` deleted the project's own tests. Configuration now refuses
a build root that names no directory, is the project directory or a
directory above it, or holds the base of a :paths or :files entry that
adds to a build, naming the reason.

A `-:` entry names what a build leaves out, so naming nothing is no
longer a validation error. Below the build root, where nothing exists
before a first build, it passes silently, and elsewhere with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bin/ handles some paths before the configuration pipeline runs: the
project file, mixins, the log file and directory, plugin load paths and
the WHICH_CEEDLING hand-off. PathValidator#standardize_paths now spells
them through ProjectPaths.standardize, so `~` names the home directory
and a trailing separator drops, as everywhere else.

The log directory follows the build root after inline Ruby string
expansion, and bin/ expands and standardizes :plugins ↳ :load_paths
through the same RakefileComponentResolver helper the application uses,
so both find plugins in the same places.

PathValidator#validate, MixinResolvinator#extract_mixins and
#validate_mixins, and RakefileComponentResolver#plugin_rake_files split
into smaller steps, retiring their backlog entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Setupinator#do_setup becomes a short list of named steps, each holding
what one numbered stage of setup did. The Configurator steps that fill
out the configuration run from an ordered list, as configuration's own
path collections and validations do. Behavior is unchanged, and
setupinator.rb leaves the AbcSize and MethodLength backlog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each :cmock ↳ :unity_helper_path header is included in every mock by its
filename, which was taken before paths were standardized, so a helper
written with backslashes was included by its whole path. The filename
is now taken after standardizing, as a setup step of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An absolute or `~` :release_build ↳ :output was joined below the release
build root. It now names the release itself, with its map file beside
it, and linking makes the release's directory.

The Dependencies plugin reads its configured :paths as every configured
path is read, keeps an artifact or include named by an absolute path as
written, and writes each object's list file beside the object rather
than in the working directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project named by --project from another directory reads its relative
paths against the working directory, not the project file's directory.
A system spec now builds such a project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FileFinderHelper#find_best_path_in_collection, which matches a module
generator path hint to a configured path, scores each path, picks the
first highest score, and complains through small steps of their own.
New specs pin its behavior, unchanged, and file_finder_helper.rb leaves
the Metrics backlog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DestinationCollision finds the outputs more than one input maps to and
stops the build naming them, for two tests sharing a build name and two
sources sharing an object alike, with one message shape and fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FilenameExtension#pattern matches any one of a type's extensions,
escaped, as one group. Release library sorting, the release compile and
assemble rules, gcovr's exclusions and Bullseye's source filtering use
it rather than each building the alternation by hand.
sort_objects_and_libraries sheds its Metrics offenses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A build compares the same few roots with every file, and spelling each
absolutely on every comparison made a no-change build of temp_sensor
measurably slower than master's. ProjectPaths.absolute now remembers
each spelling per working directory and home directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A build root spelled through a symlink, such as macOS's /tmp for
  /private/tmp, is compared as the place it really is, so it cannot
  pass for a directory of its own when it is the project's.
- A build root that expands to nothing stops bin/ with the refusal
  configuration gives, rather than placing logs at the filesystem root.
- A bin/ path of only whitespace stays as written, so a destination
  never becomes the filesystem root.
- A `-:` subdirectories glob of a directory that does not exist warns
  as any other `-:` entry naming nothing does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal of a mock #include that climbs with `..` fired for one in a
branch that preprocessing drops, and for a spelling the mock search
paths reach, both of which build on master. It is gone; such an
#include is anchored to the test file's directory as any other.

MockLocator#placement now also gives the directory a mock's #include as
written reaches it from, which joins the mock search paths whether or
not CMock is given the folder, and compares the folder with the
header's mirrored subdirectory alone, never the mock root that carries
the test's name. The planner keys mocks by the directory they are
written to, so two same-named mocks whose #include folders alone differ
both build, and each stand-in includes its header by the folder its
real mock will. A test without a mock root places no stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A test that #includes a module's source as well as its header must not
link that source's object a second time. The object left out was
formed from the #include as written, which no longer matched the
object's place at the source's own path, so the link found two
definitions. Each such #include now resolves to the file it names
first.

One file named by two spellings, such as a vendored CException source
below a `./build` build root, or a `./`-prefixed Unity helper beside
the support files that collect it, is one source, never a collision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project directory named `_up_`, `_abs_`, `_unc_` or `_ceedling_`
encoded exactly as the marker for a path outside the project, so its
object read back as a different source, or none. Such a directory now
gains a leading `_` when encoded, and loses it when decoded.

A file of the running Ceedling installation on another drive also
places below `_ceedling_`, and each object's whole path is checked
against the platform's path length limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core review found one place still reached by two spellings:
- A symlinked directory below a collected root was listed by its link
  while a `-:` entry naming it was spelled through its target, so the
  exclusion missed it and two entries could collect it twice. Every
  collected directory is now spelled canonically, and containment is
  judged on both spellings. A build root written through a symlink is
  excluded the same way, and :files revisions compare each file through
  its real directory.
- A `-:` entry now leaves out the directories below it unless an
  additive entry names one nearer, as `+:src/legacy/keep` beside
  `-:src/legacy` does. Exclusion is a walk up each directory, not a
  comparison with every excluded one, so a large tree collects in
  master's time again.
- ProjectPaths reads a `~user` path as a directory of that name, keeps
  a UNC share's `..` below the share, treats a doubled leading `/` as
  the root outside Windows, reads `~` after a decorator and a space, and
  drops every trailing separator. Clean absolute paths skip respelling.
- Mirror roots read a literal bracket beside a glob (#104) and root
  `src/**/**` at `src`; ReportGenerator's source directories collapse
  by containment; header ranking walks up each header's directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A second review pass found that spelling every collected directory as
it really is changed what a project sees: an #include through a
symlinked subdirectory below a :paths base no longer resolved, and a
symlinked test directory renamed its tests. Collection again spells
each place through its entry's base as the base really is, as before,
and compares and deduplicates places by their real spelling, now kept
in PathSpellings.

- Each directory is kept or left out by the entries that name it, so a
  middle-wildcard entry or an explicit directory no longer shields
  directories other entries name.
- A directory named like a placement marker in any case is escaped,
  since macOS and Windows file systems fold case.
- The shortcut for a clean absolute path spells `/a/` and padded paths
  as the full computation does, and on Windows leaves short `~` names
  and trailing dots and spaces to File.expand_path.
- Each object's path length is checked once, and a `-:` glob matching
  nothing below a directory that exists warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mkarlesky and others added 5 commits October 28, 2026 21:55
file_path_utils.rb no longer has AbcSize or CyclomaticComplexity
offenses, nor release_build_executor.rb CyclomaticComplexity or
PerceivedComplexity ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A :files revision such as `+:**/*.c` matched the build root's mocks,
runners and vendored Unity and CMock sources. A release build then
compiled them and failed on a missing cmock.h, even right after clobber.
A :files revision now adds no file below the build root unless its own
base lies there or it names the file exactly, as :paths collection
already leaves the build root out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A build root holding a :paths or :files entry was refused, so generated
code configured in build/gen could not build. Only an empty build root,
the project directory, or a directory above it is refused now, since
clobber would empty the project's own files. A :paths or :files entry
within the build root collects with a warning instead.

No entry collects from the directories Ceedling creates: the build
root's artifacts, test, release, vendor and cache directories, the log
and mock directories, and each enabled plugin's build directory. An
entry based above the build root still collects nothing from it, and
one based at the build root collects what the build root holds besides.
PathExclusions holds the three kinds of exclusion collection applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A :files glob such as `+:**/*.c` added test files to the source
collection and the release build input, and a source root holding the
test root added them to the release build input. The release then
compiled them and stopped. Test files are now filtered out after :files
revisions, and out of the release build input however they reach it.
Only the source collection warns of the overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Changelog, Breaking Changes, Known Issues, and Release Notes cover
the path handling fixes and the behavior they change. The configuration
reference describes `~`, `-:` recursion, build root collection and its
warning, and build files placed by source path. PATH_HANDLING.md
explains the path modules, build subpath markers, and platform
differences for developers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

CI summary · e392802 · ✅ 23/23 jobs passed · nearest baseline 4f6f1b6 (master)

Tests (Linux 3.3) Examples Failures Pending
units 5252 (+273) 0 0
integration 568 (+57) 0 19
system 406 (+26) 0 35
Coverage Lines Δ
units 90.53% ▲1.00
integration 51.25% ▲0.64
system 83.93% ▲0.24
combined 98.51% ▲0.10

Diff coverage: 98.92% (735/743 changed lines). Uncovered: bin/cli_helper.rb:128, lib/ceedling/build_subpath.rb:57-58, lib/ceedling/project_paths.rb:90, lib/ceedling/project_paths.rb:156-158, plugins/dependencies/lib/dependencies.rb:295

All legs (examples / failures / pending)
Leg units integration system
Linux 3.0 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 35
Linux 3.1 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 35
Linux 3.2 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 35
Linux 3.3 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 35
Linux 3.4 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 35
Linux 3.5 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 35
Windows 3.0 5248 / 0 / 1 567 / 0 / 19 406 / 0 / 62
Windows 3.1 5248 / 0 / 1 567 / 0 / 19 406 / 0 / 62
Windows 3.2 5248 / 0 / 1 567 / 0 / 19 406 / 0 / 62
Windows 3.3 5248 / 0 / 1 567 / 0 / 19 406 / 0 / 62
Windows 3.4 5248 / 0 / 1 567 / 0 / 19 406 / 0 / 62
macOS 3.1 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 51
macOS 3.2 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 51
macOS 3.3 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 51
macOS 3.4 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 51
macOS 3.5 5252 / 0 / 0 568 / 0 / 19 406 / 0 / 51
Jobs

mkarlesky and others added 3 commits October 29, 2026 01:16
A project inside a Ceedling checkout, as the system specs' projects are,
had its sibling sources placed below `_ceedling_`, since they lay below
the installation root. The subpath carried the checkout's whole path to
the project, and Windows refused gcov's paths past 260 characters. Only
the installation's assets, bin, lib, plugins, and vendor directories
place below `_ceedling_` now. Other files beside the project place by
`_up_` as any sibling does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project directory took the example's description, up to 60 characters,
and its temp dir Dir.mktmpdir's 22. On Windows CI the deepest build file of
a header_include_path_validation project reached 272 characters, past the
260 limit. A project is now named by its prefix and a digest of the full
description, and its temp dir by 7 characters. That path drops to about 205.
Failure reports print the project name beside the example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bundler evaluates the gemspec on every `bundle exec`, and Dir['**/*'] walked
specout/ before filtering it out. A retained system test project nested past
Windows' path limit made the glob raise, and every later example failed. The
walk now skips build/, site-web/, specout/ and tools/ at the top level. The
packaged files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mkarlesky
mkarlesky merged commit 9f68267 into master Oct 29, 2026
24 checks passed
@mkarlesky
mkarlesky deleted the fix/1.2.2-path-handling branch October 29, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File generation fails when absolute include paths are used

1 participant