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
4 changes: 4 additions & 0 deletions crates/wright-driver/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ mod tests {
mutual_protocol_version(&supported(&["1.6"]), Some(ConnectFloor::Entry)),
None
);
assert_eq!(
mutual_protocol_version(&supported(&["1.5", "1.7"]), Some(ConnectFloor::Entry)),
Some("1.7")
);
assert_eq!(
mutual_protocol_version(&supported(&["1.0"]), None),
Some("1.0")
Expand Down
1 change: 1 addition & 0 deletions crates/wright-driver/src/diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ mod tests {
kind: "workshop".to_string(),
locale: Some("en-US".to_string()),
},
compile_inputs: None,
};
let locale = workshop_rs::catalog::Locale::new("en-US");
for kind in OWNER_KINDS {
Expand Down
152 changes: 152 additions & 0 deletions crates/wright-driver/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ pub struct ResolvedInput {
pub display: String,
pub identity: String,
pub origin: Origin,
/// The complete disk-observation set the provider declared for the
/// load (LPP `compileInputs`, #1005) — like `identity`, set after
/// resolution by the provider path. `None` when the provider does not
/// advertise compile-input completeness, or for a non-provider load.
pub compile_inputs: Option<Vec<PathBuf>>,
}

pub fn sha256_hex(bytes: &[u8]) -> String {
Expand Down Expand Up @@ -108,6 +113,75 @@ pub(crate) fn digest_files(files: Vec<PathBuf>) -> DiskFingerprint {
DiskFingerprint::Digest(sha256_hex(&hasher.finalize()))
}

/// The fingerprint of a provider-declared compile-input set (LPP
/// `compileInputs`, #1005). Each declared path contributes the observation
/// the declaration records — existence and kind, a symlink's target, a
/// file's contents, a listed directory's entry names and kinds, or a
/// marker for any other kind — so any change the compile could observe
/// invalidates the snapshot while files outside the declared set stay
/// unread and the read never blocks on a non-file kind. Callers trust the
/// declaration only when the provider advertised `compileInputs`.
pub(crate) fn digest_declared_inputs(inputs: &[PathBuf]) -> DiskFingerprint {
let mut inputs = inputs.to_vec();
inputs.sort();
inputs.dedup();
let mut hasher = Sha256::new();
for path in &inputs {
hasher.update(path.as_os_str().as_encoded_bytes());
hasher.update([0]);
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
hasher.update(b"link");
if let Ok(target) = std::fs::read_link(path) {
hasher.update(target.as_os_str().as_encoded_bytes());
}
}
Ok(meta) if meta.is_dir() => {
hasher.update(b"dir");
let mut entries: Vec<Vec<u8>> = std::fs::read_dir(path)
.map(|read| {
read.flatten()
.map(|entry| {
let kind = entry
.file_type()
.map(|kind| {
if kind.is_dir() {
b'd'
} else if kind.is_symlink() {
b'l'
} else {
b'f'
}
})
.unwrap_or(b'?');
let mut bytes = entry.file_name().into_encoded_bytes();
bytes.push(kind);
bytes
})
.collect()
})
.unwrap_or_default();
entries.sort();
for entry in entries {
hasher.update(&entry);
hasher.update([0]);
}
}
Ok(meta) if meta.is_file() => {
hasher.update(b"file");
match std::fs::read(path) {
Ok(bytes) => hasher.update(&bytes),
Err(_) => hasher.update([0xff]),
}
}
Ok(_) => hasher.update(b"other"),
Err(_) => hasher.update(b"missing"),
}
hasher.update([0]);
}
DiskFingerprint::Digest(sha256_hex(&hasher.finalize()))
}

pub fn resolve(config: &SessionConfig) -> Result<ResolvedInput, Diagnostic> {
match &config.input {
InputSpec::Path(path) => resolve_path(path, config),
Expand Down Expand Up @@ -187,6 +261,7 @@ fn resolve_file(
display,
identity: sha256_hex(&bytes),
origin,
compile_inputs: None,
})
}

Expand Down Expand Up @@ -233,6 +308,7 @@ fn resolve_directory(
display,
identity: String::new(),
origin,
compile_inputs: None,
})
}

Expand Down Expand Up @@ -414,6 +490,7 @@ fn resolve_text(
display,
identity: sha256_hex(text.as_bytes()),
origin,
compile_inputs: None,
})
}

Expand Down Expand Up @@ -445,6 +522,7 @@ fn resolve_stdin(config: &SessionConfig) -> Result<ResolvedInput, Diagnostic> {
display: "<stdin>".to_string(),
identity: sha256_hex(&bytes),
origin,
compile_inputs: None,
})
}

Expand Down Expand Up @@ -613,6 +691,80 @@ mod tests {
std::fs::remove_dir_all(directory).expect("remove test directory");
}

#[test]
#[cfg(unix)]
fn digest_declared_inputs_observes_each_declared_kind() {
let directory = temp_dir("declared");
let member = directory.join("main.opy");
let missing = directory.join("missing.opy");
let undeclared = directory.join("notes.md");
std::fs::write(&member, "v1\n").expect("write member");
std::fs::write(&undeclared, "v1\n").expect("write undeclared");
let inputs = vec![directory.clone(), member.clone(), missing.clone()];
let baseline = digest_declared_inputs(&inputs);

std::fs::write(&undeclared, "v2\n").expect("edit undeclared");
assert_eq!(
baseline,
digest_declared_inputs(&inputs),
"a path outside the declared set is not observed"
);

std::fs::write(&member, "v2\n").expect("edit member");
assert_ne!(
baseline,
digest_declared_inputs(&inputs),
"a declared file's contents are observed"
);
std::fs::write(&member, "v1\n").expect("restore member");
assert_eq!(baseline, digest_declared_inputs(&inputs));

std::fs::write(directory.join("added.opy"), "n\n").expect("add member");
assert_ne!(
baseline,
digest_declared_inputs(&inputs),
"a declared directory's entries are observed"
);
std::fs::remove_file(directory.join("added.opy")).expect("remove member");

std::fs::write(&missing, "m\n").expect("create missing");
assert_ne!(
baseline,
digest_declared_inputs(&inputs),
"a declared missing path's existence is observed"
);
std::fs::remove_file(&missing).expect("restore missing");
assert_eq!(baseline, digest_declared_inputs(&inputs));

let link = directory.join("link.opy");
std::os::unix::fs::symlink(&member, &link).expect("create symlink");
let linked = digest_declared_inputs(std::slice::from_ref(&link));
std::fs::remove_file(&link).expect("unlink");
std::os::unix::fs::symlink(&undeclared, &link).expect("relink");
assert_ne!(
linked,
digest_declared_inputs(&[link]),
"a declared symlink's target is observed"
);

std::fs::remove_dir_all(directory).expect("remove test directory");
}

#[test]
#[cfg(unix)]
fn digest_declared_inputs_does_not_block_on_a_fifo() {
let directory = temp_dir("fifo");
let member = directory.join("member.opy");
let mkfifo = std::process::Command::new("mkfifo").arg(&member).status();
assert!(mkfifo.expect("spawn mkfifo").success());
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || drop(sender.send(digest_declared_inputs(&[member]))));
receiver
.recv_timeout(std::time::Duration::from_secs(15))
.expect("a declared FIFO must not block the freshness check");
std::fs::remove_dir_all(directory).expect("remove test directory");
}

#[test]
fn directory_detection_reports_workshop_and_opy_as_ambiguous() {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
Expand Down
1 change: 1 addition & 0 deletions crates/wright-driver/src/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ mod tests {
kind: "workshop".to_string(),
locale: None,
},
compile_inputs: None,
};

let items = vec![finding("a", "warning", Some("sub/f.ws"))];
Expand Down
42 changes: 25 additions & 17 deletions crates/wright-driver/src/service/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,34 @@ use crate::diag::Diagnostic;
use crate::input;
use crate::{CompilerSession, Loaded, Provenance};

/// The snapshot's disk-observation set (#1005): where the load's declared
/// member list covers every disk input the snapshot depends on, freshness
/// hashes exactly those files instead of walking the whole input tree.
/// The snapshot's disk-observation set (#1005): where the load declares
/// every disk input the snapshot depends on, freshness hashes exactly
/// those observations instead of walking the whole input tree.
///
/// `Provenance::Source` names the single parsed file, so its member list
/// is complete when the input spec targets that file — members are
/// display paths resolved against `cwd`, the same base the provider
/// document set uses for non-mapped members. A directory spec still
/// observes membership changes (an added `.ws` file flips a Workshop
/// directory load to `input-kind-ambiguous`), so it keeps the directory
/// fingerprint even though the resolved member list has one entry.
/// A mapped provider artifact's `files` table is not complete — opy-rs
/// omits compile-time `.js` script inputs (`#!postCompileHook`,
/// `__script__`) whose edits change the artifact (opy-rs#654) — so
/// mapped loads keep the whole-tree fingerprint the provider contract
/// implies until the member list covers them. Unmapped artifacts,
/// check-only loads, and caller-held text inputs declare no member list
/// and keep the previous fingerprint (or `Detached`).
/// A provider that advertised `compileInputs` (LPP 1.7) declares the
/// complete observation set of its compile — files read, directories
/// listed, missing paths, symlinks and their targets — and the
/// fingerprint hashes those paths. `Provenance::Source` names the single
/// parsed file, so its member list is complete when the input spec
/// targets that file — members are display paths resolved against `cwd`,
/// the same base the provider document set uses for non-mapped members.
/// A directory spec still observes membership changes (an added `.ws`
/// file flips a Workshop directory load to `input-kind-ambiguous`), so
/// it keeps the directory fingerprint even though the resolved member
/// list has one entry. A provider without `compileInputs` declares no
/// complete member list — a mapped artifact's `files` table omits
/// compile-time `.js` inputs (opy-rs#654) — so its loads keep the
/// whole-tree fingerprint, as do unmapped artifacts, check-only loads,
/// and caller-held text inputs. An advertised-but-empty declaration is
/// malformed — a compile necessarily observed at least its entry — so it
/// falls back like an undeclared set.
fn loaded_fingerprint(config: &crate::SessionConfig, loaded: &Loaded) -> input::DiskFingerprint {
let resolved = &loaded.input;
if let Some(inputs) = &resolved.compile_inputs
&& !inputs.is_empty()
{
return input::digest_declared_inputs(inputs);
}
let file_input = match &config.input {
InputSpec::Path(path) => !resolved.cwd.join(path).is_dir(),
_ => false,
Expand Down
15 changes: 11 additions & 4 deletions crates/wright-driver/src/session/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,16 @@ impl CompilerSession {
#[cfg(not(target_arch = "wasm32"))]
if self.source_provider.is_none() {
// The shared connect path (#1004): entry requests prefer the
// 1.4 artifact-negotiation session, and the target's kind sets
// the retry floor — entry requests need 1.1 project loading,
// and a directory target cannot drop below the 1.3
// highest session the client implements so compile answers
// can carry `compileInputs` (LPP 1.7, #1005), and the target's
// kind sets the retry floor — entry requests need 1.1 project
// loading, and a directory target cannot drop below the 1.3
// source-identity contract it loads under (#638).
let connected = self
.connect_provider(
opy_provider::OPY_LANGUAGE_ID,
&lpp_client_info(),
wright_lpp::LPP_ARTIFACT_NEGOTIATION_PROTOCOL_VERSION,
wright_lpp::LPP_COMPILE_INPUTS_PROTOCOL_VERSION,
Some(resolved.target.into()),
)
.map_err(|error| {
Expand Down Expand Up @@ -124,6 +125,12 @@ impl CompilerSession {
},
ProviderOperation::Compile => provider.compile(&target).map_err(|e| e.diagnostic())?,
};
// The provider's declared compile inputs ride the resolved input
// into `Loaded`, where the freshness check hashes exactly that
// observation set instead of the whole entry tree (#1005). `None`
// — a provider without the `compileInputs` capability, or a
// check-only answer — keeps the wider fingerprint.
resolved.compile_inputs = provider.compile_inputs();
if (operation == ProviderOperation::Compile || compilation.workshop_text.is_some())
&& resolved.target == InputTarget::Directory
{
Expand Down
Loading
Loading