Skip to content
Draft
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
76 changes: 53 additions & 23 deletions crates/webcodex-core/src/validation_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,22 +239,20 @@ pub fn parse_node_native_test_diagnostics(stdout: &str, truncated: bool) -> Vali
}

fn node_native_test_summary(stdout: &str) -> Option<CargoTestSummary> {
// Preserve indentation: Node emits nested TAP plans and assertions inside
// suites, and those cannot authorize a root-level test-count summary.
let lines: Vec<_> = stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| !line.trim().is_empty())
.collect();
if lines.len() < 10 || lines.first().copied() != Some("TAP version 13") {
return None;
}
let tail = lines.get(lines.len().checked_sub(8)?..)?;
let duration = tail[7]
.strip_prefix("# duration_ms ")?
.parse::<f64>()
.ok()?;
if !duration.is_finite() || duration < 0.0 {
return None;
}
let (body, tail) = lines.split_at(lines.len().checked_sub(8)?);
// Native Node appends the root plan immediately before the fixed trailer.
// Its N counts top-level assertions, not all nested tests in # tests.
let root_count = body.last()?.strip_prefix("1..")?.parse::<u64>().ok()?;
let mut root_assertions = 0u64;
let fields = [
"# tests ",
"# suites ",
Expand All @@ -264,28 +262,60 @@ fn node_native_test_summary(stdout: &str) -> Option<CargoTestSummary> {
"# skipped ",
"# todo ",
];
for line in body.iter().skip(1).take(body.len().saturating_sub(2)) {
// Reject duplicate root plans and numeric accounting while allowing
// ordinary t.diagnostic prose such as "# pass phase complete".
let repeated_count = fields.iter().any(|prefix| {
line.strip_prefix(prefix)
.and_then(|value| value.parse::<u64>().ok())
.is_some()
});
let repeated_duration = line
.strip_prefix("# duration_ms ")
.and_then(|value| value.parse::<f64>().ok())
.is_some_and(|value| value.is_finite() && value >= 0.0);
if line.starts_with("1..")
|| line.starts_with("TAP version ")
|| repeated_count
|| repeated_duration
{
return None;
}
let ordinal = line
.strip_prefix("ok ")
.or_else(|| line.strip_prefix("not ok "));
if let Some(ordinal) = ordinal {
root_assertions = root_assertions.checked_add(1)?;
if ordinal.split_whitespace().next()?.parse::<u64>().ok()? != root_assertions {
return None;
}
}
}
if root_assertions != root_count {
return None;
}
// Accounting lines must themselves be top-level (unindented).
let duration = tail[7]
.strip_prefix("# duration_ms ")?
.parse::<f64>()
.ok()?;
if !duration.is_finite() || duration < 0.0 {
return None;
}
let mut values = [0_u64; 7];
for (i, prefix) in fields.iter().enumerate() {
values[i] = tail[i].strip_prefix(prefix)?.parse::<u64>().ok()?;
}
let [total, _suites, passed, failed, cancelled, skipped, todo] = values;
if cancelled != 0
let [total, suites, passed, failed, cancelled, skipped, todo] = values;
// With no root assertions, neither nested tests nor suites can exist.
// An empty suite remains valid: Node emits one root suite assertion.
if (root_count == 0 && (total != 0 || suites != 0))
|| cancelled != 0
|| passed
.checked_add(failed)?
.checked_add(skipped)?
.checked_add(todo)?
!= total
|| !lines[..lines.len() - 8].iter().any(|line| {
line.strip_prefix("1..")
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
})
{
return None;
}
// A second accounting footer cannot be accepted as authoritative.
if lines[..lines.len() - 8]
.iter()
.any(|line| line.starts_with("# tests "))
{
return None;
}
Expand Down
107 changes: 106 additions & 1 deletion crates/webcodex-core/src/validation_evidence_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ fn pytest_summary_proves_only_complete_executed_results() {
#[test]
fn node_native_tap_counts_only_complete_conservative_reporter_trailers() {
use crate::validation_evidence::parse_node_native_test_diagnostics;
let transcript = "TAP version 13\n# Subtest: passes\nok 1 - passes\n1..2\n# tests 3\n# suites 0\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 0\n# duration_ms 42.41\n";
let transcript = "TAP version 13\n# Subtest: first\nok 1 - first\n# Subtest: second\nok 2 - second\n# Subtest: skipped\nok 3 - skipped # SKIP\n1..3\n# tests 3\n# suites 0\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 0\n# duration_ms 42.41\n";
let summary = parse_node_native_test_diagnostics(transcript, false)
.test_summary
.expect("complete native TAP footer");
Expand Down Expand Up @@ -841,3 +841,108 @@ fn node_native_tap_counts_only_complete_conservative_reporter_trailers() {
.is_none()
);
}

#[test]
fn node_native_tap_root_plan_and_footer_are_authoritative() {
use crate::validation_evidence::parse_node_native_test_diagnostics;
// Native Node v26.10.0 TAP indentation and trailer shape.
const SIMPLE: &str = "TAP version 13\n# Subtest: passes\nok 1 - passes\n1..1\n# tests 1\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 42.41\n";
const NESTED: &str = "TAP version 13\n# Subtest: group\n # Subtest: first\n ok 1 - first\n # Subtest: second\n ok 2 - second\n 1..2\nok 1 - group\n1..1\n# tests 3\n# suites 0\n# pass 3\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 43.1\n";
const SKIP_TODO: &str = "TAP version 13\n# Subtest: pass\nok 1 - pass\n# Subtest: skip\nok 2 - skip # SKIP\n# Subtest: todo\nok 3 - todo # TODO\n1..3\n# tests 3\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 1\n# duration_ms 53.0\n";
const FAILURE: &str = "TAP version 13\n# Subtest: failure\nnot ok 1 - failure\n1..1\n# tests 1\n# suites 0\n# pass 0\n# fail 1\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 30.5\n";
const ZERO: &str = "TAP version 13\n1..0\n# tests 0\n# suites 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 0.0\n";
// Node describe/it has one root suite plan and counts nested test leaves.
const SUITE: &str = "TAP version 13\n# Subtest: group\n # Subtest: first\n ok 1 - first\n # Subtest: second\n ok 2 - second\n 1..2\nok 1 - group\n1..1\n# tests 2\n# suites 1\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 35.1\n";
// An empty Node suite has one root suite assertion but zero tests.
const EMPTY_SUITE: &str = "TAP version 13\n# Subtest: empty\nok 1 - empty\n1..1\n# tests 0\n# suites 1\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 20.0\n";
for (output, passed, failed, ignored) in [
(SIMPLE, 1, 0, 0),
(NESTED, 3, 0, 0),
(SUITE, 2, 0, 0),
(EMPTY_SUITE, 0, 0, 0),
(SKIP_TODO, 1, 0, 2),
(FAILURE, 0, 1, 0),
(ZERO, 0, 0, 0),
] {
let summary = parse_node_native_test_diagnostics(output, false)
.test_summary
.expect("complete native TAP evidence");
assert_eq!(
(summary.passed, summary.failed, summary.ignored),
(Some(passed), Some(failed), Some(ignored))
);
}

// Real Node t.diagnostic text can begin with an accounting word.
let legitimate_diagnostics = SIMPLE.replacen(
"\n1..1\n# tests",
"\n# pass phase complete\n# skipped optional setup\n1..1\n# tests",
1,
);
assert!(
parse_node_native_test_diagnostics(&legitimate_diagnostics, false)
.test_summary
.is_some(),
"non-numeric Node diagnostics must not erase proven tests"
);
for (case, invalid) in [
(
"zero root plan but positive test count",
ZERO.replace("# tests 0", "# tests 1")
.replace("# pass 0", "# pass 1"),
),
(
"zero root plan but nonzero suite count",
ZERO.replace("# suites 0", "# suites 1"),
),
(
"nested-only plan",
SIMPLE.replace("1..1\n# tests", " 1..1\n# tests"),
),
(
"duplicate root plan",
SIMPLE.replace("1..1\n# tests", "1..1\n1..1\n# tests"),
),
(
"displaced root plan",
SIMPLE.replace("1..1\n# tests", "1..1\nok 2 - unexpected\n# tests"),
),
(
"inconsistent root plan",
SIMPLE.replace("1..1\n# tests", "1..9\n# tests"),
),
(
"out-of-order assertion",
SIMPLE.replace("ok 1 - passes", "ok 2 - passes"),
),
] {
assert!(
parse_node_native_test_diagnostics(&invalid, false)
.test_summary
.is_none(),
"must not prove tests from {case}"
);
}
for (field, value) in [
("tests", "1"),
("suites", "0"),
("pass", "1"),
("fail", "0"),
("cancelled", "0"),
("skipped", "0"),
("todo", "0"),
("duration_ms", "42.41"),
] {
let duplicate = SIMPLE.replacen(
"\n1..1\n# tests",
&format!("\n# {field} {value}\n1..1\n# tests"),
1,
);
assert!(
parse_node_native_test_diagnostics(&duplicate, false)
.test_summary
.is_none(),
"must reject duplicated accounting field {field}"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option<Value> {
"properties": {
"profile": {"type": "string", "enum": ["direct", "host_code_mode"]},
"host_budget_secs": {"type": "integer", "minimum": 1},
"initial_job_handoff_secs": {"type": "integer", "minimum": 1},
"initial_job_handoff_secs": {"type": "integer", "minimum": 1, "deprecated": true, "description": "Legacy compatibility-only timing hint. No independent execution consumer; max_sync_wait_secs controls structured handoff."},
"max_sync_wait_secs": {"type": "integer", "minimum": 1},
"continuation_wait_secs": {"type": "integer", "minimum": 1}
},
Expand Down
53 changes: 52 additions & 1 deletion crates/webcodex-tool-contracts/src/tests/input_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1471,11 +1471,62 @@ fn project_validation_python_adapter_is_public_without_a_pytest_tool() {
.as_str()
.unwrap_or_default();
assert!(adapter_description.contains("Python supports test through pytest"));
assert!(adapter_description.contains("Node returns unavailable"));
assert!(adapter_description
.contains("Node supports native script check and opt-in native TAP test"));
assert!(
schema.to_string().contains("pytest -k"),
"project_validate schema must expose the Python pytest -k filter contract"
);
assert!(ToolCall::from_tool_name("project_validate", value).is_ok());
assert!(ToolCall::from_tool_name("pytest", json!({"project":"demo"})).is_err());
}

#[test]
fn project_validation_node_contract_advertises_bounded_test_and_execution_effects() {
let specs = registered_tool_specs();
let spec = spec_named(&specs, "project_validate");
let description = spec.description.as_str();
assert!(description.contains("Node >=22.3: check via node --run"));
assert!(description.contains(
"native TAP test when scripts.test equals node --test or node --test --test-reporter=tap"
));
assert!(description.contains("fixed Runner argv"));
assert!(description.contains("Node TAP rejects filters"));
assert!(description.contains("Project scripts may modify files or use network"));
assert_eq!(
lookup_tool_definition("project_validate")
.unwrap()
.metadata()
.effect,
ToolEffect::Execute
);

let schema = &spec.input_schema;
assert_eq!(schema["additionalProperties"], false);
for action in ["check", "test"] {
let value = json!({"project":"demo", "adapter":"node", "action":action});
assert!(test_support::validate_schema_instance(&value, schema).is_ok());
assert!(ToolCall::from_tool_name("project_validate", value).is_ok());
}
let with_count = json!({"project":"demo", "adapter":"node", "action":"test",
"test":{"require_tests":false, "min_tests":2}});
assert!(test_support::validate_schema_instance(&with_count, schema).is_ok());
assert!(ToolCall::from_tool_name("project_validate", with_count).is_ok());
for (field, value) in [
("executable", json!("node")),
("argv", json!(["--test"])),
("shell", json!("sh")),
("script", json!("test")),
] {
let mut invalid = json!({"project":"demo", "adapter":"node", "action":"test"});
invalid[field] = value;
assert!(
test_support::validate_schema_instance(&invalid, schema).is_err(),
"{field}"
);
assert!(
ToolCall::from_tool_name("project_validate", invalid).is_err(),
"{field}"
);
}
}
13 changes: 8 additions & 5 deletions crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1541,8 +1541,8 @@ pub enum ToolCall {
timeout_secs: Option<u64>,
},

/// Run portable read-only project validation. The Runner resolves the nearest
/// supported Rust/Go or Python recipe and admits one canonical structured validation Job.
/// Run bounded project validation; project-authored tests/scripts can modify files or use network.
/// The Runner resolves a Rust/Go/Python/Node recipe and admits a canonical Job.
ProjectValidate {
/// Exact registered Runner Project.
project: String,
Expand All @@ -1552,7 +1552,10 @@ pub enum ToolCall {
#[serde(default)]
cwd: Option<String>,
action: webcodex_core::project_validation::ProjectValidationAction,
/// Omission means auto. Rust and Go are supported; Python supports test through pytest and check/format_check through project-local Ruff with explicit target-version; Node returns unavailable.
/// Omission means auto. Rust and Go are supported; Python supports test through pytest and
/// check/format_check through project-local Ruff with explicit target-version.
/// Node supports native script check and opt-in native TAP test; no caller-selected
/// script names or argv.
#[serde(default)]
adapter: Option<webcodex_core::project_validation::ProjectValidationAdapter>,
/// Optional portable scope. Explicit packages narrow Rust/Go selection; all_packages=true selects the
Expand All @@ -1564,8 +1567,8 @@ pub enum ToolCall {
#[serde(default)]
dependency_policy: Option<webcodex_core::project_validation::ProjectDependencyPolicy>,
/// Test-only selector and count postconditions. Rust uses a libtest substring,
/// Go uses native -run regexp, and Python pytest uses -k. Omission preserves
/// unfiltered positive-test proof.
/// Go uses native -run regexp, and Python pytest uses -k. Node TAP supports
/// count requirements but rejects test filters. Omission keeps unfiltered positive-test proof by default.
#[serde(default)]
test: Option<webcodex_core::project_validation::ProjectValidationTestOptions>,
/// Total execution budget, clamped to 3600 seconds. Host grace never starts another execution.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[
)
.with_composition_policy(super::ToolCompositionPolicy::Sequential)
.with_host_orchestration_hint(super::ToolHostOrchestrationHint::sequential()),
"Runner owns project recipes and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: native check script only (check > typecheck > lint); no pre/post or other actions. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", )
"Runner owns project recipes and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: check via node --run (check > typecheck > lint; no pre/post), or native TAP test when scripts.test equals node --test or node --test --test-reporter=tap (fixed Runner argv); no npm/Jest/Vitest. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter allows Rust substring, Go -run regexp or pytest -k; Node TAP rejects filters. require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", )
.with_execution(super::ToolExecutionContract::new(
super::ToolExecutionForm::StructuredValidation,
super::ToolExecutionLifetime::Runner,
Expand Down
Loading
Loading