Skip to content

feat(adf): save source graphs with dataset lineage at ADF discover - #94

Open
matthewmoorcroft wants to merge 64 commits into
discovery/shared-ast-standardfrom
stack/adf-discovery
Open

matthewmoorcroft wants to merge 64 commits into
discovery/shared-ast-standardfrom
stack/adf-discovery

Conversation

@matthewmoorcroft

@matthewmoorcroft matthewmoorcroft commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Intent

Put ADF discovery on the source-neutral discovery graph contract from #84. The ADF loader projects each pipeline into a SourceGraph, saves source_graphs.json first (versioned, hashed, motifs inside), and builds inventory.json through the shared emitter, which records the graphs' hash. ADF data lineage is derived conservatively: only provable physical values become dataset identities.

What changed

  • Review fixes, round three (20 Oct): only a real boolean waitOnCompletion is recorded (null or a string leaves the wait unknown); an Expression override with an empty value no longer wipes the dataset identity; two Switch cases with the same value both keep their activities (the parser no longer replaces the first, the graph gives each case its own branch, and the bundle gives each its own condition task key), and the repeat is reported on the Switch node (duplicate_switch_cases) and logged; a bracket-quoted table such as [Sales (2024)] keeps its identity, because only ARM's own functions make an ARM expression; Blob and ADLS Gen2 datasets share one abfss:// identity on purpose, since both reach the same storage account; @dataset()?.x resolves like @dataset().x; the dataset lineage module is split into identity, store and signature parts (a pure move). refactor: add source-neutral discovery graph contract and shared lineage core #84's round three is merged in with a merge commit.
  • Review fixes, round two (16-18 Oct): the signature rule below; non-map parameters are ignored instead of crashing discover; a null or non-name ExecutePipeline reference stays unresolved; a control edge read back without resolved: true is unresolved, and a wait flag that is not true or false reads back as unset; an ARM-expression linked service name never proves a store; Fabric Lakehouse and ADLS Gen1 locations are qualified by their plain linked service name, like SFTP; repeated motif detections are counted once, on the inventory's rule (same motif id and same activities, in any order), so the profile, the saved graph and the inventory agree; the --debug pipeline dump includes the pipeline's lineage block.
  • Merged refactor: add source-neutral discovery graph contract and shared lineage core #84's final head with a merge commit (no rebase). This PR reads lineage through the shared ir_serde.lineage_from_dict, which now also carries refactor: add source-neutral discovery graph contract and shared lineage core #84's wait-flag rule.
  • Review fixes (14-15 Oct): a linked service name must be a plain name before it qualifies an identity; the S3 / GCS bucketName now names the file store, so two buckets with the same folder layout no longer link; an ExecutePipeline edge is resolved only when its child pipeline is in the export, including under --pipeline, matched case-insensitively as ADF does and recorded under the exported spelling; a test checks that running discover twice gives byte-identical inventory.json with no generated_at. In the same style: an unevaluated ARM or {{ placeholder folderPath no longer anchors a path signature, an unevaluated ARM fileName is kept as written so different ones never link, SFTP, file server, FTP, Azure Files, HDFS and HTTP locations are qualified by their plain linked service name, a literal container or bucket whose store cannot be proven gets no signature, and the IR-side build_control_edges takes the same known-pipelines check.
  • sources/adf/loader.py:
    • Attaches detected motifs to each graph's lineage.
    • Writes source_graphs.json, then inventory.json through build_source_inventory with source_graphs_sha256.
    • inventory.json deliberately has no top-level generated_at, so the same export, given the same --source-dir, always gives the same bytes. This is documented in skills/flowx-discover/sources/adf.md.
  • sources/adf/discovery_mapping.py: maps the ADF AST to SourceGraph one to one.
    • Every declared branch is kept, including Switch cases with no activities.
    • A case whose value is literally default is keyed case:default (kept unique), so it never collides with the Switch's own default branch.
  • sources/adf/dataset_lineage.py (with dataset_identity.py, dataset_store.py and path_signature.py, split in round three): dataset identities.
    • Tables are qualified by the linked service's literal server (and database). Paths run down to the literal file name.
    • @dataset().x location parts resolve against the call-site bindings.
    • Anything parameterised or unresolved gives no identity: ADF expressions, DAB placeholders, unevaluated ARM expressions, and parameterised linked services, databases or storage accounts. Bracket-quoted SQL names such as [dbo].[Orders] stay valid.
    • Path signature rule (round two): an unresolved path gets a weak signature only when the activity passes a folderPath with literal text at the call site; the dataset's own location is not enough on its own. The folder and file name are then read from the dataset's location with the call-site values filled in (the call-site value is used when the location sets none). Only provably literal text forms the path (quoted text, numbers, text outside @{...}); every other value is one <P> slot where it sits, and a function over a bound dataset() value is a slot keyed by that value (<P:tolower('orders')>). So nothing vanishes, no argument text leaks, and no link is made from a non-literal value. An interpolated call-site folder links like its @concat spelling. tests/unit/test_adf_path_signature_corpus.py guards the rule.
    • An unresolved path's weak signature names the dataset's store when it is known, so datasets on different stores never join on folder shape alone. The store is <container>@<account>, the linked service name in place of an account hidden in a secret (only for a linked service with no parameters), or the table store. An unknown store is never guessed; that signature stays unqualified.
    • Run-time override rule: a read or write whose activity overrides the dataset's physical source or target at run time gets no identity and no signature, so it never forms a data edge. That covers storeSettings wildcard, file-list or prefix paths; a source query, *Query or *StoredProcedureName; and a sink *StoredProcedureName.
  • Groundwork for cross-pipeline deterministic conversion, not called yet (models/ir.py, lineage.py, ir_serde.py, bundler/dab_writer.py):
    • The IR lineage fields (Activity.data_reads, data_writes and motif_id, and Pipeline.lineage), the build_* and with_lineage functions, their IR serialisation, and package's read-back of them in dab_writer.py.
    • Nothing in Phase 1 populates or calls them yet, so output is unchanged.
    • Within that layer, a run-now of an existing job by ID is not a cross-workflow edge, and motif annotations carry source_type_hint.
    • The one shared lineage parser, ir_serde.lineage_from_dict, is used in production by discovery_serde.
  • Discover console (rides along): discover prints a loud warning when it parses 0 pipelines (naming --adf-source-path), and labels the activity count "raw, incl. nested". This affects console output only; nothing on disk changes.
  • Docs: AGENTS.md, the README output tree and the flowx-discover skill describe source_graphs.json and the inventory hash.

How it was tested

  • Unit tests cover the loader, the mapping, dataset lineage and the lineage layer. Dataset lineage tests cover identity tiers, servers, file names, ARM expressions, bracket-quoted names, parameterised files and every override kind. The full unit and integration suites, ruff and mypy pass in CI.
  • ADF and Airflow discover, convert and package on the fixture corpora: bundles and translation reports are byte-identical to the merge-base with main (b8d0766). A scratch merge of refactor: add source-neutral discovery graph contract and shared lineage core #84 and feat(adf): save source graphs with dataset lineage at ADF discover #94 onto current main (858550e) is byte-identical to main too, with the full unit suite passing. Only the ADF inventory changes, gaining the shared emitter's fields, source_graphs_sha256 and each activity's task_key.
  • Codex cross-review, read-only, twice. Round 1 found 4 issues and round 2 found 3. All are fixed here, along with the review-found follow-ons: the Switch default label clash and the override-rule siblings. An independent commit-by-commit review then found one more, the cross-store signature edge, which is also fixed here. Every fix has a regression test that fails on the previous code.

Merge order

Merge after #84. This PR targets discovery/shared-ast-standard; retarget it to main once #84 has merged. The retarget needs a one-line import resolution in bundler/dab_writer.py: keep both from flowx.ir_serde import data_asset_from_dict, lineage_from_dict and main's from flowx.models.dab import DabNotebook, SecretInstruction, SetupTask. #95, #96 and #97 follow in order.

This pull request and its description were written by Isaac.

matthewmoorcroft and others added 3 commits September 16, 2026 17:24
Map ADF exports onto the shared source-neutral discovery AST and emit a
source-agnostic inventory, populate control/data lineage (Switch-aware),
surface detected ADF motifs additively in the inventory, and carry the
lineage/motif tags on the IR (data_reads/data_writes, motif_id on the
Activity base; pipeline lineage block) with matching serialization.

Consolidates PRs #79 (ADF -> shared source AST + inventory emitter),
#80 (data/control lineage + Switch-aware walking), #81 (emit lineage into
inventory.json) and #90/#64 (motif detection + exact-duplicate dedupe),
plus the coverage golden.

Co-authored-by: Isaac <no-reply@databricks.com>
FIX 1: When ADF discover parses zero pipelines (e.g. --source-dir pointed at a
directory of ARM templates with no recognized pipelines/ layout), emit a loud
stderr warning instead of finishing with a success-shaped "Loaded 0
pipeline(s)". The warning steers the operator to pass the ARM template as the
file path or supply the expected export layout. Exit code for the 0-pipeline
case is unchanged (still 0); behaviour for valid dirs/files is untouched.

FIX 7: Label the discover summary's "Total activities" count as raw activities
including nested, and note that convert reports top-level task-units after motif
collapse, so the smaller convert number is not mistaken for a discrepancy.
Console-output only -- inventory.json keys are byte-compatible (unchanged).

Co-authored-by: Isaac <no-reply@databricks.com>
Cross-review (non-blocking): the loud 0-pipeline warning referenced the loader's
internal --source-dir flag, but that is not what a user passes -- the discover
runner takes --adf-source-path (the generic --source-path alias normalises to
--source-dir internally). Name --adf-source-path in the guidance so the operator
is pointed at the flag they actually use. Test asserts the user-facing flag is
named and the internal one is not.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 6 commits October 5, 2026 12:09
…elope, graph-contract naming) into stack/adf-discovery

The emitter and its tests now live in the base PR as a superset of this
branch's copy, so both take the base version.

Co-authored-by: Isaac <no-reply@databricks.com>
ADF discover built SourceGraphs only in memory and wrote the lossy inventory
projection. It now also writes metadata/source_graphs.json with
write_source_graphs (contract_version "1", per-graph and document hashes),
the same file Airflow discover writes, so a later phase can read exactly what
discover saw without parsing the source again. Convert does not read it yet.

Co-authored-by: Isaac <no-reply@databricks.com>
Docstring and comment wording only.

Co-authored-by: Isaac <no-reply@databricks.com>
…ntory records its hash) into stack/adf-discovery

Co-authored-by: Isaac <no-reply@databricks.com>
…he saved file

ADF discover now records each pipeline's detected motifs on its source graph
before writing source_graphs.json, so they are saved and hashed with the
graph, then projects inventory.json from those graphs and records the file's
document hash as source_graphs_sha256. Detection still runs once on the ADF
definitions and the profile report is unchanged.

Co-authored-by: Isaac <no-reply@databricks.com>
…e docstring fix) into stack/adf-discovery

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft matthewmoorcroft changed the title Phase 1 stack #2: ADF discovery — inventory + lineage + motifs Phase 1 stack #2: ADF discovery on the graph contract (persisted source graphs with motifs, inventory and lineage) Oct 5, 2026
@matthewmoorcroft matthewmoorcroft changed the title Phase 1 stack #2: ADF discovery on the graph contract (persisted source graphs with motifs, inventory and lineage) feat(adf): save ADF source graphs with motifs and build the inventory from them Oct 6, 2026
matthewmoorcroft added a commit that referenced this pull request Oct 6, 2026
…ntic-insights

Carries the #94 review fixes forward: ADF dataset identities qualified by file name
and literal server, parameterised parts never form an identity, the shared lineage
parser in ir_serde, and the source_graphs.json docs. The flowx-discover output row
for source_graphs.json conflicted with this branch's wording commit; the resolution
keeps both wordings.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 6, 2026
…agentic-component

Carries #94 and #95 forward: dataset identity fixes, the shared lineage parser, one
shared canonical hash (discovery_serde.canonical_sha256), hardened insights validation,
and the enrich wording and docs.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
…/routing-agentic

Carries #94, #95 and #96 forward: dataset identity fixes, the shared lineage parser,
one shared canonical hash (discovery_serde.canonical_sha256), hardened insights
validation and enrich wording, and the agentic component resource-key checks.

Conflicts kept both wordings: the AGENTS.md discover line matches phase1-integration,
and the MCP tool description takes #95's corrected enrich text with this branch's
route and fill_agentic entries. README listed source_insights.json twice after the
auto-merge; the two lines are combined into one that keeps both descriptions.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
…tegration

Brings in the #94 review fixes: ADF dataset identities qualified by file name and
literal server, parameterised parts never form an identity, the shared lineage parser
in ir_serde, and the source_graphs.json docs. The AGENTS.md discover line and the
flowx-discover output row conflicted with the later wording commits; both keep the
wording from each side.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 7 commits October 7, 2026 15:12
Unevaluated ARM template expressions ([parameters(...)], [concat(...)]) are now
treated as unresolved, so they never become dataset identities or false data edges.
File locations written as @dataset().x resolve against the call-site bindings, as
table names already did, so parameterised file datasets keep their valid edges; an
unbound part still gives no identity. A run-now of an existing job by ID is no longer
emitted as a cross-workflow control edge to the caller's own task key. The ADF
discover docs record that inventory.json deliberately has no generated_at timestamp.
Each fix has a regression test that fails on the previous code.

Co-authored-by: Isaac <no-reply@databricks.com>
… stack/adf-discovery

Carries #84's Codex fixes forward: signature-tier data edges claim no identity, and
empty policies survive the source-graph round trip.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 3 commits October 17, 2026 20:30
Greg's second review of #94:

- Build the folder and file-name signature from the dataset's location with
  the call-site values filled in, so bronze/<folder> and silver/<folder>
  never join. Only a reference that passes an anchored folderPath still gets
  a signature, so no new link appears; without a location folder the
  call-site value stands in, as before.
- Keep each runtime value in place as <P> (landing/<P> vs <P>/landing)
  instead of joining the literals and counting slots.
- Write FN[] rather than FN[None] when there is no file name.
- Ignore dataset or call-site parameters that are not a map instead of
  crashing discover.
- Record a null or non-string ExecutePipeline reference as an empty,
  unresolved child rather than "None".
- Return the store and whether the location names one from one helper.
- Say byte-identical inventory.json holds for the same --source-dir, and drop
  "or the dataset name" from the test docstring.

Co-authored-by: Isaac <no-reply@databricks.com>
Codex cross-review of the round-two fix: @concat('foo/', '@{left}') and
@concat('foo/', '@{right}') were read as interpolated strings and both
reduced to the same shape, so they could link. ADF reads a value that opens
with @ (but not @{) as a whole expression, where a quoted '@{x}' is text.

Co-authored-by: Isaac <no-reply@databricks.com>
Codex cross-review: pipeline().RunId, pipeline().TriggerTime, trigger()
outputs, linkedService() values, utcNow() and guid() left nothing in the
path shape, so @concat('landing/', pipeline().RunId) matched the literal
folder landing and could link to it (it did at 9ef2ab0 too). Every runtime
value is now a <P> slot, as the slot rule already says.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 12 commits October 17, 2026 22:08
Review of the default-deny shape: a call such as toLower(dataset().folderPath)
became a plain <P>, so a writer bound to orders and a reader bound to
customers both read bronze/<P> and linked. Such a call is now one slot keyed
by the call and its bound inputs, <P:toLower('orders')>. It is still never
path text and cannot anchor a signature, but two keyed slots match only when
the call and its inputs match. A file name made only of such a slot keeps its
key instead of collapsing to FN[]. The corpus now covers bound values too.

Co-authored-by: Isaac <no-reply@databricks.com>
…e rule

Codex cross-review of the keyed slot and the review pass:
- A number or boolean bound value keeps its text, so years 2024 and 2025
  are different folders, plain or wrapped in string().
- An apostrophe in a bound value is doubled inside a keyed slot, so
  O'Brien and O' Brien never share a key.
- A } inside quoted text no longer ends an @{...} block, so no argument
  text leaks into the path.
- @@landing is the literal folder @landing, as ADF reads it.
- The signature section now states the rule (only provably literal text
  forms the path; anything else is one slot) and points at the corpus test.

Co-authored-by: Isaac <no-reply@databricks.com>
…ame case in slot keys

Codex cross-review of the keyed slot:
- landing/@{2024} always prints 2024, so it is the literal folder
  landing/2024 and never joins a run-time year.
- ADF reads toLower and TOLOWER alike, so a keyed slot's key is lower-cased
  outside quotes and the two still join over the same bound value.

Co-authored-by: Isaac <no-reply@databricks.com>
Stack checklist B2 (missing data counted as resolved): lineage_from_dict
read a control edge with no resolved flag, or a non-boolean one such as
"false", as resolved. Only a saved resolved: true now reads back as
resolved. Also pins the discover console's activity total, which is the
inventory's own count including nested activities, as the PR describes.

Co-authored-by: Isaac <no-reply@databricks.com>
… once

Stack checklist review with Codex on #94's full diff:
- H3, blocking: a linked service named by an unevaluated expression such as
  [parameters('ls')] still gave a table or path identity when a definition
  was loaded under that key. The reference now names no linked service
  unless its name is plain, so it can never prove a store.
- F3: a motif detected twice was saved once in the inventory but counted
  twice in the profile report. Detections are de-duplicated once, so the
  graph and the report read the same list.
- D3/E1: the pipeline debug dict, which promises every field, now includes
  the lineage block.

Co-authored-by: Isaac <no-reply@databricks.com>
Brings in #84's second review round: fail-closed known_workflows, empty
identities as unresolved, the exact asset_type rule, and the strict envelope.

discovery_serde conflict: take #94's side, which reads lineage through the
shared ir_serde.lineage_from_dict, and keep #84's new asset_type sentence in
the module docstring. #84's wait_for_completion rule moves into ir_serde in
the next commit.

Co-authored-by: Isaac <no-reply@databricks.com>
…workflows

#84's final head reads a control edge's wait_for_completion as None unless it
is a real boolean. #94 reads lineage through the shared
ir_serde.lineage_from_dict, so the rule now lives there, covered by a direct
test that fails without it and by #84's source-graph tests.

#84 also makes an omitted known_workflows fail closed, so the IR test for a
RunJobActivity control edge passes the workflows it expects to resolve.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

The changes from the second review are applied at commit 846b51a. Rather than one more patch per case, the path signature now follows one rule.

What was wrong with the old approach. The signature kept quoted text plus a fixed list of known run-time values (pipeline(), item(), variables(), dataset(), activity()) and silently dropped everything else. A dropped value doesn't make the signature weaker; it makes a run-specific folder look literal. So @concat('landing/', rand(1,100)), pipeline()?.TriggerTime or item()['zone'] read as plain landing or raw/zone and linked to folders they aren't. Each review round found the next missing value, because adding one value to the list fixes only that value.

The rule now. Only provably literal text forms the path: quoted text in an expression, or text outside the @{...} blocks of an interpolated value. Any other value is one <P> slot where it sits, whatever function or reference it is, so nothing vanishes and no argument text leaks into the path. A function over a dataset() value the call site bound to a literal, such as toLower(dataset().folderPath), becomes a slot keyed by that value (<P:toLower('orders')>). It is still never path text, but orders and customers stay apart. The folder and file name are read from the dataset's own location, with the passed-in values spliced in raw and the slashes normalised once.

A1. An interpolated call-site folder such as landing/@{item().run} now gets a signature and links exactly like its @concat('landing/', ...) spelling. Those are correct links, so we accept them. The earlier note that no reference gains a signature is corrected accordingly: these interpolated folders gain one, and nothing else does.

Accuracy, and the guard. On 17 real examples (the false links found in review, the links that must keep working, including the unknown-store watermark tier, and your two cases), the result is 16 right with no invented link. 9ef2ab0 got 8 right with 5 invented. The one miss is a folder made only of a formatted date, which no longer links on its format string. tests/unit/test_adf_path_signature_corpus.py guards the rule: it builds 216 expressions from literal and run-time pieces in four spellings, plus bound-value cases. It asserts that a run-time value never reads as a literal folder and that no argument text leaks into the path. A new construct gets added to the corpus, not patched per value.

Your eight points:

  1. Folder signature ignored the dataset's folder: the shape is now read from the dataset's own location.folderPath with the passed-in values filled in, so bronze/orders and silver/orders no longer link. The call-site value is used only when the location sets no folder.
  2. Slot positions lost: each run-time value stays in place as <P>, so landing/<P> and <P>/landing differ. The |slots=N count is gone.
  3. parameters that are not a map, on the dataset or at the call site, are ignored instead of crashing discover.
  4. "pipeline": null, or any reference that is not an object or a name, records an empty, unresolved child. Before, it could resolve to an exported pipeline called None.
  5. _resolve_dataset_store now returns the store and whether the location names one, and _location_names_store is gone.
  6. A missing file name is written FN[].
  7. The doc says byte-identical inventory.json holds for the same --source-dir.
  8. "or the dataset name" is dropped from the test docstring.

Each fix has a regression test that fails before it.

I also went through the whole diff against #84 with your review points in mind, and fixed what that turned up: a control edge read back without resolved: true is unresolved, and a wait flag that is not true or false reads back as unset; an ARM-expression linked service name never proves a store; Fabric Lakehouse and ADLS Gen1 locations are qualified by their linked service, like SFTP, so two lakehouses with the same folder layout no longer link; repeated motif detections are counted once, on the inventory's rule, so the profile and the inventory agree; and the --debug pipeline dump includes the lineage block. #84's final head is merged in with a merge commit, not a rebase.

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The ADF-to-source-graph work is solid and well tested overall. Fix the wait-flag handling and the empty-Expression case before merging.

  • tests/unit/test_adf_discovery_mapping.py: Add tests for a null or non-boolean waitOnCompletion, and for a ForEach/Until with a Switch inside, so the bugs above can't come back unnoticed.

reference = reference.get("referenceName")
callee = reference if isinstance(reference, str) else ""
properties[INVOKES_WORKFLOW_PROPERTY] = callee
properties[INVOKES_WAIT_PROPERTY] = bool(type_properties.get("waitOnCompletion", True))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: bool(...) turns a null wait flag into False and the string "false" into True. Only keep real booleans and leave the rest unset, which matches how the shared reader already works.

wait = type_properties.get("waitOnCompletion", True)
if isinstance(wait, bool):
    properties[INVOKES_WAIT_PROPERTY] = wait

Comment on lines +164 to +166
def _names_any(settings: object, is_override_key: Callable[[str], bool]) -> bool:
"""``True`` when *settings* is a dict with a non-empty value under a key *is_override_key* accepts."""
return isinstance(settings, dict) and any(value and is_override_key(key) for key, value in settings.items())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: An Expression object with an empty value ({"type": "Expression", "value": ""}) counts as an override and wipes out the dataset's identity, while a bare "" correctly doesn't. Unwrap the expression before checking whether it's empty.

Comment on lines +749 to +765
The folder and file name are then read from the dataset's own ``location``, with
each ``dataset().<name>`` in them filled in from the reference's parameters
(call-site values first, then the dataset's declared defaults); a value that does
not read as text, such as ``null``, is left a run-time slot. So
``@concat('bronze/', dataset().folderPath)`` and ``@concat('silver/',
dataset().folderPath)`` stay apart even when both are called with ``folderPath:
"orders"``. When the location sets no folder or file name, or the dataset is not
in the export, the ``folderPath`` / ``fileName`` the call site passes stands in for
it. A file name with no literal text gives an empty ``FN[]``.

``None`` when the reference passes no anchored ``folderPath``, or the dataset's
own folder has no literal segment to anchor on.
"""
call_site = _call_site_parameters(dataset_ref)
passed_folder = _path_shape(call_site.get("folderPath"), {}, require_physical=True)
if passed_folder is None or not _has_literal_anchor(passed_folder):
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requiring a literal call-site folderPath looks intentional (there's a test for it), but this docstring and the PR description make it sound like the dataset's own location is enough. Make the wording match the rule.

Comment on lines +382 to +384
for case_value in declared_values:
case_label = default_case_label if case_value == "default" else case_value
branches[case_label] = [_activity_to_node(child, definitions) for child in parsed_cases.get(case_value, [])]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two Switch cases with the same value write to the same branch key, so the first case's activities get dropped without a warning. Rare, but worth a finding or a check that fails loudly.

Comment on lines +653 to +660
file_system, account = _resolve_file_store(location, linked_service, dataset_params, context)
folder_path = _resolved_part(location.get("folderPath"))
file_name = _resolved_part(location.get("fileName"))
if file_system is None or account is None or folder_path is None or file_name is None:
return None

relative_path = "/".join(part.strip("/") for part in (folder_path, file_name) if part.strip("/"))
return f"abfss://{file_system}@{account}.dfs.core.windows.net/{relative_path}".rstrip("/")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every file identity is written as abfss://…dfs.core.windows.net/…, including Azure Blob. So a Blob dataset and an ADLS Gen2 dataset with the same path would get linked. Use a prefix that matches the connector.


_ACCOUNT_NAME_RE = re.compile(r"AccountName=([A-Za-z0-9]+)", re.IGNORECASE)
_DATASET_PARAM_RE = re.compile(r"^@dataset\(\)\.([A-Za-z_][A-Za-z0-9_]*)$")
_ARM_EXPRESSION_RE = re.compile(r"^\[\s*[A-Za-z_][A-Za-z0-9_]*\s*\(.*\]$", re.DOTALL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This regex treats [Sales (2024)] as an ARM expression, so that table loses its identity. Only [Order Details] and [dbo].[Orders] are tested.

Comment on lines +1 to +44
"""Resolve ADF dataset references into source-neutral :class:`DataAsset` values.

This is the ADF half of data-lineage population (#62b). It re-homes the
dataset-identity resolver and the path-signature logic first written for the
closed #36 ADF-only lineage attempt, but emits the shared #61 two-tier
:class:`~flowx.models.ir.DataAsset` (``identity`` + ``signature``) instead of a
bespoke edge type, so the source-neutral join in :mod:`flowx.lineage` /
:mod:`flowx.discovery_lineage` does the matching.

Two tiers, exactly as #36 established them:

* **identity** -- the resolved physical location of the asset (a store-qualified
``<server>/schema.table`` or a concrete ``abfss://`` path down to the literal
file name). Present only when it resolves *deterministically*
from literals; a parameterised reference is never guessed at and leaves
``identity`` unset. This is the strong join key.
* **signature** -- the *path-derived* weak key. For an asset with a resolved
identity the signature mirrors that identity, so the weak tier can never join a
resolved asset to an unresolved one on a coincidence. For an unresolved reference
the signature is the *structural path signature* (#36's "expression" tier: the
dataset's folder and file name with their literal text kept in place -- quoted
text, numbers, ``concat`` pieces, and text outside ``@{...}`` -- and exactly one ``<P>`` slot
for every other run-time value or function call, wherever it sits, keyed by any
bound ``dataset()`` value it uses) when the folder has a literal anchor, prefixed
``ST[<store>]/`` when the dataset's store is known (see
:func:`_resolve_dataset_store`), so two datasets on different stores that merely
share a folder shape never join. An unknown store is
never guessed: the signature stays unqualified, and a qualified and an unqualified
signature never join. A dataset that names its store (a literal container or
bucket, or a server, share, lakehouse or account set on its linked service) but
cannot prove the rest of it gets no signature, since the folder shape alone cannot
tell two such stores apart.
It is **never** the bare dataset reference name: two unrelated opaque
references that merely share a name must not join (#36's explicit rule), so when
neither a physical identity nor a path-anchored signature is available the
signature is left empty and the asset cannot participate in signature matching.
The signature is also left empty when the activity overrides the dataset's
location at run time (a source query, stored procedure or ``storeSettings`` path
override on a read; a sink stored procedure on a write), because the dataset's
path is then not what the activity touches.

Only literal, provable values ever become an ``identity`` -- the resolver returns
``None`` rather than guessing, which is what stopped #36's spurious edges.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: At about 1,000 lines, much of it docstrings, this file is hard to follow. Consider splitting identity, store and signature into separate parts, merging _DATASET_PARAM_RE and _DATASET_PARAMETER_RE, cutting the issue-history docs down to short summaries, and shortening any verbose docstrings.

matthewmoorcroft and others added 11 commits October 20, 2026 05:55
{"type": "Expression", "value": ""} under a source query, a sink stored
procedure or a storeSettings path key counted as an override and wiped the
dataset identity, while a bare "" did not. The override check now unwraps an
Expression to its value first, through one small helper also used by the
other places that unwrapped by hand.

Co-authored-by: Isaac <no-reply@databricks.com>
An ExecutePipeline waitOnCompletion is recorded only when it is a real
boolean, so null no longer reads as False and "false" no longer as True; the
wait stays unknown, matching the shared control-edge reader. A missing flag
still takes the translator's default, True.

Two Switch cases with one value can keep only one branch (the AST keeps the
last). The Switch node now lists each lost case and its activities under
duplicate_switch_cases, and discover logs a warning naming the pipeline.
Tests also cover a Switch nested in a ForEach or Until.

Co-authored-by: Isaac <no-reply@databricks.com>
The ARM check matched any "[name (" so a bracket-quoted SQL name such as
[Sales (2024)] read as an unevaluated ARM expression and lost its identity.
A bracketed value is now ARM only when it calls one of ARM's own template
functions (case-insensitive, any spacing), a list* function, or a
namespace.function the template defines. The namespaced form was missed
before and is now caught too.

Co-authored-by: Isaac <no-reply@databricks.com>
A storage account name is unique across Azure and the Blob and ADLS Gen2
connectors are two endpoints onto that one account, so a Blob writer and an
ADLS Gen2 reader of the same container and path touch the same file. The
shared abfss:// identity is kept on purpose; the docstrings now say so, and a
test pins that the same account and container link through either connector
while a different account or container does not.

Co-authored-by: Isaac <no-reply@databricks.com>
The _path_signature docstring and the module docstring read as if the
dataset's own location were enough for a signature. The rule, already tested,
needs both: the reference passes a folderPath with literal text, and the
folder it resolves to has a literal segment. The docstrings now state both.

Co-authored-by: Isaac <no-reply@databricks.com>
One _DATASET_PARAMETER_RE now serves both the whole-value @dataset().x
lookup and the path shapes, so @dataset()?.x (ADF's safe navigation) also
resolves to its bound value, with a test. The module docstring and the four
longest function docstrings are cut to what the code does today, without the
history of earlier issues.

Co-authored-by: Isaac <no-reply@databricks.com>
A move only: every function and constant keeps its exact body (checked by
comparing the AST of each definition before and after). dataset_identity
holds the physical identity and the value helpers it shares, dataset_store
the store that qualifies a signature, path_signature the structural
signature, and dataset_lineage puts them together for each read and write.
activity_data_assets and resolve_dataset_identity are still imported from
dataset_lineage, the only names #95 to #97 use.

Co-authored-by: Isaac <no-reply@databricks.com>
Brings in #84's third review round: nested containers, the asset_type check
on both lineage tiers, the wait-aware control-edge dedupe, and the lineage
types moved to flowx.models.lineage.

Resolution, as in #84's carry trial: discovery_serde.py and lineage.py take
#94's side (lineage reads back through ir_serde.lineage_from_dict, and the IR
builders stay), with #84's new asset_type wording kept and docstring
references pointed at flowx.models.lineage. models/ir.py takes #84's side,
which drops the moved classes and keeps the re-exports; #94's MotifAnnotation
sentence about Activity.motif_id moves into models/lineage.py.

Co-authored-by: Isaac <no-reply@databricks.com>
Cross-review found two ways the round-three fixes failed open. Unwrapping
every object to its "value" made a non-empty, non-Expression query object
(a Key Vault reference, say) read as empty, so it no longer overrode the
read; only an Expression-shaped object is unwrapped now. And an ARM call to a
function missing from the list (distinct, or a newer one) read as a literal
name; a bracketed call whose arguments hold a quoted string or another call
is now ARM too, which [Sales (2024)] still is not.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

Thanks, Greg. Round three is applied up to 337d2d6, one line per comment:

  1. Wait flag (blocking): only a real boolean waitOnCompletion is recorded, using your snippet; null, "false", numbers and Expression objects leave the wait unknown, as the shared reader expects. A missing flag still takes the translator's default, True. Tests cover each value, plus a Switch inside a ForEach and an Until. (cb4ddbc)
  2. Empty Expression override (blocking): the override check unwraps an Expression to its value first, so {"type": "Expression", "value": ""} overrides nothing, like a bare "", for a source query, a sink stored procedure and a storeSettings path. A non-empty Expression, or any other query object such as a Key Vault reference, still overrides. (a0d0931, 8f4f6bd)
  3. Call-site folderPath wording: the _path_signature and module docstrings now say a signature needs both a call-site folderPath with literal text and a literal segment in the folder it resolves to. The PR description says the same. (2f03836)
  4. Duplicate Switch case values: nothing is dropped now. Conversion already converted every case, so the parser now adds a repeated value's activities to the same list instead of replacing them, and the graph keeps each declared case as its own branch (full, then case:full). So the AST, the profile count, the graph and conversion all keep every activity. The Switch node lists the repeated value and its branch labels under duplicate_switch_cases, and discover logs a warning naming the pipeline. The bundle also gives a repeated value its own condition task key, which used to collide. Tested, including an empty last duplicate. (cb4ddbc, 38a0469, 337d2d6)
  5. Blob vs ADLS Gen2 prefix: I kept one abfss:// identity for both, on purpose. A storage account name is unique across Azure, and the Blob and ADLS Gen2 connectors are two endpoints onto the same account, so the same container and path are the same files through either one. A Copy writing through the Blob connector and a notebook reading the same container through ADLS should link, and a wasbs:// prefix for Blob would break that link. The docstring says this now, and a test pins it: the same account and container link through either connector; a different account or container does not. If you'd still rather have wasbs:// for Blob, I'm happy to switch. (3ff83e4)
  6. [Sales (2024)]: a bracketed value is read as ARM when it calls one of ARM's own template functions (any case or spacing), a list* function or a namespaced user function, or when its arguments hold a quoted string or another call, which only ARM's do. So [Sales (2024)] keeps its identity, tested alongside [Order Details] and [dbo].[Orders], while [ns.fn(...)] (missed before) and an ARM function missing from the list still read as ARM. (936db3b, 8f4f6bd)
  7. File size: _DATASET_PARAM_RE and _DATASET_PARAMETER_RE are merged, so @dataset()?.x now resolves too (tested). The issue-history docs are cut to short summaries, and the longest docstrings are shortened (a519535). The module is then split into dataset_identity.py, dataset_store.py, path_signature.py and dataset_lineage.py, which puts them together. That commit only moves code: every definition's body is unchanged, checked by comparing each one's AST before and after (0145059).

The behaviour fixes (1, 2, 4, 6 and the ?. lookup) each have a regression test that fails before them; the tests for 5 pin the current behaviour. #84's round three is merged in with a merge commit (0813081), and the full unit suite, ruff and mypy pass.

This branch has not been deployed

No deployments
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.

2 participants