Skip to content

Commit 8269aeb

Browse files
committed
fix: address various PR concerns and comments
1 parent 11282a3 commit 8269aeb

13 files changed

Lines changed: 375 additions & 61 deletions

File tree

‎messages/lineage.md‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Generate field-level dataLineage for a Data Code Extension %s package.
44

55
# description
66

7-
Runs the package's entrypoint against a Salesforce org with every write intercepted, traces each written field back to the fields it was computed from, and replaces the dataLineage section of the package's config.json with the result. Nothing is written to Data Cloud. Run 'scan' first so the read and write permissions in config.json are current. Fields whose lineage can't be traced are listed and get no lineage unless you add entries by hand.
7+
Runs the package's entrypoint against a Salesforce org with its Data Cloud writes intercepted, traces each written field back to the fields it was computed from, and replaces the dataLineage section of the package's config.json with the result. Nothing is written to Data Cloud objects, but other calls the script makes still run for real with the org's credentials, including named credential requests, LLM gateway calls, and Einstein predictions. Streaming scripts aren't supported. Run 'scan' first so the read and write permissions in config.json are current. Fields whose lineage can't be traced are listed and get no lineage unless you add entries by hand.
88

99
Generated lineage is a best-effort analysis of your script and may be incomplete or wrong (for example, for UDFs or other code the tool can't see into). It determines how governance tags propagate, which can restrict or open access to data. You're responsible for reviewing and maintaining it. Before config.json is written, you must accept this, either at the prompt or with --accept-lineage-ownership.
1010

@@ -48,12 +48,22 @@ Successfully authenticated with org '%s'
4848

4949
# info.generating
5050

51-
Running package to trace lineage. Nothing is written to Data Cloud...
51+
Running package to trace lineage. Data Cloud writes are intercepted; other external calls the script makes still run...
5252

5353
# info.summary
5454

5555
Generated %s dataLineage entries with %s (target, source) field pairs: +%s added, -%s removed, ~%s changed.
5656

57+
# info.removed
58+
59+
Entries in config.json that the generated lineage drops:
60+
%s
61+
62+
# info.changed
63+
64+
Entries in config.json whose source fields the generated lineage changes:
65+
%s
66+
5767
# info.noSources
5868

5969
%s target fields are computed from no source field (for example, literals) and get no lineage:
@@ -95,13 +105,17 @@ Lineage generation completed successfully!
95105

96106
# warning.permissionGaps
97107

98-
The generated lineage references objects that aren't in config.json permissions, so deploy would fail (1041/1042). Run 'data-code-extension script scan' first, then generate lineage again.
108+
The generated lineage references objects that aren't in config.json permissions, so deploy would fail (1041/1042). Run 'data-code-extension script scan' first, then generate lineage again. Scan can't detect objects whose names your script builds at run time; add those to permissions in config.json by hand.
99109
%s
100110

101111
# warning.emptyResult
102112

103113
No lineage could be generated, so config.json was not modified. The package follows the no-lineage path unless you add dataLineage entries by hand.
104114

115+
# warning.emptyResultExisting
116+
117+
No lineage could be generated, so config.json was not modified. Its existing %s dataLineage entries still apply and are deployed with the package. Review or remove them in '%s'.
118+
105119
# warning.replace
106120

107121
This replaces the existing dataLineage section. Any entries you added or edited by hand will be lost. Lineage determines how governance tags propagate to the target; review the result before deploying.
@@ -126,6 +140,14 @@ Accept and replace the existing dataLineage in config.json?
126140

127141
Lineage can only be generated for script packages; '%s' is a function package.
128142

143+
# error.streamingPackage
144+
145+
Lineage can't be generated for streaming scripts; '%s' has a streamingSource in config.json.
146+
147+
# error.configUnwritable
148+
149+
Can't write '%s': %s
150+
129151
# error.outputFileCollision
130152

131153
--output-file '%s' is inside the package's payload or project metadata, or is one of its config files. Choose a path outside the package's payload directory.
@@ -152,8 +174,8 @@ Writing generated lineage to config.json requires accepting ownership of it.
152174

153175
# actions.ownershipNotAccepted
154176

155-
- Review the generated lineage, then re-run with --accept-lineage-ownership to accept responsibility for it and write it to config.json.
156-
- Re-run with --dry-run to preview the generated lineage without changing config.json.
177+
- Re-run with --accept-lineage-ownership to accept responsibility for the generated lineage and write it to config.json.
178+
- Re-run with --dry-run to preview the generated lineage, or check it against config.json, without writing.
157179

158180
# error.replaceNotConfirmed
159181

@@ -162,7 +184,7 @@ config.json already has a different dataLineage section, and replacing it needs
162184
# actions.replaceNotConfirmed
163185

164186
- Re-run with --no-prompt to replace the existing dataLineage.
165-
- Re-run with --dry-run to preview the generated lineage without changing config.json.
187+
- Re-run with --dry-run to preview the generated lineage, or check it against config.json, without writing.
166188

167189
# error.sdkTooOld
168190

@@ -203,7 +225,7 @@ Target Salesforce org to read data from.
203225

204226
# flags.targetOrg.description
205227

206-
The alias or username of the Salesforce org whose data the package reads while lineage is traced. The org must have Data Cloud enabled and appropriate permissions. Nothing is written to the org.
228+
The alias or username of the Salesforce org whose data the package reads while lineage is traced. The org must have Data Cloud enabled and appropriate permissions. Nothing is written to Data Cloud objects in the org, but other calls the script makes, such as named credential requests, LLM gateway calls, and Einstein predictions, still run against it.
207229

208230
# flags.configFile.summary
209231

@@ -215,11 +237,11 @@ Optional path to an SDK config.yaml that overrides the reader and writer configu
215237

216238
# flags.dependencies.summary
217239

218-
Dependencies override for the run.
240+
Modules to import before the run.
219241

220242
# flags.dependencies.description
221243

222-
Optional comma-separated list of Python package dependencies to use during the run, overriding those defined in the package's requirements.txt.
244+
Optional comma-separated list of Python modules (or module.attribute) to import before the entrypoint runs. Use import names, such as 'yaml' rather than 'pyyaml'. Nothing is installed: each module must already be installed in the active Python environment.
223245

224246
# flags.dryRun.summary
225247

@@ -243,12 +265,12 @@ Replace existing dataLineage without prompting.
243265

244266
# flags.noPrompt.description
245267

246-
Skips the confirmation before replacing a dataLineage section that differs from the generated one. Required to replace existing lineage when running non-interactively or with --json. Doesn't accept ownership of the generated lineage; use --accept-lineage-ownership for that.
268+
Skips the confirmation before replacing a dataLineage section that differs from the generated one. Required when running non-interactively or with --json and config.json already has dataLineage; checked before the run. Doesn't accept ownership of the generated lineage; use --accept-lineage-ownership for that.
247269

248270
# flags.acceptLineageOwnership.summary
249271

250272
Accept responsibility for the generated lineage without prompting.
251273

252274
# flags.acceptLineageOwnership.description
253275

254-
Confirms that you understand the generated lineage is best-effort and may be incomplete or wrong, that it determines how governance tags propagate, and that you're responsible for reviewing and maintaining it. Skips the ownership prompt. Required to write lineage when running non-interactively or with --json.
276+
Confirms that you understand the generated lineage is best-effort and may be incomplete or wrong, that it determines how governance tags propagate, and that you're responsible for reviewing and maintaining it. Skips the ownership prompt. Required when running non-interactively or with --json, unless you use --dry-run; checked before the run.

‎messages/run.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ Authentication with Salesforce org '%s' failed while running the package.
9898
- Confirm the org alias or username passed to --target-org is correct.
9999
- Verify the org has Data Cloud enabled and your user has the required permissions.
100100

101+
# error.runTerminated
102+
103+
The package run was stopped by signal %s before it finished. Runs time out after %s seconds.
104+
105+
# actions.runTerminated
106+
107+
- If the run timed out, reduce the data it processes (for example, the reader's default_row_limit in an SDK config file) or check for a step that hangs, such as a network call.
108+
- If the process ran out of memory or was stopped by another program, free resources and try again.
109+
101110
# error.runProcessStartFailed
102111

103112
Could not start the Python interpreter '%s': %s
@@ -149,8 +158,8 @@ Optional path to a JSON config file that provides input payload for the run. Def
149158

150159
# flags.dependencies.summary
151160

152-
Dependencies override for the run.
161+
Modules to import before the run.
153162

154163
# flags.dependencies.description
155164

156-
Optional comma-separated list of Python package dependencies to use during the run, overriding those defined in the package's requirements.txt.
165+
Optional comma-separated list of Python modules (or module.attribute) to import before the entrypoint runs. Use import names, such as 'yaml' rather than 'pyyaml'. Nothing is installed: each module must already be installed in the active Python environment.

‎messages/scan.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ dataLineage references objects that are no longer in permissions (%s entries aff
119119

120120
Write the updated permissions to config.json anyway?
121121

122-
# info.staleLineageNotWritten
122+
# error.staleLineageNotWritten
123123

124-
No files were modified. Update dataLineage, then scan again.
124+
The updated permissions weren't confirmed, so no files were modified. Update dataLineage, then scan again.
125125

126126
# error.staleLineageNotConfirmed
127127

‎src/base/lineageBase.ts‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,20 @@ export abstract class LineageBase extends SfCommand<LineageGenerateResult> {
8787
throw new SfError(messages.getMessage('error.functionPackage', [flags.entrypoint]), 'LineageFunctionPackage');
8888
}
8989
// Read config.json up front so a missing or malformed file fails before the run.
90-
await readConfigJson(configPath);
90+
const initialConfig = await readConfigJson(configPath);
91+
if (initialConfig.streamingSource) {
92+
throw new SfError(messages.getMessage('error.streamingPackage', [flags.entrypoint]), 'LineageStreamingPackage');
93+
}
94+
// Fail before the run on everything that would only refuse the write afterwards.
95+
if (!flags['dry-run']) {
96+
this.checkNonInteractiveWrite(messages, flags, hasLineageSection(initialConfig));
97+
await fs.access(configPath, fsConstants.W_OK).catch((err: Error) => {
98+
throw new SfError(
99+
messages.getMessage('error.configUnwritable', [configPath, err.message]),
100+
'LineageConfigUnwritable'
101+
);
102+
});
103+
}
91104

92105
const outputFile = flags['output-file'] ? path.resolve(flags['output-file']) : undefined;
93106
if (outputFile) {
@@ -112,7 +125,7 @@ export abstract class LineageBase extends SfCommand<LineageGenerateResult> {
112125
);
113126
}
114127
// Fail before the run rather than lose its result to a bad path afterwards.
115-
await fs.access(path.dirname(outputFile), fsConstants.W_OK).catch((err: Error) => {
128+
await checkOutputFileWritable(outputFile).catch((err: Error) => {
116129
throw new SfError(
117130
messages.getMessage('error.outputFileUnwritable', [outputFile, err.message]),
118131
'LineageOutputFileUnwritable'
@@ -179,7 +192,12 @@ export abstract class LineageBase extends SfCommand<LineageGenerateResult> {
179192
};
180193

181194
if (entries.length === 0) {
182-
this.warn(messages.getMessage('warning.emptyResult'));
195+
// Existing entries still ship at deploy; say so rather than imply the no-lineage path.
196+
this.warn(
197+
current.length > 0
198+
? messages.getMessage('warning.emptyResultExisting', [current.length, configPath])
199+
: messages.getMessage('warning.emptyResult')
200+
);
183201
return result;
184202
}
185203

@@ -233,6 +251,12 @@ export abstract class LineageBase extends SfCommand<LineageGenerateResult> {
233251
diff.changed.length,
234252
])
235253
);
254+
if (diff.removed.length > 0) {
255+
this.log(messages.getMessage('info.removed', [bullets(diff.removed)]));
256+
}
257+
if (diff.changed.length > 0) {
258+
this.log(messages.getMessage('info.changed', [bullets(diff.changed)]));
259+
}
236260
if (lineage.noSources.length > 0) {
237261
this.log(messages.getMessage('info.noSources', [lineage.noSources.length, bullets(lineage.noSources)]));
238262
}
@@ -277,13 +301,36 @@ export abstract class LineageBase extends SfCommand<LineageGenerateResult> {
277301
);
278302
}
279303
const prompt = needsOwnership
280-
? replacing
304+
? needsReplace
281305
? 'prompt.acceptOwnershipReplace'
282306
: 'prompt.acceptOwnership'
283307
: 'prompt.replace';
284308
return this.confirm({ message: messages.getMessage(prompt), defaultAnswer: false, ms: LINEAGE_CONFIRM_TIMEOUT_MS });
285309
}
286310

311+
/**
312+
* Without a prompt to fall back on, a write needs its flags up front: fail before the run
313+
* instead of after it. `--dry-run` is the way to check lineage without writing.
314+
* confirmWrite repeats these checks in case config.json gains a dataLineage section during the run.
315+
*/
316+
private checkNonInteractiveWrite(messages: Messages<string>, flags: BaseLineageFlags, replacing: boolean): void {
317+
if (!this.jsonEnabled() && process.stdin.isTTY) return;
318+
if (!flags['accept-lineage-ownership']) {
319+
throw new SfError(
320+
messages.getMessage('error.ownershipNotAccepted'),
321+
'LineageOwnershipNotAccepted',
322+
messages.getMessages('actions.ownershipNotAccepted')
323+
);
324+
}
325+
if (replacing && !flags['no-prompt']) {
326+
throw new SfError(
327+
messages.getMessage('error.replaceNotConfirmed'),
328+
'LineageReplaceNotConfirmed',
329+
messages.getMessages('actions.replaceNotConfirmed')
330+
);
331+
}
332+
}
333+
287334
protected abstract getCodeType(): 'script';
288335
protected abstract getMessages(): Messages<string>;
289336
}
@@ -356,6 +403,20 @@ async function realPath(file: string, hops = 0): Promise<string> {
356403
}
357404
}
358405

406+
/**
407+
* Rejects an --output-file that `fs.writeFile` would fail on: an existing target must be a
408+
* writable regular file, and a new one needs a writable parent directory.
409+
*/
410+
async function checkOutputFileWritable(outputFile: string): Promise<void> {
411+
const stat = await statOrUndefined(outputFile);
412+
if (stat === undefined) {
413+
await fs.access(path.dirname(outputFile), fsConstants.W_OK);
414+
return;
415+
}
416+
if (!stat.isFile()) throw new Error('not a regular file');
417+
await fs.access(outputFile, fsConstants.W_OK);
418+
}
419+
359420
function bullets(lines: string[]): string {
360421
return lines.map((line) => ` - ${line}`).join('\n');
361422
}

‎src/base/scanBase.ts‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,13 @@ export abstract class ScanBase extends SfCommand<ScanResult> {
7777
if (hasStaleLineage(stale)) this.warnStaleLineage(messages, executionResult.config, stale);
7878
}
7979

80+
// Exit non-zero so a following deploy doesn't ship the old permissions.
8081
if (executionResult.written === false) {
81-
this.log(messages.getMessage('info.staleLineageNotWritten'));
82-
return {
83-
success: false,
84-
pythonInfo,
85-
packageInfo,
86-
codeType,
87-
workingDirectory: workingDir,
88-
executionResult,
89-
message: messages.getMessage('info.staleLineageNotWritten'),
90-
};
82+
throw new SfError(
83+
messages.getMessage('error.staleLineageNotWritten'),
84+
'ScanStaleLineageNotWritten',
85+
messages.getMessages('actions.staleLineageNotConfirmed')
86+
);
9187
}
9288

9389
this.log(messages.getMessage('info.scanExecuted', [workingDir]));

‎src/utils/lineageConfig.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ export function countEntriesReferencing(config: Record<string, unknown>, objects
152152
).length;
153153
}
154154

155-
/** Write config.json with a 2-space indent and a trailing newline. */
155+
/** Write config.json with a 2-space indent and no trailing newline, as scan and init do. */
156156
export async function writeConfigJson(configPath: string, config: Record<string, unknown>): Promise<void> {
157-
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
157+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
158158
}
159159

160160
function objectOf(field: string): string {

0 commit comments

Comments
 (0)