diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27964d1ea0c..c61c4c26106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,10 @@ jobs: - name: Lens-CI registry activation smoke if: needs.affected.outputs.v4 == 'true' || needs.affected.outputs.workflow_policy == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') run: cargo test -p v3-compiler --test integration v4_lens_registry_dag_smoke_test -- --quiet + # M1 model↔ci.yml binding — v3 integration harness uses gunbc#846 zero-test-filter; run here. + - name: M1 v4 workflow CI model/YAML binding smoke + if: needs.affected.outputs.v4 == 'true' || needs.affected.outputs.workflow_policy == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + run: cargo test -p v3-compiler --test integration v4_workflow_ci_runner_dag_smoke_test::v4_workflow_ci_m1_rust_emit_probe_modeled_and_bound_to_ci_yml -- --exact --quiet # v4 bootstrap viability — standalone `v4` jobs hit a ~5m Ubicloud shutdown mid-emit; # folding into `ci` (40m job budget) keeps one runner lifecycle. The `v4` job is a # receipt-only stub so required check names stay stable (see main ci.yml commentary). @@ -221,6 +225,16 @@ jobs: cp -R src/v4/. "${deps_root}/" rm "${deps_root}/workflow/ci.dag" target/release/v2-compiler compile --source-root "${entry_root}" --source-root "${deps_root}" --output-dir "${RUNNER_TEMP}/v4-lens-ci-rust" --target rust + # M1: full-tree --target rust + rustc gap surface (TASKS.md T-24; r3 gates #98/#100 bridge). + # Modeled authority: src/v4/workflow/ci.dag `m1_rust_emit_probe_signal` + + # `m1_ci_live_workflow_signal` (feature:project-github-actions-landed; non-blocking). + - name: M1 v4 full-tree rust emit probe (v2 emit + cargo check) + if: needs.affected.outputs.v4 == 'true' || needs.affected.outputs.workflow_policy == 'true' + continue-on-error: true + timeout-minutes: 20 + env: + V4_M1_RUST_EMIT_OUT: ${{ runner.temp }}/v4-rust-emit + run: bash scripts/v4-m1-rust-emit-probe.sh - name: v2 → v4 bootstrap compile (fail-closed full) if: needs.affected.outputs.v4 == 'true' id: v4_bootstrap_compile diff --git a/scripts/detect-affected-components.sh b/scripts/detect-affected-components.sh index a146e2bc56b..2c8603d634b 100755 --- a/scripts/detect-affected-components.sh +++ b/scripts/detect-affected-components.sh @@ -92,7 +92,8 @@ fi # v4 affected: src/v4/ touched OR workspace deps changed (deps can break v2 build, # which v4 depends for bootstrap). Also dsl/std/ — MVP-1 gate compile dep pool # (fixtures/v4-mvp1/add); dsl/std-only PRs must re-run the add receipt. -if echo "$changed" | grep -qE '^src/v4/|^fixtures/v4-mvp1/|^scripts/v4-mvp1|^dsl/std/|^Cargo\.(toml|lock)$'; then +# scripts/v4-mvp1* and scripts/v4-m1* — v4 CI shell gates (MVP-1 e2e, M1 rust emit probe). +if echo "$changed" | grep -qE '^src/v4/|^fixtures/v4-mvp1/|^scripts/v4-mvp1|^scripts/v4-m1|^dsl/std/|^Cargo\.(toml|lock)$'; then v4_state="true" echo "v4 affected: yes (running v2→v4 bootstrap viability test)" >&2 else diff --git a/scripts/v4-m1-rust-emit-probe.sh b/scripts/v4-m1-rust-emit-probe.sh new file mode 100755 index 00000000000..2e652c83aed --- /dev/null +++ b/scripts/v4-m1-rust-emit-probe.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# scripts/v4-m1-rust-emit-probe.sh +# +# M1 informational probe: v2-compiler --target rust over full src/v4, then +# cargo check on emitted output. Surfaces v2 Rust emitter / rustc gaps without +# failing CI (probe mode exits 0 unless V4_M1_RUST_EMIT_PROBE_STRICT=1). +# +# Authority: src/v4/workflow/ci.dag (T-24) + src/v4/TASKS.md T-24; r3 gates #98/#100 interim bridge. +# Pattern: scripts/v4-bootstrap-viability.sh (compile + log receipt parsing). +# +# Env: +# V2_COMPILER — v2-compiler binary (default: target/release/v2-compiler) +# V4_M1_RUST_EMIT_OUT — emit output dir (default: /tmp/v4-rust-emit) +# V4_M1_RUST_EMIT_LOG — v2 compile log (default: ${OUT}.compile.log) +# V4_M1_RUSTC_LOG — cargo check log (default: ${OUT}.rustc.log) +# V4_M1_RUST_EMIT_PROBE_STRICT — if 1, exit non-zero when compile or rustc fails +# V4_M1_RUSTC_TIMEOUT_SECS — optional timeout for cargo check (CI: 600) + +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +bin="${V2_COMPILER:-target/release/v2-compiler}" +if [[ -n "${GITHUB_ACTIONS:-}" && -z "${V4_M1_RUST_EMIT_OUT:-}" ]]; then + out="${RUNNER_TEMP:-/tmp}/v4-rust-emit" +else + out="${V4_M1_RUST_EMIT_OUT:-/tmp/v4-rust-emit}" +fi +# Avoid ctrl-build shims for cargo check — emitted tree lives on the runner filesystem. +if [[ -x /opt/cargo/bin/cargo ]]; then + cargo_bin="/opt/cargo/bin/cargo" +else + cargo_bin="${CARGO_BIN:-cargo}" +fi +compile_log="${V4_M1_RUST_EMIT_LOG:-${out}.compile.log}" +rustc_log="${V4_M1_RUSTC_LOG:-${out}.rustc.log}" +summary="${out}.m1-probe-summary.txt" +strict="${V4_M1_RUST_EMIT_PROBE_STRICT:-0}" + +if [[ ! -x "$bin" ]]; then + echo "error: v2-compiler not found at $bin (build v2-compiler --release first)" >&2 + if [[ "$strict" == "1" ]]; then + exit 1 + fi + echo "::notice title=M1 rust emit probe::skipped — v2-compiler missing" + exit 0 +fi + +rm -rf "$out" +mkdir -p "$(dirname "$compile_log")" + +emit_notice() { + local title="$1" + local body="$2" + # GitHub Actions workflow commands tolerate newlines in body when escaped. + local escaped="${body//$'\n'/%0A}" + escaped="${escaped//\r/}" + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::notice title=${title}::${escaped}" + fi +} + +echo "=== M1: v2-compiler compile --target rust src/v4 ===" +set +e +"$bin" compile --source-root src/v4 --output-dir "$out" --target rust 2>&1 | tee "$compile_log" +compile_status=${PIPESTATUS[0]} +set -e + +compiled_receipt="" +if [[ -f "$compile_log" ]]; then + compiled_receipt="$(grep -E '^compiled: [0-9]+ files emitted, [0-9]+ diagnostics$' "$compile_log" | tail -1 || true)" +fi + +files_emitted=0 +v2_diagnostics=0 +if [[ -n "$compiled_receipt" ]]; then + files_emitted="$(echo "$compiled_receipt" | sed -n 's/^compiled: \([0-9]*\) files emitted, \([0-9]*\) diagnostics$/\1/p')" + v2_diagnostics="$(echo "$compiled_receipt" | sed -n 's/^compiled: \([0-9]*\) files emitted, \([0-9]*\) diagnostics$/\2/p')" +fi + +rs_on_disk=0 +if [[ -d "$out" ]]; then + rs_on_disk="$(find "$out" -name '*.rs' 2>/dev/null | wc -l | tr -d ' ')" +fi + +# v2 diagnostic lines: error[file:line:col]: message OR error: message +v2_error_lines=0 +v2_error_categories="" +if [[ -f "$compile_log" ]]; then + v2_error_lines="$(grep -cE '^[[:space:]]*error(\[[^]]+\])?: ' "$compile_log" 2>/dev/null || true)" + v2_error_lines="${v2_error_lines:-0}" + v2_error_categories="$( + grep -oE '^[[:space:]]*error(\[[^]]+\])?: ' "$compile_log" 2>/dev/null \ + | sed 's/^[[:space:]]*//' | sort | uniq -c | sort -rn | head -20 || true + )" +fi + +rustc_attempted=false +rustc_skipped=true +rustc_skip_reason="" +rustc_status="" +rustc_error_total=0 +rustc_categories="" +rustc_files_with_errors=0 +if [[ "$compile_status" -eq 0 && -f "$out/Cargo.toml" ]]; then + rustc_attempted=true + rustc_skipped=false + rustc_skip_reason="" + echo "=== M1: cargo check on emitted tree ===" + rustc_timeout="${V4_M1_RUSTC_TIMEOUT_SECS:-}" + if [[ -n "${GITHUB_ACTIONS:-}" && -z "$rustc_timeout" ]]; then + rustc_timeout=600 + fi + set +e + if [[ -n "$rustc_timeout" ]]; then + timeout --preserve-status "$rustc_timeout" \ + "$cargo_bin" check --manifest-path "$out/Cargo.toml" 2>&1 | tee "$rustc_log" + else + "$cargo_bin" check --manifest-path "$out/Cargo.toml" 2>&1 | tee "$rustc_log" + fi + rustc_status=${PIPESTATUS[0]} + set -e +else + if [[ "$compile_status" -ne 0 ]]; then + rustc_skip_reason="compile_failed (exit ${compile_status})" + elif [[ ! -f "$out/Cargo.toml" ]]; then + rustc_skip_reason="no Cargo.toml in emit output" + else + rustc_skip_reason="unknown" + fi + echo "=== M1: skipping cargo check (${rustc_skip_reason}) ===" | tee "$rustc_log" +fi + +if [[ -f "$rustc_log" ]]; then + # grep exits 1 on zero matches; with pipefail that must not abort the probe + # before summary + non-strict exit 0 (modeled non_blocking / INVARIANTS P3/P5). + rustc_error_total="$(grep -cE '^error\[E[0-9]+\]:' "$rustc_log" 2>/dev/null || true)" + rustc_error_total="${rustc_error_total:-0}" + rustc_categories="$( + grep -oE 'error\[E[0-9]+\]:' "$rustc_log" 2>/dev/null \ + | sort | uniq -c | sort -rn | head -25 || true + )" + rustc_files_with_errors="$( + grep -oE '\-\-> src/[^:]+\.rs' "$rustc_log" 2>/dev/null \ + | sed 's/^--> //' | sort -u | wc -l | tr -d ' ' || true + )" + rustc_files_with_errors="${rustc_files_with_errors:-0}" +fi + +{ + echo "M1 v4 full-tree rust emit probe" + echo "===========================" + echo "v2 compile exit: ${compile_status}" + echo "v2 receipt: ${compiled_receipt:-}" + echo "v2 stderr error lines: ${v2_error_lines}" + echo ".rs files on disk: ${rs_on_disk}" + echo "" + echo "cargo check attempted: ${rustc_attempted}" + echo "cargo check skipped: ${rustc_skipped}" + if [[ "$rustc_attempted" == "true" ]]; then + echo "cargo check exit: ${rustc_status}" + else + echo "cargo check skip_reason: ${rustc_skip_reason}" + fi + echo "rustc error[E####] lines: ${rustc_error_total}" + echo "distinct .rs files with rustc errors: ${rustc_files_with_errors}" + echo "" + if [[ -n "$v2_error_categories" ]]; then + echo "v2 error prefix histogram (top 20):" + echo "$v2_error_categories" + echo "" + fi + if [[ -n "$rustc_categories" ]]; then + echo "rustc error code histogram (top 25):" + echo "$rustc_categories" + echo "" + fi + echo "logs: compile=${compile_log} rustc=${rustc_log}" +} | tee "$summary" + +probe_body="$(head -20 "$summary")" +emit_notice "M1 v4 rust emit probe" "$probe_body" + +if [[ "$compile_status" -ne 0 ]]; then + emit_notice "M1 compile failed" "exit=${compile_status}; see ${compile_log}" +elif [[ "$v2_diagnostics" != "0" ]]; then + emit_notice "M1 compile diagnostics" "${compiled_receipt:-see log}" +fi + +if [[ "$rustc_attempted" == "true" && "${rustc_status:-0}" -ne 0 ]]; then + emit_notice "M1 rustc gap surface" "cargo check exit=${rustc_status}; ${rustc_error_total} error lines; ${rustc_files_with_errors} files" +fi + +echo "=== M1 probe summary written to ${summary} ===" + +if [[ "$strict" == "1" ]]; then + if [[ "$compile_status" -ne 0 ]]; then + exit "$compile_status" + fi + if [[ "$rustc_skipped" == "true" ]]; then + echo "error: strict mode requires cargo check after successful compile (skip_reason=${rustc_skip_reason})" >&2 + exit 1 + fi + if [[ "$rustc_attempted" == "true" && "${rustc_status:-0}" -ne 0 ]]; then + exit "$rustc_status" + fi +fi + +exit 0 diff --git a/src/v3/compiler/tests/integration/v4_workflow_ci_runner_dag_smoke_test.rs b/src/v3/compiler/tests/integration/v4_workflow_ci_runner_dag_smoke_test.rs index e9335895b73..d7b981d6d6f 100644 --- a/src/v3/compiler/tests/integration/v4_workflow_ci_runner_dag_smoke_test.rs +++ b/src/v3/compiler/tests/integration/v4_workflow_ci_runner_dag_smoke_test.rs @@ -15,13 +15,15 @@ //! this hand-Rust parse harness. use v3_compiler::parse_for_test; -use v3_compiler::parse_surface::SurfaceItem; +use v3_compiler::parse_surface::{SurfaceExpr, SurfaceItem, SurfaceLiteral, SurfaceRecordField}; use v3_compiler::tokenize_for_test; const CI_DAG: &str = include_str!("../../../../v4/workflow/ci.dag"); const CI_DAG_PATH: &str = "src/v4/workflow/ci.dag"; const CI_YML: &str = include_str!("../../../../../.github/workflows/ci.yml"); const CI_YML_PATH: &str = ".github/workflows/ci.yml"; +const M1_BINDING_TEST_FILTER: &str = + "v4_workflow_ci_runner_dag_smoke_test::v4_workflow_ci_m1_rust_emit_probe_modeled_and_bound_to_ci_yml"; const CLAIM_DAG: &str = include_str!("../../../../v4/test/claim/workflow/affected_set_ci_runner.dag"); const CLAIM_PATH: &str = "src/v4/test/claim/workflow/affected_set_ci_runner.dag"; @@ -80,6 +82,79 @@ fn surface_declares_fn(module: &v3_compiler::parse_surface::SurfaceModule, name: }) } +fn data_body<'a>( + module: &'a v3_compiler::parse_surface::SurfaceModule, + name: &str, +) -> &'a SurfaceExpr { + module + .items + .iter() + .find_map(|item| match item { + SurfaceItem::Data { + name: item_name, + body: Some(body), + .. + } if item_name == name => Some(body), + _ => None, + }) + .unwrap_or_else(|| panic!("missing data body `{name}`")) +} + +fn record_body_field<'a>(body: &'a SurfaceExpr, field_name: &str) -> &'a SurfaceExpr { + let SurfaceExpr::Record { fields, .. } = body else { + panic!("expected record body, got {body:?}"); + }; + fields + .iter() + .find(|field| field.name == field_name) + .map(|SurfaceRecordField { value, .. }| value) + .unwrap_or_else(|| panic!("record body missing `{field_name}` field")) +} + +fn expr_string(expr: &SurfaceExpr) -> &str { + match expr { + SurfaceExpr::Literal { + value: SurfaceLiteral::String(value), + .. + } => value, + other => panic!("expected string literal expr, got {other:?}"), + } +} + +fn expr_int(expr: &SurfaceExpr) -> i64 { + match expr { + SurfaceExpr::Literal { + value: SurfaceLiteral::Int(value), + .. + } => value + .parse() + .unwrap_or_else(|_| panic!("expected int literal expr, got non-integer `{value}`")), + other => panic!("expected int literal expr, got {other:?}"), + } +} + +fn expr_bool(expr: &SurfaceExpr) -> bool { + match expr { + SurfaceExpr::Literal { + value: SurfaceLiteral::Bool(value), + .. + } => *value, + SurfaceExpr::Var { name, .. } if name == "true" => true, + SurfaceExpr::Var { name, .. } if name == "false" => false, + other => panic!("expected bool literal expr, got {other:?}"), + } +} + +fn workflow_step_block<'a>(workflow_yml: &'a str, step_name: &str) -> &'a str { + let marker = format!(" - name: {step_name}"); + let start = workflow_yml + .find(&marker) + .unwrap_or_else(|| panic!("{CI_YML_PATH}: missing workflow step `{step_name}`")); + let rest = &workflow_yml[start..]; + let end = rest.find("\n - name: ").unwrap_or(rest.len()); + &rest[..end] +} + fn surface_declares_test_claim_data( module: &v3_compiler::parse_surface::SurfaceModule, name: &str, @@ -157,6 +232,79 @@ fn v4_workflow_ci_test_claim_selection_entrypoints() { ); } +#[test] +fn v4_workflow_ci_m1_rust_emit_probe_modeled_and_bound_to_ci_yml() { + let module = parse_module(CI_DAG, CI_DAG_PATH); + assert!( + module.items.iter().any(|item| matches!( + item, + SurfaceItem::TypeSum { name, .. } if name == "CiCommand" + )), + "{CI_DAG_PATH}: CiCommand must exist" + ); + assert!( + CI_DAG.contains("| M1RustEmitProbeCommand"), + "{CI_DAG_PATH}: M1 probe must be a CiCommand arm" + ); + assert!( + CI_DAG.contains("feature:project-github-actions-landed") + && CI_DAG.contains("consumer:v4.workflow.ci m1_ci_live_workflow_signal") + && CI_DAG.contains("bind src/v4/TASKS.md T-24"), + "{CI_DAG_PATH}: M1 live-workflow bridge must carry checkable P5 dissolution tags" + ); + assert!( + surface_declares_fn(&module, "ci_command_authority_ok"), + "{CI_DAG_PATH}: command authority must cover M1RustEmitProbeCommand" + ); + assert!( + CI_DAG.contains("M1RustEmitProbeCommand => true"), + "{CI_DAG_PATH}: M1 probe command must pass authority check" + ); + assert!( + CI_DAG.contains("id: m1_rust_emit_probe_execution") + && CI_DAG.contains("id: m1_rust_emit_probe_signal"), + "{CI_DAG_PATH}: ci_pipeline must declare M1 job and gate" + ); + let live_signal = data_body(&module, "m1_ci_live_workflow_signal"); + let binding_smoke_step_name = + expr_string(record_body_field(live_signal, "binding_smoke_step_name")); + let step_name = expr_string(record_body_field(live_signal, "step_name")); + let script_path = expr_string(record_body_field(live_signal, "script_path")); + let non_blocking = expr_bool(record_body_field(live_signal, "non_blocking")); + let timeout_minutes = expr_int(record_body_field(live_signal, "timeout_minutes")); + let binding_smoke_step = workflow_step_block(CI_YML, binding_smoke_step_name); + assert!( + binding_smoke_step.contains(&format!( + "cargo test -p v3-compiler --test integration {M1_BINDING_TEST_FILTER} -- --exact --quiet" + )), + "{CI_YML_PATH}: `{binding_smoke_step_name}` must execute the M1 model/YAML binding receipt (gunbc#846 zero-test-filter bypass)" + ); + let m1_step = workflow_step_block(CI_YML, step_name); + assert!( + m1_step.contains("if: needs.affected.outputs.v4 == 'true' || needs.affected.outputs.workflow_policy == 'true'"), + "{CI_YML_PATH}: `{step_name}` must run for v4 and workflow-policy changes" + ); + if non_blocking { + assert!( + m1_step.contains("continue-on-error: true"), + "{CI_YML_PATH}: `{step_name}` must set continue-on-error when modeled non_blocking is true" + ); + } else { + assert!( + !m1_step.contains("continue-on-error: true"), + "{CI_YML_PATH}: `{step_name}` must not set continue-on-error when modeled non_blocking is false" + ); + } + assert!( + m1_step.contains(&format!("timeout-minutes: {timeout_minutes}")), + "{CI_YML_PATH}: `{step_name}` must set timeout-minutes from modeled timeout_minutes" + ); + assert!( + m1_step.contains(&format!("run: bash {script_path}")), + "{CI_YML_PATH}: `{step_name}` must invoke the modeled probe script" + ); +} + #[test] fn v4_workflow_ci_bootstrap_gate_skip_policy_is_modeled() { let module = parse_module(CI_DAG, CI_DAG_PATH); diff --git a/src/v4/workflow/ci.dag b/src/v4/workflow/ci.dag index 8c9e86dc5e3..e78afe15103 100644 --- a/src/v4/workflow/ci.dag +++ b/src/v4/workflow/ci.dag @@ -49,6 +49,7 @@ type CiCommand | IgnoredTestCommand { test_name: String } | LensCiCommand { required_lenses: List } | BootstrapStageCompile { produces: Symbol } + | M1RustEmitProbeCommand type CiJob { id: Symbol @@ -79,11 +80,22 @@ type LensCiLiveWorkflowSignal { semantic_target: String } +// 🟡 gated — live-workflow projection interim (docs/r3-program-plan.md gates #98/#100; docs/modeling-discipline.md Practice 10) — feature:project-github-actions-landed — consumer:v4.workflow.ci m1_ci_live_workflow_signal — bind src/v4/TASKS.md T-24 — dissolve-on-arrival: emitted/projected GitHub Actions workflow reads `ci_pipeline`/`M1RustEmitProbeCommand` directly and `m1_ci_live_workflow_signal` is deleted; forbidden: adding M1 probe step facts outside `ci_pipeline` + this binding. +type M1CiLiveWorkflowSignal { + binding_smoke_step_name: String + step_name: String + script_path: String + non_blocking: Bool + timeout_minutes: Int +} + data v2_compile_src_v4: Symbol = v2_compile_src_v4 data lens_ci_registry_execution: Symbol = lens_ci_registry_execution +data m1_rust_emit_probe_execution: Symbol = m1_rust_emit_probe_execution data structural_v2_compile: Symbol = structural_v2_compile data lens_ci_registry_signal: Symbol = lens_ci_registry_signal +data m1_rust_emit_probe_signal: Symbol = m1_rust_emit_probe_signal data lens_ci_required_lenses: List = [ Complexity, @@ -100,6 +112,14 @@ data lens_ci_live_workflow_signal: LensCiLiveWorkflowSignal = { semantic_target: "rust" } +data m1_ci_live_workflow_signal: M1CiLiveWorkflowSignal = { + binding_smoke_step_name: "M1 v4 workflow CI model/YAML binding smoke" + step_name: "M1 v4 full-tree rust emit probe (v2 emit + cargo check)" + script_path: "scripts/v4-m1-rust-emit-probe.sh" + non_blocking: true + timeout_minutes: 20 +} + data ci_pipeline: Outcome = ci_pipeline_well_formed(p: CiPipeline { jobs: [ CiJob { @@ -111,6 +131,11 @@ data ci_pipeline: Outcome = ci_pipeline_well_formed(p: CiPipeline { id: lens_ci_registry_execution, command: LensCiCommand { required_lenses: lens_ci_required_lenses }, needs: [v2_compile_src_v4] + }, + CiJob { + id: m1_rust_emit_probe_execution, + command: M1RustEmitProbeCommand, + needs: [v2_compile_src_v4] } ], gates: [ @@ -123,6 +148,11 @@ data ci_pipeline: Outcome = ci_pipeline_well_formed(p: CiPipeline { id: lens_ci_registry_signal, job: lens_ci_registry_execution, run_policy: Always + }, + CiGate { + id: m1_rust_emit_probe_signal, + job: m1_rust_emit_probe_execution, + run_policy: Always } ] }) @@ -246,6 +276,7 @@ fn ci_command_authority_ok(c: CiCommand) -> Bool { IgnoredTestCommand { test_name: _ } => true LensCiCommand { required_lenses } => lens_registry_ids_resolve(lens_ids: required_lenses) BootstrapStageCompile { produces: pr } => bootstrap_stage_output(plan: bootstrap_plan, s: pr) + M1RustEmitProbeCommand => true } }