Repository navigation
feat(adf): save source graphs with dataset lineage at ADF discover - #94
matthewmoorcroft wants to merge 64 commits into
Conversation
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>
…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>
…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>
…/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>
…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>
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>
…enerated_at doc note
…ds and ARM storage URLs
…erridden datasets
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>
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>
…age module docstring
|
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 ( The rule now. Only provably literal text forms the path: quoted text in an expression, or text outside the A1. An interpolated call-site folder such as 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. Your eight points:
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 |
ghanse
left a comment
There was a problem hiding this comment.
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 anullor non-booleanwaitOnCompletion, 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)) |
There was a problem hiding this comment.
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| 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()) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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, [])] |
There was a problem hiding this comment.
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.
| 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("/") |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
This regex treats [Sales (2024)] as an ARM expression, so that table loses its identity. Only [Order Details] and [dbo].[Orders] are tested.
| """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. | ||
| """ |
There was a problem hiding this comment.
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.
{"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>
|
Thanks, Greg. Round three is applied up to 337d2d6, one line per comment:
The behaviour fixes (1, 2, 4, 6 and the |
Intent
Put ADF discovery on the source-neutral discovery graph contract from #84. The ADF loader projects each pipeline into a
SourceGraph, savessource_graphs.jsonfirst (versioned, hashed, motifs inside), and buildsinventory.jsonthrough the shared emitter, which records the graphs' hash. ADF data lineage is derived conservatively: only provable physical values become dataset identities.What changed
waitOnCompletionis recorded (nullor 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 oneabfss://identity on purpose, since both reach the same storage account;@dataset()?.xresolves 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.parametersare ignored instead of crashing discover; a null or non-name ExecutePipeline reference stays unresolved; a control edge read back withoutresolved: trueis 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--debugpipeline dump includes the pipeline's lineage block.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.bucketNamenow 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-identicalinventory.jsonwith nogenerated_at. In the same style: an unevaluated ARM or{{placeholderfolderPathno longer anchors a path signature, an unevaluated ARMfileNameis 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-sidebuild_control_edgestakes the same known-pipelines check.sources/adf/loader.py:source_graphs.json, theninventory.jsonthroughbuild_source_inventorywithsource_graphs_sha256.inventory.jsondeliberately has no top-levelgenerated_at, so the same export, given the same--source-dir, always gives the same bytes. This is documented inskills/flowx-discover/sources/adf.md.sources/adf/discovery_mapping.py: maps the ADF AST toSourceGraphone to one.defaultis keyedcase:default(kept unique), so it never collides with the Switch's own default branch.sources/adf/dataset_lineage.py(withdataset_identity.py,dataset_store.pyandpath_signature.py, split in round three): dataset identities.@dataset().xlocation parts resolve against the call-site bindings.[dbo].[Orders]stay valid.folderPathwith literal text at the call site; the dataset's ownlocationis not enough on its own. The folder and file name are then read from the dataset'slocationwith 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 bounddataset()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@concatspelling.tests/unit/test_adf_path_signature_corpus.pyguards the rule.<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.storeSettingswildcard, file-list or prefix paths; a sourcequery,*Queryor*StoredProcedureName; and a sink*StoredProcedureName.models/ir.py,lineage.py,ir_serde.py,bundler/dab_writer.py):Activity.data_reads,data_writesandmotif_id, andPipeline.lineage), thebuild_*andwith_lineagefunctions, their IR serialisation, and package's read-back of them indab_writer.py.source_type_hint.ir_serde.lineage_from_dict, is used in production bydiscovery_serde.--adf-source-path), and labels the activity count "raw, incl. nested". This affects console output only; nothing on disk changes.flowx-discoverskill describesource_graphs.jsonand the inventory hash.How it was tested
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 currentmain(858550e) is byte-identical tomaintoo, with the full unit suite passing. Only the ADF inventory changes, gaining the shared emitter's fields,source_graphs_sha256and each activity'stask_key.defaultlabel 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 tomainonce #84 has merged. The retarget needs a one-line import resolution inbundler/dab_writer.py: keep bothfrom flowx.ir_serde import data_asset_from_dict, lineage_from_dictand main'sfrom flowx.models.dab import DabNotebook, SecretInstruction, SetupTask. #95, #96 and #97 follow in order.This pull request and its description were written by Isaac.