Skip to content

Commit 88cc8ec

Browse files
committed
feat: warn on unbound shell variables
1 parent 4c8c9c7 commit 88cc8ec

2 files changed

Lines changed: 141 additions & 23 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { FlowshWorkflow } from '../dsl/types.js';
4+
import { generateShellScript } from './shell-generator.js';
5+
6+
const createWorkflow = (command: string, environmentVariables: string[] = []): FlowshWorkflow => {
7+
return {
8+
metadata: { name: 'Test Workflow' },
9+
environment_variables: environmentVariables.map(variable => ({ variable })),
10+
graph: {
11+
nodes: [
12+
{ id: 'start', type: 'start', data: { title: 'Start' } },
13+
{ id: 'code', type: 'code', data: { command } },
14+
{ id: 'end', type: 'end', data: { title: 'End' } },
15+
],
16+
edges: [
17+
{ id: 'start-to-code', source: 'start', target: 'code' },
18+
{ id: 'code-to-end', source: 'code', target: 'end' },
19+
],
20+
},
21+
};
22+
};
23+
24+
describe('generateShellScript', () => {
25+
it('warns about potentially unbound variables referenced in the script', () => {
26+
const workflow = createWorkflow('echo "$UNBOUND_VAR"');
27+
const result = generateShellScript(workflow);
28+
29+
expect(result.success).toBe(true);
30+
expect(result.warnings).toContain(
31+
"Potentially unbound variable 'UNBOUND_VAR' referenced in generated script"
32+
);
33+
});
34+
35+
it('does not warn when variables are declared in environment_variables', () => {
36+
const workflow = createWorkflow('echo "$KNOWN_VAR"', ['KNOWN_VAR']);
37+
const result = generateShellScript(workflow);
38+
39+
expect(result.success).toBe(true);
40+
expect(result.warnings.some(warning => warning.includes('KNOWN_VAR'))).toBe(false);
41+
});
42+
43+
it('does not warn for known shell variables', () => {
44+
const workflow = createWorkflow('echo "$BASH_VERSION"');
45+
const result = generateShellScript(workflow);
46+
47+
expect(result.success).toBe(true);
48+
expect(result.warnings.some(warning => warning.includes('BASH_VERSION'))).toBe(false);
49+
});
50+
});

src/generation/shell-generator.ts

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,10 @@ export function generateShellScript(
255255
progressTracker.complete();
256256
}
257257

258-
const provisionalScript = scriptParts.filter(part => part.trim() !== '').join('\n\n');
259-
const internalVariableExports = collectInternalVariableExports(provisionalScript);
260-
if (internalVariableExports) {
261-
const insertIndex = variableSetup.trim() ? 2 : 1;
262-
scriptParts.splice(insertIndex, 0, internalVariableExports);
263-
}
264-
265258
const script = scriptParts.filter(part => part.trim() !== '').join('\n\n');
259+
const declaredVariables = collectDeclaredVariables(workflow, allVariables);
260+
const unboundWarnings = detectPotentialUnboundVariables(script, declaredVariables);
261+
warnings.push(...unboundWarnings);
266262

267263
if (monitor) {
268264
monitor.finish(true);
@@ -475,11 +471,64 @@ function generateVariableSetup(workflow: FlowshWorkflow, variables: Map<string,
475471
}
476472

477473
/**
478-
* Collect internal variable assignments and export them for subshell usage.
474+
* Collect variables declared through workflow configuration and node registration.
479475
*/
480-
function collectInternalVariableExports(shellScript: string): string {
481-
const exportPattern = /^\s*export\s+([A-Z_][A-Z0-9_]*)/gm;
482-
const assignmentPattern = /^(?!\s*(?:local|declare|typeset)\b)\s*([A-Z_][A-Z0-9_]*)=/;
476+
function collectDeclaredVariables(
477+
workflow: FlowshWorkflow,
478+
variables: Map<string, string>
479+
): Set<string> {
480+
const declared = new Set<string>(variables.keys());
481+
const envVars = workflow.environment_variables || workflow.spec?.environment_variables || [];
482+
const conversationVars =
483+
workflow.conversation_variables || workflow.spec?.conversation_variables || [];
484+
485+
for (const envVar of envVars) {
486+
if (envVar.variable) {
487+
declared.add(envVar.variable);
488+
}
489+
}
490+
491+
for (const convVar of conversationVars) {
492+
if (convVar.variable) {
493+
declared.add(convVar.variable);
494+
}
495+
}
496+
497+
return declared;
498+
}
499+
500+
function detectPotentialUnboundVariables(
501+
shellScript: string,
502+
declaredVariables: Set<string>
503+
): string[] {
504+
const exportPattern = /^\s*export\s+([A-Z_][A-Z0-9_]*)(?:=|$)/gm;
505+
const assignmentPattern = /^(?!\s*(?:local|declare|typeset|readonly)\b)\s*([A-Z_][A-Z0-9_]*)=/;
506+
const referencePattern = /\$\{?([A-Z_][A-Z0-9_]*)\}?/g;
507+
const knownShellVars = new Set([
508+
'BASH_VERSION',
509+
'BASH_SOURCE',
510+
'BASH_LINENO',
511+
'FUNCNAME',
512+
'LINENO',
513+
'PWD',
514+
'OLDPWD',
515+
'HOME',
516+
'PATH',
517+
'IFS',
518+
'SHELLOPTS',
519+
'UID',
520+
'EUID',
521+
'PPID',
522+
'SHLVL',
523+
'OSTYPE',
524+
'MACHTYPE',
525+
'HOSTNAME',
526+
'HOSTTYPE',
527+
'TERM',
528+
'COLUMNS',
529+
'LINES',
530+
'RANDOM',
531+
]);
483532

484533
const exportedVars = new Set<string>();
485534
for (const match of shellScript.matchAll(exportPattern)) {
@@ -488,26 +537,45 @@ function collectInternalVariableExports(shellScript: string): string {
488537
}
489538
}
490539

491-
const foundVars: string[] = [];
492-
const seenVars = new Set<string>();
540+
const assignedVars = new Set<string>();
493541
for (const line of shellScript.split('\n')) {
494-
const match = line.match(assignmentPattern);
495-
if (!match || !match[1]) {
542+
const trimmed = line.trim();
543+
if (!trimmed || trimmed.startsWith('#')) {
496544
continue;
497545
}
498-
const varName = match[1];
499-
if (!exportedVars.has(varName) && !seenVars.has(varName)) {
500-
seenVars.add(varName);
501-
foundVars.push(varName);
546+
const match = trimmed.match(assignmentPattern);
547+
if (match?.[1]) {
548+
assignedVars.add(match[1]);
502549
}
503550
}
504551

505-
if (foundVars.length === 0) {
506-
return '';
552+
const warningVars = new Set<string>();
553+
for (const line of shellScript.split('\n')) {
554+
const trimmed = line.trim();
555+
if (!trimmed || trimmed.startsWith('#')) {
556+
continue;
557+
}
558+
const matches = trimmed.matchAll(referencePattern);
559+
for (const match of matches) {
560+
const varName = match[1];
561+
if (!varName) {
562+
continue;
563+
}
564+
if (
565+
declaredVariables.has(varName) ||
566+
assignedVars.has(varName) ||
567+
exportedVars.has(varName) ||
568+
knownShellVars.has(varName)
569+
) {
570+
continue;
571+
}
572+
warningVars.add(varName);
573+
}
507574
}
508575

509-
const exportLines = foundVars.map(varName => `export ${varName}`);
510-
return `# Internal Variable Exports\n${exportLines.join('\n')}`;
576+
return [...warningVars].sort().map(varName => {
577+
return `Potentially unbound variable '${varName}' referenced in generated script`;
578+
});
511579
}
512580

513581
/**

0 commit comments

Comments
 (0)