feat(core): add TaskFileResolver primitive; refactor show target --check to use it#35583
feat(core): add TaskFileResolver primitive; refactor show target --check to use it#35583polygraph-snapshot-app[bot] wants to merge 12 commits into
Conversation
✅ Deploy Preview for nx-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for nx-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
View your CI Pipeline Execution ↗ for commit 71a4d46
☁️ Nx Cloud last updated this comment at |
9ec92af to
8378d0e
Compare
| const taskId = t.configuration | ||
| ? `${t.projectName}:${t.targetName}:${t.configuration}` | ||
| : `${t.projectName}:${t.targetName}`; |
There was a problem hiding this comment.
We have utils to create the task id already, don't do it ad-hoc like this
| function parseTaskId(taskId: string): { | ||
| project: string; | ||
| target: string; | ||
| configuration?: string; | ||
| } { | ||
| const [project, target, configuration] = splitByColons(taskId); | ||
| if (!project || !target) { | ||
| throw new Error( | ||
| `Invalid taskId "${taskId}" — expected "project:target[:configuration]"` | ||
| ); | ||
| } | ||
| return { project, target, configuration }; | ||
| } |
There was a problem hiding this comment.
Nope, we have dedicated utils for this stuff
| // The result key is usually the same as taskId but may include a | ||
| // defaultConfiguration suffix when none was explicitly given. | ||
| let inputs: HashInputs | undefined = planResult[taskId]; | ||
| if (!inputs) { | ||
| const prefix = `${project}:${target}`; | ||
| for (const [key, val] of Object.entries(planResult)) { | ||
| if (key === prefix || key.startsWith(prefix + ':')) { | ||
| inputs = val; | ||
| break; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
We can read the target's default configuration instead of guessing here
| function findCanonicalTaskId(taskId: string, tg: TaskGraph): string | null { | ||
| if (tg.tasks[taskId]) return taskId; | ||
| const { project, target } = parseTaskId(taskId); | ||
| const prefix = `${project}:${target}`; | ||
| for (const id of Object.keys(tg.tasks)) { | ||
| if (id === prefix || id.startsWith(prefix + ':')) return id; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
This is kinda crazy, we've got better utils for this. Don't duplicate.
| function getDepsOutputs(taskId: string): ExpandedDepsOutput[] { | ||
| if (depsOutputsCache.has(taskId)) return depsOutputsCache.get(taskId)!; | ||
|
|
||
| const tg = getTaskGraphFor(taskId); | ||
| if (!tg) { | ||
| depsOutputsCache.set(taskId, []); | ||
| return []; | ||
| } | ||
| const canonical = findCanonicalTaskId(taskId, tg); | ||
| if (!canonical) { | ||
| depsOutputsCache.set(taskId, []); | ||
| return []; | ||
| } | ||
| const task = tg.tasks[canonical] as Task; | ||
| let result: ExpandedDepsOutput[] = []; | ||
| try { | ||
| result = | ||
| getInputs(task, options.projectGraph, getNxJson()).depsOutputs ?? []; | ||
| } catch { | ||
| result = []; | ||
| } | ||
| depsOutputsCache.set(taskId, result); | ||
| return result; | ||
| } |
| function getUpstreamTaskIds(taskId: string, transitive: boolean): string[] { | ||
| const tg = getTaskGraphFor(taskId); | ||
| if (!tg) return []; | ||
| const canonical = findCanonicalTaskId(taskId, tg); | ||
| if (!canonical) return []; | ||
| const direct = tg.dependencies[canonical] ?? []; | ||
| if (!transitive) return [...direct]; | ||
| const visited = new Set<string>(); | ||
| const queue = [...direct]; | ||
| while (queue.length) { | ||
| const id = queue.shift()!; | ||
| if (visited.has(id)) continue; | ||
| visited.add(id); | ||
| queue.push(...(tg.dependencies[id] ?? [])); | ||
| } | ||
| return [...visited]; | ||
| } |
There was a problem hiding this comment.
We almost certainly have a better util
| function pathMatchesOutputPattern( | ||
| normalizedPath: string, | ||
| pattern: string | ||
| ): boolean { | ||
| const np = pattern.replace(/\\/g, '/'); | ||
| return ( | ||
| normalizedPath === np || | ||
| normalizedPath.startsWith(np + '/') || | ||
| minimatch(normalizedPath, np, { dot: true }) | ||
| ); | ||
| } |
| function isOutputImpl(taskId: string, path: string): boolean { | ||
| const normalized = path.replace(/\\/g, '/'); | ||
| return getOutputs(taskId).some((p) => | ||
| pathMatchesOutputPattern(normalized, p) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Why these weird Impl suffixes? Also, why are we even doing the filtering like this? This sucks. Getting bespoke listts of outputs per path instead of some bulk query
| const depsOutputs = getDepsOutputs(taskId); | ||
| if (depsOutputs.length === 0) return false; | ||
| for (const { dependentTasksOutputFiles, transitive } of depsOutputs) { | ||
| if (!minimatch(normalized, dependentTasksOutputFiles, { dot: true })) { |
There was a problem hiding this comment.
We almost certainly have a util
…cking Adds a programmatic API that lets external consumers verify whether sandbox-violation file paths from a prior task run would be considered legitimate inputs/outputs in the current workspace configuration. The API mirrors the logic that powers nx show target --check: inputs are reconciled via HashPlanInspector.inspectTaskInputs, outputs via getOutputsForTargetAndConfiguration with glob matching. HashPlanInspector and the new verifier are now exported from devkit-exports for use by the nx-cloud light client.
…evkit-internals Replaces the sandbox-report-aware verifySandboxViolations export with a generic primitive. createTaskFileResolver returns a handle exposing getInputs / getOutputs / isInput / isOutput per task — the cloud light client owns the SandboxReport shape and the iteration loop. Exposed via devkit-internals (not the public devkit-exports surface). A follow-up commit will refactor nx show target --check to consume this same resolver.
…ally
Previously isInput() only matched against the materialized HashInputs.files
list — when an upstream task hadn't run yet, depOutputs was empty and any
file declared via { dependentTasksOutputFiles: '...', transitive?: bool }
was reported as not-an-input even when the path obviously matched both the
glob and a declared upstream output.
The new logic walks the task graph from the inspected task, pulls each
upstream's declared output globs, and reports the path as an input when
it matches the dependentTasksOutputFiles glob AND lies inside one of those
upstream outputs. Honors transitive: true/false. The check is exposed
separately as resolver.matchesDependentTaskOutputs so consumers (e.g. the
nx-cloud check-sandbox-report command) can reason about why a path was
considered an input.
Adds 6 unit tests covering: materialized depOutputs, static glob+output
match, glob-without-output mismatch, output-without-glob mismatch,
transitive=true walk, transitive=false short-walk, and the standalone
matchesDependentTaskOutputs accessor.
31a7d28 to
adae585
Compare
…uts public functions
Surfaces are simpler (no factory pattern): callers pass files directly and
receive { matched, unmatched } without constructing or threading a resolver
object.
Functions are public (devkit-exports) not internal: Cloud can import them
directly without going through requireNx / devkit-internals.
Module-level graph caching keeps it fast: createProjectGraphAsync and
HashPlanInspector.init are called at most once per process — the same
efficiency as the old resolver but without the explicit pass-through.
Breaking: TaskFileResolver interface and createTaskFileResolver factory
are removed from devkit-internals. Cloud should import checkFilesAreInputs
/ checkFilesAreOutputs from @nx/devkit instead.
…uts public functions [Self-Healing CI Rerun]
Replaces splitByColons (low-level lexer) with the project-graph-aware splitTarget helper so taskIds whose project name contains colons (e.g. some:scoped:project:build) parse correctly.
… [Self-Healing CI Rerun]
There was a problem hiding this comment.
Nx Cloud has identified a flaky task in your failed CI:
🔂 Since the failure was identified as flaky, we triggered a CI rerun by adding an empty commit to this branch.
🎓 Learn more about Self-Healing CI on nx.dev
Summary
Adds a small generic
TaskFileResolverprimitive (exported fromdevkit-internals,not
devkit-exports) that lets callers ask whether a workspace-relative path is adeclared input or output of a given task. Refactors
nx show target ... --check(both inputs and outputs) to use it instead of duplicating the input/output
reconciliation logic.
This supersedes the earlier
verifySandboxViolationsAPI: that high-level helperhas been removed in favor of this lower-level primitive. Consumers (the nx-cloud
light client) own their own report schemas and call the primitive per file.
API
Exported from
packages/nx/src/devkit-internals.ts.isInput()accepts a path as an input if any of the following hold:HashInputs.files(resolved self-inputs).HashInputs.depOutputs(materialized — only populated afterupstream tasks have run).
dependentTasksOutputFilesglob declared on the task ANDlies inside the declared outputs of an upstream task in the task graph
(honors
transitive: true|false). This is the static check that letssandbox-report verification work without first running the dependency.
Files
packages/nx/src/hasher/task-file-resolver.tspackages/nx/src/hasher/task-file-resolver.spec.tspackages/nx/src/hasher/verify-sandbox-violations.tspackages/nx/src/hasher/verify-sandbox-violations.spec.tspackages/nx/src/devkit-internals.tscreateTaskFileResolverpackages/nx/src/devkit-exports.tsverifySandboxViolationsexportspackages/nx/src/command-line/show/show-target/inputs.tspackages/nx/src/command-line/show/show-target/outputs.tsTest plan
6 new tests covering
dependentTasksOutputFilesstatic validation).show target inputs|outputs(25 tests passing locally).Linked PR
Consumed by nrwl/ocean#11134, which owns the
SandboxReportschema anditerates the report calling
resolver.isInput/resolver.isOutputper file.