Skip to content

Check that declared source paths exist during component analysis - #202

Open
shellygr wants to merge 1 commit into
masterfrom
shelly/validate-component-paths
Open

Check that declared source paths exist during component analysis#202
shellygr wants to merge 1 commit into
masterfrom
shelly/validate-component-paths

Conversation

@shellygr

@shellygr shellygr commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What

The path fields on SourceExplicitContract, SourceExternalActor and ExistingFromSource are
written by the component-analysis agent. Nothing checked them against the filesystem.
validate_solidity_connectivity covered names, duplicates and the interaction graph, and it was
typed over BaseApplication with no project root to check anything against.

So a declared path that resolves to nothing travels a long way before anyone notices. It goes
through run_setup_part1's name_to_path, into the transitive closure, into extra_files in
run_autosetup_phase, and finally into the AutoSetup subprocess argv, where parse_contract_files
raises File ... does not exist. By then an hour has passed and several analysis and bug-analysis
phases have run.

The shape that prompted this: a repository whose build project sits one or more directories below
the repository root. Paths are project-root-relative, so every declared path has to carry that
leading directory. A path assembled from an import statement or from a build tool's source
directory instead of from the agent's own file-tool output loses it, and every contract in the
submission loses it the same way.

How

The validator takes a third project_root: Path | None and checks the declared paths inside its
existing single pass over components. Path complaints join the same accumulated message as the
graph ones, so a submission that is wrong in both ways is corrected in one retry instead of two.
project_root threads through Ecosystem.validate_analysis, which follows the existing
locate_main precedent of handing ecosystem callables the run's facts. Solana and Soroban take an
ignored parameter; nothing in their models carries a source path.

Containment is lexical, meaning relative and never stepping up out of the root. Resolving instead
would follow symlinks, and the forbidden-read predicate deliberately keeps .sol readable under
lib/ and node_modules/, which are routinely symlinked. The agent is entitled to name a file
there and has no way to restate it as a non-symlinked path, so a resolving check would reject it
with no way to comply. There is a test pinning that.

The error says where to look instead. One walk of the tree collects readable files carrying the
declared name, narrowed to those whose path ends with the declared path, so the dropped-directory
case names exactly one real file rather than reporting "not found". One walk covers every bad path
in the submission, and wholly withheld directories are pruned during descent rather than filtered
out afterwards, because this runs while the agent waits on its retry.

Cache hits get the same check. root_cache_key is
sha256(project_root | doc_hash | relative_path | contract_name), which covers neither the source
tree nor the rules in force, and the cloud namespaces the cache per user and repository. Without
this, a model written before the check existed is replayed unvalidated on every rerun and the gate
never fires at all. An entry that fails validation is stale, so it is re-derived and the existing
cache_put replaces it.

The prompt and the path field descriptions now state the convention the validator enforces. They
reject nothing on their own; they say what the check requires.

Scope note for reviewers

SourceExternalActor.path is checked too, which is wider than the failure above. It is the same
class of unchecked agent-written string and it reaches the summarizer prompt. The error offers
"omit the path instead" as the remedy, and an actor with no path is passed over silently
downstream, so a repository that passes today can start reporting a validation error. That is
intended, not a regression.

Testing

tests/test_solidity_component_paths.py is new: 17 tests over acceptance, a path missing its
leading directory (asserting the hint names the one real file), several files sharing a name, no
file of that name anywhere, a directory, absolute and escaping paths, a path under a withheld
directory, a symlinked dependency being accepted, several bad paths arriving in one message, graph
errors and path errors arriving together, the external actor with and without a path,
project_root=None, greenfield, and FromSourceApplication where the existing contract is checked
and the fresh one is not. Two more cover the cache branch in both directions.

tests/test_analysis_input_phrase.py gained rendering assertions for the two path bullets and the
omit-the-path clause. tests/test_pipeline_overlap.py now asserts the driver forwards a real
project root.

Each new test was checked by breaking the code it covers and confirming it fails.

Targeted run: 91 passed. pyright composer/ analyzer sanity_analyzer certora_autosetup: 0 errors.

The path fields on the source-carrying model types are written by the
component-analysis agent and were never checked against the filesystem.
A path that resolves to nothing travelled through name_to_path, the
transitive closure and extra_files before AutoSetup's parse_contract_files
raised on it, an hour and several phases after it was written.

validate_solidity_connectivity now takes the project root and checks each
declared path in its existing pass over components, so a submission wrong
in both its graph and its paths comes back as one message. Cache hits are
held to the same check, since the analysis cache key covers neither the
source tree nor the rules in force.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shellygr
shellygr requested a review from jtoman September 2, 2026 20:25
Comment on lines +84 to +85
if fs_forbidden_read(PurePath(declared)):
return _PathFault.WITHHELD

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm confused how this happened?

found: dict[str, list[str]] = {name: [] for name in names}
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = Path(dirpath).relative_to(root)
dirnames[:] = [d for d in dirnames if not fs_withheld_subtree(rel_dir / d)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was today years old when I learned you could control the recursion behavior of os.walk by just mutating dirnames. This language is insane dude.

Comment on lines +115 to +117
name = PurePosixPath(declared).name
candidates = same_named[name]
tail_matches = [c for c in candidates if c.endswith("/" + declared)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, I'm confused. This is basically finding candidates where the LLM clearly forgot a leading directory?

return _PathFault.DIRECTORY
if not candidate.is_file():
return _PathFault.MISSING
if fs_forbidden_read(PurePath(declared)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FWIW, we should be using the forbidden_read on SourceFields which makes up the input to the pipeline. fs_forbidden_read is just the default (that we happen to use always, so its worth considering whether we need to keep forbidden_read as a separate part of the input)

async def run_component_analysis[T: BaseApplication](
ty: type[T],
child_ctxt: WorkflowContext[T],
input: SystemDoc | SourceCode | None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FWIW, this is the input field which holds the source field to use.

extra_input: list[str | dict],
expected_main_id: SourceIdentifier | None = None,
*,
project_root: Path | None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

doesn't this duplicate the data available on SourceCode? When will this be non-none and input !is SourecCode?


``project_root`` is the tree the validator resolves any source paths the model declares
against; it is required (not defaulted) so a new call site has to say what the model's paths
mean, and is ``None`` only when the run has no source tree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

which is also the semantics (if you will) of input == SystemDoc or input == None. I think we're duplicating data.

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.

2 participants