Skip to content

Commit 6a334e9

Browse files
rubenfiszelclaude
andauthored
fix: detect S3 assets passed as SDK object arg in ts parser (#9181)
windmill-parser-ts-asset only recognized writeS3File/loadS3File when the first arg was a bare 's3://...' string literal. The actual SDK signature takes an S3Object ({ s3, storage? }) or 's3://bucket/key' string, which every real script uses, so object-form writes/reads were never detected as assets. Resolve the S3Object arg the same way the runtime parseS3Object does, mapping { s3, storage } to s3://<storage>/ <key> and feeding it through parse_asset_syntax so the path matches the // on s3:///… trigger form. Adds regression tests. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8555554 commit 6a334e9

1 file changed

Lines changed: 194 additions & 14 deletions

File tree

  • backend/parsers/windmill-parser-ts-asset/src

backend/parsers/windmill-parser-ts-asset/src/lib.rs

Lines changed: 194 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::HashMap;
22

33
use swc_common::{sync::Lrc, FileName, SourceMap, Spanned};
4-
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, Str};
4+
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, ObjectLit, Prop, PropName, Str};
55
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax};
66
use swc_ecma_visit::{Visit, VisitWith};
77
use windmill_parser::asset_parser::{
@@ -309,6 +309,56 @@ impl Visit for AssetsFinder {
309309
}
310310
}
311311

312+
/// Extract a string-literal property value from an object literal.
313+
/// Returns `Some(value)` for `{ name: "value" }`, ignoring computed,
314+
/// shorthand, spread, and non-string-literal properties.
315+
fn object_str_prop(obj: &ObjectLit, name: &str) -> Option<String> {
316+
for prop in &obj.props {
317+
let swc_ecma_ast::PropOrSpread::Prop(p) = prop else {
318+
continue;
319+
};
320+
let Prop::KeyValue(kv) = p.as_ref() else {
321+
continue;
322+
};
323+
let key = match &kv.key {
324+
PropName::Ident(i) => i.sym.as_str(),
325+
PropName::Str(s) => s.value.as_str(),
326+
_ => continue,
327+
};
328+
if key != name {
329+
continue;
330+
}
331+
if let Expr::Lit(Lit::Str(s)) = kv.value.as_ref() {
332+
return Some(s.value.to_string());
333+
}
334+
}
335+
None
336+
}
337+
338+
/// Resolve the SDK `S3Object` argument of `loadS3File`/`loadS3FileStream`/
339+
/// `writeS3File` to a canonical asset path, mirroring the runtime
340+
/// `parseS3Object`: an object `{ s3: "<key>", storage?: "<bucket>" }` maps to
341+
/// the URI `s3://<bucket>/<key>` (empty bucket for default storage, i.e.
342+
/// `s3:///<key>`), and a bare `"s3://bucket/key"` string is passed through.
343+
/// The resulting URI is fed through `parse_asset_syntax` so the stored path
344+
/// matches the `// on s3:///…` trigger form exactly.
345+
fn s3_object_arg_path(arg: &Expr) -> Option<String> {
346+
let uri = match arg {
347+
Expr::Lit(Lit::Str(s)) => s.value.to_string(),
348+
Expr::Object(obj) => {
349+
let key = object_str_prop(obj, "s3")?;
350+
let storage = object_str_prop(obj, "storage").unwrap_or_default();
351+
format!("s3://{storage}/{key}")
352+
}
353+
_ => return None,
354+
};
355+
Some(
356+
parse_asset_syntax(&uri, false)
357+
.map(|(_, p)| p.to_string())
358+
.unwrap_or(uri),
359+
)
360+
}
361+
312362
impl AssetsFinder {
313363
fn visit_call_expr_inner(&mut self, node: &swc_ecma_ast::CallExpr) -> Result<(), ()> {
314364
let ident = match node.callee.as_expr().map(AsRef::as_ref) {
@@ -331,20 +381,20 @@ impl AssetsFinder {
331381

332382
let arg_value = node.args.get(arg_pos);
333383

334-
match arg_value.map(|e| e.expr.as_ref()) {
335-
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => {
336-
let path = parse_asset_syntax(&value, false)
337-
.map(|(_, p)| p)
338-
.unwrap_or(&value);
339-
self.assets.push(ParseAssetsResult {
340-
kind,
341-
path: path.to_string(),
342-
access_type,
343-
columns: None,
344-
});
345-
}
384+
// S3 helpers take an `S3Object` (`{ s3, storage? }`) or an
385+
// `s3://bucket/key` string — the form every real script uses. Other
386+
// helpers take a bare resource-path string literal.
387+
let is_s3_helper = matches!(kind, AssetKind::S3Object);
388+
389+
let path = match arg_value.map(|e| e.expr.as_ref()) {
390+
Some(arg) if is_s3_helper => s3_object_arg_path(arg).ok_or(())?,
391+
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => parse_asset_syntax(&value, false)
392+
.map(|(_, p)| p.to_string())
393+
.unwrap_or_else(|| value.to_string()),
346394
_ => return Err(()),
347-
}
395+
};
396+
self.assets
397+
.push(ParseAssetsResult { kind, path, access_type, columns: None });
348398
Ok(())
349399
}
350400
}
@@ -375,6 +425,136 @@ mod tests {
375425
);
376426
}
377427

428+
#[test]
429+
fn test_ts_asset_parser_write_s3_object_arg() {
430+
// The SDK signature is `writeS3File(s3object: S3Object, ...)` and every
431+
// real script passes the object form with a bare key. It must resolve
432+
// to the same canonical path as a `// on s3:///<key>` trigger.
433+
let input = r#"
434+
import * as wmill from "windmill-client"
435+
export async function main() {
436+
await wmill.writeS3File(
437+
{ s3: "pipelines/km_real/raw_events.json" },
438+
JSON.stringify([]),
439+
undefined,
440+
"application/json"
441+
)
442+
}
443+
"#;
444+
let s = parse_assets(input);
445+
assert_eq!(
446+
s.map(|r| r.assets).map_err(|e| e.to_string()),
447+
Ok(vec![ParseAssetsResult {
448+
kind: AssetKind::S3Object,
449+
path: "/pipelines/km_real/raw_events.json".to_string(),
450+
access_type: Some(W),
451+
columns: None,
452+
},])
453+
);
454+
}
455+
456+
#[test]
457+
fn test_ts_asset_parser_s3_object_with_storage() {
458+
// `{ s3, storage }` maps to `s3://<storage>/<key>`, matching the
459+
// `s3://bucket/key` string form and `parseS3Object`.
460+
let input = r#"
461+
import * as wmill from "windmill-client"
462+
export async function main() {
463+
await wmill.loadS3File({ s3: "dir/in.csv", storage: "mybucket" })
464+
}
465+
"#;
466+
let s = parse_assets(input);
467+
assert_eq!(
468+
s.map(|r| r.assets).map_err(|e| e.to_string()),
469+
Ok(vec![ParseAssetsResult {
470+
kind: AssetKind::S3Object,
471+
path: "mybucket/dir/in.csv".to_string(),
472+
access_type: Some(R),
473+
columns: None,
474+
},])
475+
);
476+
}
477+
478+
#[test]
479+
fn test_ts_asset_parser_multiple_s3_object_writes() {
480+
// Mirrors the f/km/r_seed shape: several direct object-form writes in
481+
// main() — all four outputs must be detected.
482+
let input = r#"
483+
import * as wmill from "windmill-client"
484+
export async function main() {
485+
await wmill.writeS3File({ s3: "pipelines/km_real/raw_events.json" }, "[]")
486+
await wmill.writeS3File({ s3: "pipelines/km_real/enriched.json" }, "[]")
487+
await wmill.writeS3File({ s3: "pipelines/km_real/summary.json" }, "[]")
488+
await wmill.writeS3File({ s3: "pipelines/km_real/report.json" }, "{}")
489+
}
490+
"#;
491+
// merge_assets returns a deterministic (path-sorted) order.
492+
let s = parse_assets(input);
493+
assert_eq!(
494+
s.map(|r| r.assets).map_err(|e| e.to_string()),
495+
Ok(vec![
496+
ParseAssetsResult {
497+
kind: AssetKind::S3Object,
498+
path: "/pipelines/km_real/enriched.json".to_string(),
499+
access_type: Some(W),
500+
columns: None,
501+
},
502+
ParseAssetsResult {
503+
kind: AssetKind::S3Object,
504+
path: "/pipelines/km_real/raw_events.json".to_string(),
505+
access_type: Some(W),
506+
columns: None,
507+
},
508+
ParseAssetsResult {
509+
kind: AssetKind::S3Object,
510+
path: "/pipelines/km_real/report.json".to_string(),
511+
access_type: Some(W),
512+
columns: None,
513+
},
514+
ParseAssetsResult {
515+
kind: AssetKind::S3Object,
516+
path: "/pipelines/km_real/summary.json".to_string(),
517+
access_type: Some(W),
518+
columns: None,
519+
},
520+
])
521+
);
522+
}
523+
524+
#[test]
525+
fn test_ts_asset_parser_s3_object_quoted_key() {
526+
let input = r#"
527+
import * as wmill from "windmill-client"
528+
export async function main() {
529+
await wmill.writeS3File({ "s3": "out.json" }, "{}")
530+
}
531+
"#;
532+
let s = parse_assets(input);
533+
assert_eq!(
534+
s.map(|r| r.assets).map_err(|e| e.to_string()),
535+
Ok(vec![ParseAssetsResult {
536+
kind: AssetKind::S3Object,
537+
path: "/out.json".to_string(),
538+
access_type: Some(W),
539+
columns: None,
540+
},])
541+
);
542+
}
543+
544+
#[test]
545+
fn test_ts_asset_parser_s3_object_dynamic_key_no_false_positive() {
546+
// A computed key can't be resolved statically — must yield nothing
547+
// rather than a bogus path.
548+
let input = r#"
549+
import * as wmill from "windmill-client"
550+
export async function main(name: string) {
551+
await wmill.writeS3File({ s3: `pipelines/${name}.json` }, "{}")
552+
}
553+
"#;
554+
let s = parse_assets(input);
555+
assert_eq!(s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![]));
556+
}
557+
378558
#[test]
379559
fn test_ts_asset_parser_unused_sql() {
380560
let input = r#"

0 commit comments

Comments
 (0)