Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions src/module_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ pub fn canonical_php_package_spec(spec: &str) -> Option<String> {
if trimmed.is_empty() {
return None;
}
if is_summoned_js_module_spec(trimmed) {
return None;
}
if trimmed.starts_with('@') {
return Some(trimmed.to_string());
}
Expand Down Expand Up @@ -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/<name>` beneath the project's `js_modules/<name>/`.
///
/// 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<PathBuf, String> {
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
Expand Down
90 changes: 80 additions & 10 deletions src/project_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -101,6 +102,9 @@ pub fn is_stdlib_module_spec(spec: &str) -> bool {
/// (`@deka/encoding/json`), since `deka install` writes packages under
/// `@deka/<pkg>/`.
pub fn resolve_module_file(modules_dir: &Path, spec: &str) -> Option<PathBuf> {
if is_summoned_js_module_spec(spec) {
return None;
}
let mut aliases = module_spec_aliases(spec);
if spec.contains('/')
&& !spec.starts_with('@')
Expand All @@ -121,18 +125,21 @@ pub fn resolve_module_file(modules_dir: &Path, spec: &str) -> Option<PathBuf> {
/// 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
Expand Down Expand Up @@ -161,15 +168,15 @@ 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();

if stdlib_imports.is_empty() {
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()
Expand All @@ -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,
Expand All @@ -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!(
Expand Down Expand Up @@ -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 <source>`."
));
}
}
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 <source>`."
));
}
resolve_summoned_js_module_file(project_root, spec)
.map_err(|error| format!("{who}: {spec}: {error}. Use `deka summon <source>`."))?;
}
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<String> {
Expand All @@ -261,6 +328,9 @@ fn declared_dependencies(project_root: &Path) -> BTreeSet<String> {
/// 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);
}
Expand Down
Loading
Loading