Repository navigation
feat(adf): save source graphs with dataset lineage at ADF discover - #94
matthewmoorcroft wants to merge 72 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
|
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] = waitThere was a problem hiding this comment.
Fixed in cb4ddbc with the suggested snippet: only a real boolean waitOnCompletion is recorded, and null, "false", numbers and Expression objects leave it unknown. Tests cover each value, plus a Switch inside a ForEach and an Until.
| 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.
There was a problem hiding this comment.
Fixed in 2f03836: the _path_signature and module docstrings say a signature needs both a call-site folderPath with literal text and a literal segment in the folder it resolves to, and the description says the same.
| 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.
There was a problem hiding this comment.
Kept on purpose in 3ff83e4, with the reason in the docstring and a test: a storage account name is unique across Azure and both connectors reach that one account, so the same container and path are the same files either way. A different account or container still doesn't link.
|
|
||
| _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.
There was a problem hiding this comment.
| """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 |
An ADF REST GET adds a pipeline's lastPublishTime and empty annotations, parameters, variables, dependsOn and userProperties that a Git or ARM export leaves out. The ADF mapping kept them in each graph's and node's raw dict and in the trigger properties, so the same factory exported another way, or republished with no real change, got a new source_graphs_sha256, and enrich insights and conversion plans bound to the old hash were refused. The mapping now drops exactly the set main's ADF corpus already treats as meaningless: the pipeline's lastPublishTime and its empty annotations, parameters and variables; empty dependsOn and userProperties on every activity, nested ones included; and empty annotations on each trigger copy. Non-empty values, and every other field, are kept as written. Tests compare a live-GET form with the export form of one factory (same graphs, same hash) and check that non-empty values stay. Co-authored-by: Isaac <no-reply@databricks.com>
A pipeline from an ADF REST GET is wrapped as {id, name, type, etag,
properties}, and ADF issues a new etag on every publish. The mapping kept
id, type and etag in each graph's raw dict, so a republish with no real
change still moved source_graphs_sha256, and a REST export still differed
from a Git or ARM export of the same factory. Nothing in flowx reads those
three fields. They are now dropped; name and properties stay as before.
Tests check that a REST envelope and the Git form give the same graphs and
hash, and that a changed etag alone keeps the hash.
Co-authored-by: Isaac <no-reply@databricks.com>
ghanse
left a comment
There was a problem hiding this comment.
Thanks for working through the earlier comments. They're all fixed or explained now, and splitting dataset_lineage.py into separate identity, store and signature modules reads much better.
One thing left before merge: server, database, url and sasUri need to be unwrapped from ADF Expression objects the same way connectionString already is. It's a small fix and makes the handling consistent.
| return None | ||
| connection_fields = _connection_string_fields(connection_string) if isinstance(connection_string, str) else {} | ||
|
|
||
| server = type_props.get("server") or connection_fields.get("server") or connection_fields.get("data source") |
There was a problem hiding this comment.
connectionString gets unwrapped from an ADF Expression object, but server and database don't. So a linked service with "server": {"type": "Expression", "value": "sql.example.com"} gets no table identity at all, which is worse than what a plain string or even a Key Vault secret gets. Run each one through _expression_value:
server = _expression_value(type_props.get("server")) or connection_fields.get("server") or connection_fields.get("data source")url and sasUri in _resolve_storage_account (around lines 379 and 387) have the same gap.
There was a problem hiding this comment.
Thanks, this is in at 7002d89 (fix in fe636eb): server, database, url and sasUri now go through _expression_value like connectionString, so a wrapped literal gets the same identity as a plain string, and a wrapped parameter or ARM expression still gets none. One extra guard came with it, tightened in f04a6ff, ddb10cd and 7be38b9: exports mask SecureString values as **********, so a server, database, container or storage account name containing * is rejected rather than becoming a store name every masked linked service would share.
A linked service can wrap these fields in an Expression (or SecureString) object, as it already can connectionString. Each is now unwrapped the same way and then checked like a plain string, so a wrapped literal gets its table or path identity while a wrapped parameter reference still gets none. A masked SecureString server or database (**********) names no store, so masked servers never share one identity. Co-authored-by: Isaac <no-reply@databricks.com>
…names An object of any other type carrying a value is now left whole, so its value never becomes a server, database or storage account (it names no store, still overrides a read, and is not a literal in a signature). A server or database containing * is a masked secret, not a store name, and a storage account taken from url, sasUri or AccountName= must be letters and digits only, so two masked stores no longer share a signature. Co-authored-by: Isaac <no-reply@databricks.com>
A dataset folderPath or fileName the code cannot read (a list, or an object that is not an Expression) used to fall back to the call-site folder, so it could join a real path with the same shape; since the stricter unwrap this included unknown typed objects. It now gives no signature. A file system, container or bucket containing * is masked, not a store name, like a server or database. Docstrings now mention the untyped value object the unwrap also accepts. Co-authored-by: Isaac <no-reply@databricks.com>
Rejecting a file system holding * left the location looking as if it named no store, so two masked containers on different accounts fell back to the same unqualified folder signature and linked; before this round their account-qualified signatures differed. A masked file system now names a store that cannot be proven, so it gets no signature. Co-authored-by: Isaac <no-reply@databricks.com>
…-service stand-in rule
…entic-component Co-authored-by: Isaac <no-reply@databricks.com>
Brings #96's three fixes (authored component tasks declare and check the variables and files they name, a reloaded component field of the wrong shape is refused, parameter approximations reach SETUP.md), #95's enrich lock fix and #94's round-four fix up into #97, on top of #97's own fixes. Co-authored-by: Isaac <no-reply@databricks.com>
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
id,typeandetag(a new etag on every publish); itslastPublishTime; emptyannotations,parametersandvariableson a pipeline; emptydependsOnanduserPropertieson any activity; an emptyannotationson a trigger), so a live export and a Git or ARM export of the same factory give the same source graphs andsource_graphs_sha256.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.