From f0e5ab7062f2fdc10cfc404746e3f8efb7c76383 Mon Sep 17 00:00:00 2001 From: Sami Fouad Date: Sat, 12 Sep 2026 11:09:10 -0600 Subject: [PATCH] feat: route summoned @js/ imports (rfd#39, deka#881) -codex --- README.md | 29 +++++- src/module_spec.rs | 93 +++++++++++++++++ src/project_gate.rs | 90 ++++++++++++++-- tests/summoned_js.rs | 243 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 tests/summoned_js.rs diff --git a/README.md b/README.md index 93d124e..e9c9c69 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This crate is the shared vocabulary for how DekaScript modules are named, scanned, and resolved on disk: - **`module_spec`** — the module specifier vocabulary: bare vs. relative vs. - `@deka/`-scoped specs, the closed stdlib module list, DekaScript source + `@deka/`-scoped specs, summoned JavaScript (`@js/`), the closed stdlib module list, DekaScript source extensions (`.ds`, `.dsx`) and the file-resolution candidates they produce. - **`project_gate`** — the project-boundary gate: given a set of imports and a project's declared dependencies plus `ds_modules/` contents, decides whether @@ -20,6 +20,33 @@ scanned, and resolved on disk: project, installing/linking packages into it, and reading it back for the gate and the runtime loader. +## Summoned JavaScript (`@js/`) + +`@js/three-js` is a reserved routing class for foreign JavaScript, distinct +from scoped registry packages and stdlib aliases (rfd#39). Its name uses +`is_valid_package_name`: ASCII letters, digits, hyphens and underscores; +subpaths and nested scopes are not supported. `is_summoned_js_module_spec` +recognizes the prefix even for invalid names so callers can reject them; +`summoned_js_package_name` returns only a validated vendor name. Registry +canonicalization and the DekaScript file resolver exclude this class. + +`resolve_summoned_js_module_file(project_root, "@js/three-js")` resolves under +`js_modules/three-js/`. Entry precedence is **package.json `module`, then +`main`, then `index.mjs`**. The fallback applies when neither field exists or +package.json is absent. An explicit entry must name an existing file; invalid +JSON, non-string or empty entries, missing files, directories and paths escaping +the package (including entry symlinks) fail rather than falling back. There is +no extension probing or exports-map interpretation. + +The project gate requires the exact `@js/three-js` identity in `deka.json` +`dependencies` or `devDependencies`, the matching `deka.lock` `packages` entry +(using pm's existing `[version, source, metadata, integrity]` tuple), and a +resolvable vendored entry. Lock presence is checked here; integrity verification +remains the package manager's responsibility. Another `@js/` dependency, an +unprefixed name, `ds_modules/` copy, or local link does not satisfy the import. +External stdlib roots and `require_lockfile: false` do not waive these checks: +summoned JavaScript remains a project-owned, declared-and-vendored dependency. + ## Who uses this `dekaruntime/deka` depends on this crate for its module resolver and project diff --git a/src/module_spec.rs b/src/module_spec.rs index ae72098..56e246e 100644 --- a/src/module_spec.rs +++ b/src/module_spec.rs @@ -76,6 +76,9 @@ pub fn canonical_php_package_spec(spec: &str) -> Option { if trimmed.is_empty() { return None; } + if is_summoned_js_module_spec(trimmed) { + return None; + } if trimmed.starts_with('@') { return Some(trimmed.to_string()); } @@ -114,6 +117,96 @@ pub fn is_valid_package_name(name: &str) -> bool { .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') } +/// Reserved routing prefix for vendored, summoned JavaScript (rfd#39). +pub const SUMMONED_JS_SPEC_PREFIX: &str = "@js/"; + +/// Recognize the routing class even when its name is invalid, so malformed +/// summoned imports cannot fall through to registry or DekaScript resolution. +pub fn is_summoned_js_module_spec(spec: &str) -> bool { + spec.trim().starts_with(SUMMONED_JS_SPEC_PREFIX) +} + +/// Validated unscoped vendor name. The full identity shares the package-name +/// validator; subpaths, nested scopes, and traversal are not supported. +pub fn summoned_js_package_name(spec: &str) -> Option<&str> { + let spec = spec.trim(); + is_valid_package_name(spec) + .then(|| spec.strip_prefix(SUMMONED_JS_SPEC_PREFIX)) + .flatten() +} + +/// Resolve `@js/` beneath the project's `js_modules//`. +/// +/// A package.json `module` entry takes precedence over `main`; `index.mjs` +/// is used only when neither field exists (or package.json is absent). +/// Explicit entries must be nonempty strings naming existing files: a broken +/// entry or malformed manifest is an error, never a silent fallback. Entries +/// must stay within the vendor directory, including after symlink resolution. +/// No extension probing, exports-map lookup, or network resolution occurs. +pub fn resolve_summoned_js_module_file(project_root: &Path, spec: &str) -> Result { + let name = summoned_js_package_name(spec) + .ok_or_else(|| format!("invalid summoned JavaScript specifier: {spec}"))?; + let dir = project_root.join("js_modules").join(name); + let manifest = dir.join("package.json"); + let entry = match std::fs::read_to_string(&manifest) { + Ok(raw) => { + let json: serde_json::Value = serde_json::from_str(&raw) + .map_err(|error| format!("invalid {}: {error}", manifest.display()))?; + let object = json + .as_object() + .ok_or_else(|| format!("{} must be an object", manifest.display()))?; + match object.get("module").or_else(|| object.get("main")) { + Some(value) => value + .as_str() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + format!("{} entry must be a nonempty string", manifest.display()) + })? + .to_string(), + None => "index.mjs".to_string(), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => "index.mjs".to_string(), + Err(error) => return Err(format!("cannot read {}: {error}", manifest.display())), + }; + let entry_path = Path::new(&entry); + if entry.contains('\\') + || entry_path.components().any(|part| { + matches!( + part, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(format!( + "JavaScript entry escapes {}: {entry}", + dir.display() + )); + } + let path = dir.join(entry_path); + let root = dir.canonicalize().map_err(|error| { + format!( + "JavaScript package not vendored at {}: {error}", + dir.display() + ) + })?; + let resolved = path.canonicalize().map_err(|error| { + format!( + "JavaScript entry not vendored at {}: {error}", + path.display() + ) + })?; + if !resolved.starts_with(&root) || !resolved.is_file() { + return Err(format!( + "JavaScript entry is not a file within {}: {entry}", + dir.display() + )); + } + Ok(path) +} + /// Source extensions DekaScript resolution recognizes, in search order. /// /// `ds_source_candidates`, the missing-import help scanner, module-graph diff --git a/src/project_gate.rs b/src/project_gate.rs index 43c0f7b..5e28c8e 100644 --- a/src/project_gate.rs +++ b/src/project_gate.rs @@ -18,7 +18,8 @@ use std::path::{Path, PathBuf}; use crate::module_spec::{ STDLIB_SPEC_PREFIXES, ds_source_candidates, is_bare_module_specifier, - is_closed_stdlib_module_spec, module_spec_aliases, + is_closed_stdlib_module_spec, is_summoned_js_module_spec, module_spec_aliases, + resolve_summoned_js_module_file, summoned_js_package_name, }; use crate::modules::resolve_modules_dir; @@ -101,6 +102,9 @@ pub fn is_stdlib_module_spec(spec: &str) -> bool { /// (`@deka/encoding/json`), since `deka install` writes packages under /// `@deka//`. pub fn resolve_module_file(modules_dir: &Path, spec: &str) -> Option { + if is_summoned_js_module_spec(spec) { + return None; + } let mut aliases = module_spec_aliases(spec); if spec.contains('/') && !spec.starts_with('@') @@ -121,18 +125,21 @@ pub fn resolve_module_file(modules_dir: &Path, spec: &str) -> Option { /// Validate a project against the imports its module graph actually contains. /// /// Rules, in order: -/// 1. `deka.lock` exists, unless the caller waived it -/// 2. every stdlib import is **declared** in `deka.json` dependencies -/// 3. every stdlib import resolves to a file under the modules directory +/// 1. summoned JS imports are declared, locked, and vendored (never waived) +/// 2. `deka.lock` exists, unless the caller waived it +/// 3. every stdlib import is **declared** in `deka.json` dependencies +/// 4. every stdlib import resolves to a file under the modules directory /// -/// Rule 2 is the one that did not exist. Resolution was satisfied by a directory -/// happening to be present, so a package could import something it never +/// The stdlib declaration rule did not originally exist. Resolution was +/// satisfied by a directory happening to be present, so a package could import something it never /// declared and install fine for whoever happened to have it. pub fn validate_project( project_root: &Path, imports: &[String], opts: &GateOptions, ) -> Result<(), String> { + validate_summoned_js_imports(project_root, imports, opts.context)?; + // An external module root means the runtime supplies the stdlib; the local // tree is not expected to contain it. if opts @@ -161,7 +168,7 @@ pub fn validate_project( .filter(|s| is_stdlib_module_spec(s)) // Closed, toolchain-provided modules (dsc#142's `math`) ship inside // the compiler: there is intentionally no package to declare or - // install, so Rules 2 and 3 do not apply to them. + // install, so stdlib declaration and installation do not apply to them. .filter(|s| !is_closed_stdlib_module_spec(s)) .collect(); @@ -169,7 +176,7 @@ pub fn validate_project( return Ok(()); } - // Rule 2 — declared in deka.json. + // Stdlib declaration — declared in deka.json. let declared = declared_dependencies(project_root); let undeclared: Vec<&String> = stdlib_imports .iter() @@ -189,7 +196,7 @@ pub fn validate_project( // A `deka link`ed package satisfies an import from a working tree, with // nothing installed (deka#470). Drop those before the on-disk rules below; - // they are deliberately still subject to Rule 2, because a link changes + // they are deliberately still subject to declaration, because a link changes // *where* a dependency comes from, not whether it is a dependency. // // A manifest naming a target that has been moved or deleted is an error, @@ -206,7 +213,7 @@ pub fn validate_project( return Ok(()); } - // Rule 3 — present on disk. + // Stdlib installation — present on disk. let modules_dir = resolve_modules_dir(project_root); if !modules_dir.is_dir() { return Err(format!( @@ -237,6 +244,66 @@ pub fn validate_project( } } +/// Summoned dependencies are project-owned, even when a runtime supplies the +/// stdlib or waives its lockfile. Exact @js identities prevent scope aliases +/// and local links from satisfying a different trust class. +fn validate_summoned_js_imports( + project_root: &Path, + imports: &[String], + who: &str, +) -> Result<(), String> { + let imports: BTreeSet<&str> = imports + .iter() + .map(|spec| spec.trim()) + .filter(|spec| is_summoned_js_module_spec(spec)) + .collect(); + if imports.is_empty() { + return Ok(()); + } + for spec in &imports { + if summoned_js_package_name(spec).is_none() { + return Err(format!( + "{who}: invalid summoned JavaScript specifier: {spec}" + )); + } + } + let declared = declared_dependencies(project_root); + for spec in &imports { + if !declared.contains(*spec) { + return Err(format!( + "{who}: {spec} imported but not declared in deka.json. Use `deka summon `." + )); + } + } + let lock_path = project_root.join("deka.lock"); + let raw = std::fs::read_to_string(&lock_path).map_err(|error| { + format!( + "{who}: summoned JavaScript requires {}: {error}", + lock_path.display() + ) + })?; + let lock: serde_json::Value = serde_json::from_str(&raw) + .map_err(|error| format!("{who}: invalid {}: {error}", lock_path.display()))?; + for spec in imports { + if !lock + .get("packages") + .and_then(|value| value.as_object()) + .and_then(|packages| packages.get(spec)) + .is_some_and(|entry| { + serde_json::from_value::<(String, String, serde_json::Value, String)>(entry.clone()) + .is_ok() + }) + { + return Err(format!( + "{who}: {spec} missing package entry in deka.lock. Use `deka summon `." + )); + } + resolve_summoned_js_module_file(project_root, spec) + .map_err(|error| format!("{who}: {spec}: {error}. Use `deka summon `."))?; + } + Ok(()) +} + /// Dependency names from `deka.json`, normalised to their bare form so /// `@deka/crypto` and `crypto` compare equal. fn declared_dependencies(project_root: &Path) -> BTreeSet { @@ -261,6 +328,9 @@ fn declared_dependencies(project_root: &Path) -> BTreeSet { /// first segment, which is what a manifest would name. fn bare_name(spec: &str) -> &str { let spec = spec.trim(); + if is_summoned_js_module_spec(spec) { + return spec; + } if let Some(rest) = spec.strip_prefix("@deka/") { return rest.split('/').next().unwrap_or(rest); } diff --git a/tests/summoned_js.rs b/tests/summoned_js.rs new file mode 100644 index 0000000..4b4e6f9 --- /dev/null +++ b/tests/summoned_js.rs @@ -0,0 +1,243 @@ +use deka_modules::module_spec::{ + canonical_php_package_spec, is_summoned_js_module_spec, is_valid_package_name, + module_spec_aliases, resolve_summoned_js_module_file, summoned_js_package_name, +}; +use deka_modules::project_gate::{ + GateOptions, is_stdlib_module_spec, resolve_module_file, validate_project, +}; +use std::path::Path; + +fn write(root: &Path, name: &str, body: &str) { + let path = root.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); +} + +fn project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "deka.json", + r#"{"dependencies":{"@js/three-js":"url:https://example.test/three.mjs"}}"#, + ); + write( + tmp.path(), + "deka.lock", + r#"{"lockfileVersion":1,"packages":{"@js/three-js":["1.0.0","url:https://example.test/three.mjs",{},"sha256-test"]}}"#, + ); + write( + tmp.path(), + "js_modules/three-js/index.mjs", + "export const scene = () => ({});", + ); + tmp +} + +fn gate(root: &Path, spec: &str, options: &GateOptions) -> Result<(), String> { + validate_project(root, &[spec.to_string()], options) +} + +#[test] +fn summoned_class_is_distinct_and_shares_name_validation() { + for spec in ["@js/three-js", "@js/Name_2"] { + assert!(is_summoned_js_module_spec(spec)); + assert!(is_valid_package_name(spec)); + assert!(summoned_js_package_name(spec).is_some()); + assert!(!is_stdlib_module_spec(spec)); + assert_eq!(canonical_php_package_spec(spec), None); + assert_eq!(module_spec_aliases(spec), vec![spec]); + } + assert_eq!(summoned_js_package_name(" @js/three-js "), Some("three-js")); + for spec in [ + "@js/", + "@js/..", + "@js/../escape", + "@js/three/sub", + "@js/@scope/name", + "@js/a\\b", + "@js/a.b", + ] { + assert!(is_summoned_js_module_spec(spec)); + assert!(!is_valid_package_name(spec)); + assert_eq!(summoned_js_package_name(spec), None); + assert_eq!(canonical_php_package_spec(spec), None); + } + for spec in [ + "three-js", + "@other/three-js", + "@deka/three-js", + "./@js/three-js", + ] { + assert!(!is_summoned_js_module_spec(spec)); + assert_eq!(summoned_js_package_name(spec), None); + } +} + +#[test] +fn resolution_precedence_is_module_main_index() { + let tmp = project(); + let root = tmp.path(); + let resolve = || resolve_summoned_js_module_file(root, "@js/three-js").unwrap(); + assert_eq!(resolve(), root.join("js_modules/three-js/index.mjs")); + write(root, "js_modules/three-js/package.json", "{}"); + assert_eq!(resolve(), root.join("js_modules/three-js/index.mjs")); + write(root, "js_modules/three-js/main.js", "export {};"); + write(root, "js_modules/three-js/esm/module.mjs", "export {};"); + write( + root, + "js_modules/three-js/package.json", + r#"{"main":"./main.js"}"#, + ); + assert_eq!(resolve(), root.join("js_modules/three-js/./main.js")); + write( + root, + "js_modules/three-js/package.json", + r#"{"main":"main.js","module":"esm/module.mjs"}"#, + ); + assert_eq!(resolve(), root.join("js_modules/three-js/esm/module.mjs")); +} + +#[test] +fn invalid_explicit_entries_never_fall_back() { + let tmp = project(); + for manifest in [ + "broken", + "[]", + r#"{"module":null}"#, + r#"{"module":""}"#, + r#"{"module":12}"#, + r#"{"main":"missing.js"}"#, + r#"{"module":"missing.mjs","main":"index.mjs"}"#, + r#"{"main":"../outside.mjs"}"#, + r#"{"main":"/tmp/outside.mjs"}"#, + r#"{"main":"..\\outside.mjs"}"#, + r#"{"main":"."}"#, + ] { + write(tmp.path(), "js_modules/three-js/package.json", manifest); + assert!( + resolve_summoned_js_module_file(tmp.path(), "@js/three-js").is_err(), + "{manifest}" + ); + } +} + +#[cfg(unix)] +#[test] +fn entry_symlinks_cannot_escape_package() { + let tmp = project(); + write(tmp.path(), "outside.mjs", "export {};"); + std::fs::remove_file(tmp.path().join("js_modules/three-js/index.mjs")).unwrap(); + std::os::unix::fs::symlink( + tmp.path().join("outside.mjs"), + tmp.path().join("js_modules/three-js/index.mjs"), + ) + .unwrap(); + assert!(resolve_summoned_js_module_file(tmp.path(), "@js/three-js").is_err()); +} + +#[test] +fn declared_locked_and_vendored_passes_without_ds_modules() { + let tmp = project(); + gate(tmp.path(), " @js/three-js ", &GateOptions::default()).unwrap(); + write( + tmp.path(), + "deka.json", + r#"{"devDependencies":{"@js/three-js":"1.0.0"}}"#, + ); + gate(tmp.path(), "@js/three-js", &GateOptions::default()).unwrap(); +} + +#[test] +fn declaration_requires_exact_js_identity() { + let tmp = project(); + for name in [ + "@js/other", + "three-js", + "@deka/three-js", + "@registry/three-js", + ] { + write( + tmp.path(), + "deka.json", + &format!(r#"{{"dependencies":{{"{name}":"1.0.0"}}}}"#), + ); + let err = gate(tmp.path(), "@js/three-js", &GateOptions::default()).unwrap_err(); + assert!(err.contains("not declared"), "{err}"); + } +} + +#[test] +fn lock_requires_exact_package_entry_and_valid_json() { + let tmp = project(); + for lock in [ + "broken", + "{}", + r#"{"packages":{"@js/other":{}}}"#, + r#"{"packages":{"three-js":{}}}"#, + r#"{"packages":{"@js/three-js":null}}"#, + ] { + write(tmp.path(), "deka.lock", lock); + let err = gate(tmp.path(), "@js/three-js", &GateOptions::default()).unwrap_err(); + assert!(err.contains("deka.lock"), "{err}"); + } +} + +#[test] +fn gate_requires_resolvable_vendor_entry_and_rejects_ds_copy() { + let tmp = project(); + std::fs::remove_dir_all(tmp.path().join("js_modules")).unwrap(); + write( + tmp.path(), + "ds_modules/@js/three-js/index.ds", + "export fn scene() {}", + ); + assert!(resolve_module_file(&tmp.path().join("ds_modules"), "@js/three-js").is_none()); + let err = gate(tmp.path(), "@js/three-js", &GateOptions::default()).unwrap_err(); + assert!(err.contains("not vendored"), "{err}"); + std::fs::create_dir_all(tmp.path().join("js_modules/three-js")).unwrap(); + assert!(gate(tmp.path(), "@js/three-js", &GateOptions::default()).is_err()); + write( + tmp.path(), + "js_modules/three-js/package.json", + r#"{"module":"missing.mjs"}"#, + ); + assert!(gate(tmp.path(), "@js/three-js", &GateOptions::default()).is_err()); +} + +#[test] +fn stdlib_bypasses_do_not_waive_js_checks() { + let tmp = project(); + let opts = GateOptions { + module_root: Some(tmp.path().join("external")), + require_lockfile: false, + context: "deka build", + }; + gate(tmp.path(), "@js/three-js", &opts).unwrap(); + std::fs::remove_file(tmp.path().join("deka.lock")).unwrap(); + for module_root in [None, opts.module_root.clone()] { + let opts = GateOptions { + module_root, + ..opts.clone() + }; + let err = gate(tmp.path(), "@js/three-js", &opts).unwrap_err(); + assert!(err.starts_with("deka build:"), "{err}"); + assert!(err.contains("deka.lock"), "{err}"); + } + let err = gate(tmp.path(), "@js/../escape", &opts).unwrap_err(); + assert!(err.contains("invalid summoned"), "{err}"); +} + +#[test] +fn mixed_imports_keep_stdlib_declaration_checks() { + let tmp = project(); + let err = validate_project( + tmp.path(), + &["@js/three-js".into(), "crypto".into()], + &GateOptions::default(), + ) + .unwrap_err(); + assert!( + err.contains("not declared") && err.contains("crypto"), + "{err}" + ); +}