diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 9a277c98..f84e22ce 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -20,5 +20,7 @@ jobs: CODEBERG_SSH_KEY: ${{ secrets.CODEBERG_SSH_KEY }} SOURCEHUT_SSH_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} DISROOT_SSH_KEY: ${{ secrets.DISROOT_SSH_KEY }} - GITEA_SSH_KEY: ${{ secrets.GITEA_SSH_KEY }} + # Gitea is off for now: vars.GITEA_HOST is unset, so the job failed on + # every push. The reusable skips a forge whose key is not passed; add + # GITEA_SSH_KEY back here once the host is configured. RADICLE_KEY: ${{ secrets.RADICLE_KEY }} diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 3e7d5a54..4f23bd0d 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -9,6 +9,16 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. === https://github.com/hyperpolymath/panll/compare/v0.1.0...HEAD[Unreleased] +==== Removed (2026-11-05) + +* *The PCC ReScript scanner (`tools/pcc/src/rescript_scanner.rs`) is +retired.* #107 removed the `.res` sources it read. The seven +frontend-wiring checks that used it report "retired" with the reason, and +are marked for manual repair, so `pcc panel repair` no longer plans +ReScript edits. An AffineScript scanner is tracked in #148. +* *The Gitea mirror is off.* `mirror.yml` no longer passes `GITEA_SSH_KEY`, +so the shared mirror workflow skips Gitea; the other forges are unchanged. + ==== Added (2026-10-26 — every panel-backend command registered) * *The IPC table in `+src-gossamer/src/ipc.rs+` now registers all 239 diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 837fb6fb..15564b49 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -141,9 +141,11 @@ port. |PCC soundness |The PanLL Constraint Checker (`tools/pcc`) propagates constraints soundly and finds every violation. -|Blocked: `tools/pcc` still scans ReScript `.res` sources, which #107 -removed. It must be rebuilt for AffineScript before a proof means -anything, and it needs a written specification first. +|Blocked: `tools/pcc`'s ReScript scanner is retired, because #107 removed +the `.res` sources it read. Its frontend-wiring checks now report +"retired" until an AffineScript scanner replaces them. The propagator +itself can be specified and proved first; the scanner proof waits on +the AffineScript port, and it needs a written specification first. |#148 |Verification Dashboard accuracy @@ -153,10 +155,18 @@ port. |#148 |Provenance engine -|Provenance tracking is complete (every action is recorded). "Unforgeable" -is a cryptographic claim and needs a threat model first. -|Not started; needs a specification. -|— +|Provenance tracking is complete (every action is recorded) and +unforgeable. Owner decision (2026-11-05): "unforgeable" means each +provenance record follows `EXHIBIT-B-QUANTUM-SAFE.txt`. The record holds +the author identity, a timestamp, a SHA3-256 content hash and, optionally, +a parent hash. It is serialised as canonical JSON, signed with ML-DSA-65 +and witnessed, and verifies against keys published under +`.well-known/keys/`. Today's engine (git blame plus Co-Authored-By +trailers) is forgeable, so its output counts only as "claimed". +|Not started. The signed record format and verifier come first; the +proof obligation is that verification rejects any record whose fields, +hash or chain were altered. +|#173 |Cartridge schema validation |Loading a cartridge validates it against `src/abi/cartridge-schema.json`. diff --git a/tools/pcc/src/main.rs b/tools/pcc/src/main.rs index 4df1f7b4..db4218de 100644 --- a/tools/pcc/src/main.rs +++ b/tools/pcc/src/main.rs @@ -23,7 +23,6 @@ mod policy; mod propagator; mod repairer; mod reporter; -mod rescript_scanner; mod scanner; use clap::{Parser, Subcommand}; diff --git a/tools/pcc/src/propagator.rs b/tools/pcc/src/propagator.rs index 90566583..4f8c2133 100644 --- a/tools/pcc/src/propagator.rs +++ b/tools/pcc/src/propagator.rs @@ -9,7 +9,9 @@ //! the primary bottleneck — the single root failure blocking the most work. use crate::contract::PanelContract; -use crate::obligation::{FailureClass, Obligation, ObligationKind, ObligationStatus}; +use crate::obligation::{ + FailureClass, Obligation, ObligationKind, ObligationStatus, Repairability, +}; use crate::scanner::Scanner; use std::collections::{HashMap, HashSet}; @@ -103,6 +105,15 @@ pub fn propagate( } else { obligation.status = ObligationStatus::Unsatisfied; obligation.failure_class = Some(FailureClass::Root); + // The ReScript frontend is gone (#107), so checks against `.res` + // files are retired, and no repair should write ReScript. + if let Some(file) = obligation.file.as_deref().filter(|f| f.ends_with(".res")) { + obligation.message = format!( + "Check retired: {file} was ReScript, removed in #107; \ + an AffineScript scanner is tracked in #148" + ); + obligation.repairability = Repairability::Manual; + } } } else { // Should not happen with our DAG, but defensive. diff --git a/tools/pcc/src/rescript_scanner.rs b/tools/pcc/src/rescript_scanner.rs deleted file mode 100644 index d983868d..00000000 --- a/tools/pcc/src/rescript_scanner.rs +++ /dev/null @@ -1,308 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -//! Structure-aware ReScript scanner. -//! -//! A lightweight structural scanner that understands enough ReScript -//! syntax to distinguish declarations from references, strip comments, -//! and match on structural patterns rather than raw text. -//! -//! This is NOT a full parser — it's a line-oriented scanner with -//! enough context awareness to avoid false positives on comments, -//! strings, and partial matches. - -/// A stripped line with its original line number preserved. -/// -/// After comment stripping, each line retains a reference to its -/// original 1-indexed position in the source file, enabling accurate -/// diagnostic reporting even though comments have been removed. -#[derive(Debug)] -pub struct StrippedLine { - /// 1-indexed line number in the original source file. - pub number: usize, - /// Line content after comment stripping, trimmed of leading/trailing whitespace. - pub content: String, - /// Count of leading whitespace characters in the original line. - pub indent: usize, - /// The original line before any comment stripping. - pub raw: String, -} - -/// Strip `//` line comments and `/* */` block comments from ReScript source. -/// -/// Preserves line numbers by emitting one `StrippedLine` per source line, -/// even if the line was entirely a comment (in which case `content` is empty). -/// String literals are traversed without stripping, so patterns inside -/// `"..."` are preserved and won't match structural queries. -/// -/// Handles: -/// - Single-line comments: `// ...` -/// - Block comments: `/* ... */` (including multi-line) -/// - Escaped characters in strings: `"foo\"bar"` -/// - Nested block comments are NOT supported (ReScript doesn't nest them) -pub fn strip_comments(source: &str) -> Vec { - let mut lines = Vec::new(); - let mut in_block_comment = false; - - for (idx, raw_line) in source.lines().enumerate() { - let mut line = String::new(); - let mut chars = raw_line.chars().peekable(); - let indent = raw_line.len() - raw_line.trim_start().len(); - - while let Some(&ch) = chars.peek() { - if in_block_comment { - if ch == '*' { - chars.next(); - if chars.peek() == Some(&'/') { - chars.next(); - in_block_comment = false; - continue; - } - } else { - chars.next(); - } - continue; - } - - if ch == '/' { - chars.next(); - match chars.peek() { - Some(&'/') => break, // Line comment — rest of line ignored - Some(&'*') => { - chars.next(); - in_block_comment = true; - continue; - } - _ => line.push('/'), - } - continue; - } - - // Skip string contents to avoid matching inside string literals. - // We still push the characters so that structural patterns outside - // strings remain at the correct offsets, but the string interior - // won't accidentally match a query like `id: PanelFoo`. - if ch == '"' { - line.push(ch); - chars.next(); - while let Some(&c) = chars.peek() { - line.push(c); - chars.next(); - if c == '"' { - break; - } - if c == '\\' { - if let Some(&escaped) = chars.peek() { - line.push(escaped); - chars.next(); - } - } - } - continue; - } - - line.push(ch); - chars.next(); - } - - lines.push(StrippedLine { - number: idx + 1, - content: line.trim().to_string(), - indent, - raw: raw_line.to_string(), - }); - } - - lines -} - -/// Find a type declaration: `type xxxMsg =` or `type xxxMsg<'a> =`. -/// -/// Returns the first line where the type is declared as a definition -/// (starting with `type`), not merely referenced. This distinguishes -/// `type fooMsg = ...` from a line that merely mentions `fooMsg`. -pub fn find_type_declaration<'a>( - lines: &'a [StrippedLine], - type_name: &str, -) -> Option<&'a StrippedLine> { - let pattern_space = format!("type {} ", type_name); - let pattern_eq = format!("type {}=", type_name); - let pattern_exact = format!("type {}", type_name); - lines.iter().find(|l| { - l.content.starts_with(&pattern_space) - || l.content.starts_with(&pattern_eq) - || l.content == pattern_exact - }) -} - -/// Find a variant constructor in a type definition: `| VariantName`. -/// -/// Matches lines that begin with `|` (after trimming) followed by the -/// variant name. The variant must be followed by `(`, whitespace, or -/// end-of-line to avoid partial matches (e.g. `Panel` matching `PanelFoo`). -pub fn find_variant<'a>( - lines: &'a [StrippedLine], - variant_name: &str, -) -> Option<&'a StrippedLine> { - lines.iter().find(|l| { - let trimmed = l.content.trim_start_matches("| "); - if !trimmed.starts_with(variant_name) { - return false; - } - let rest = &trimmed[variant_name.len()..]; - rest.is_empty() - || rest.starts_with('(') - || rest.starts_with(' ') - || rest.starts_with('\t') - }) -} - -/// Find a variant with a specific argument pattern: `| Name(argType)`. -/// -/// Searches for `VariantName(arg_pattern)` in lines that start with `|` -/// or contain the pattern as part of a variant constructor. -pub fn find_variant_with_arg<'a>( - lines: &'a [StrippedLine], - variant_name: &str, - arg_pattern: &str, -) -> Option<&'a StrippedLine> { - let pattern = format!("{}({})", variant_name, arg_pattern); - lines.iter().find(|l| { - let trimmed = l.content.trim_start_matches("| "); - trimmed.contains(&pattern) - }) -} - -/// Find an `include ModuleName` declaration. -/// -/// Matches lines beginning with `include` followed by the module name, -/// confirming that the module is included (re-exported) at this point. -pub fn find_include<'a>( - lines: &'a [StrippedLine], - module_name: &str, -) -> Option<&'a StrippedLine> { - let pattern = format!("include {}", module_name); - lines.iter().find(|l| l.content.starts_with(&pattern)) -} - -/// Find a record field: `fieldName:` inside a record definition. -/// -/// Matches lines containing `fieldName:` (possibly preceded by whitespace -/// or other record syntax). The colon must immediately follow the field -/// name to avoid matching substrings. -pub fn find_record_field<'a>( - lines: &'a [StrippedLine], - field_name: &str, -) -> Option<&'a StrippedLine> { - let pattern_colon_space = format!("{}: ", field_name); - let pattern_colon = format!("{}:", field_name); - lines.iter().find(|l| { - l.content.starts_with(&pattern_colon) - || l.content.contains(&format!(" {}", &pattern_colon_space)) - || l.content.contains(&format!("\t{}", &pattern_colon_space)) - }) -} - -/// Find a match arm or any line containing the given pattern text. -/// -/// Used for patterns like `Some(PanelXxx)` in switch expressions. -/// Falls back to a simple contains-search on the stripped (comment-free) -/// content, which is still an improvement over raw-text search because -/// comments have been removed. -pub fn find_match_arm<'a>( - lines: &'a [StrippedLine], - pattern_text: &str, -) -> Option<&'a StrippedLine> { - lines.iter().find(|l| l.content.contains(pattern_text)) -} - -/// Find a panel registry entry: `id: PanelXxx` inside an allPanels array. -/// -/// Searches for `id: {view_route}` in the stripped content, matching -/// the panel registration pattern used in PanelRegistry.res. -pub fn find_registry_entry<'a>( - lines: &'a [StrippedLine], - view_route: &str, -) -> Option<&'a StrippedLine> { - let pattern = format!("id: {}", view_route); - lines.iter().find(|l| l.content.contains(&pattern)) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Verify that single-line comments are stripped. - #[test] - fn test_strip_line_comment() { - let source = "let x = 1 // this is a comment\nlet y = 2"; - let lines = strip_comments(source); - assert_eq!(lines[0].content, "let x = 1"); - assert_eq!(lines[0].number, 1); - assert_eq!(lines[1].content, "let y = 2"); - } - - /// Verify that block comments are stripped, including multi-line. - #[test] - fn test_strip_block_comment() { - let source = "let x = /* hidden */ 1\n/* start\nend */ let y = 2"; - let lines = strip_comments(source); - assert_eq!(lines[0].content, "let x = 1"); - assert_eq!(lines[2].content, "let y = 2"); - } - - /// Verify that string contents are not stripped as comments. - #[test] - fn test_string_preserved() { - let source = r#"let s = "// not a comment""#; - let lines = strip_comments(source); - assert!(lines[0].content.contains("// not a comment")); - } - - /// Verify type declaration matching. - #[test] - fn test_find_type_declaration() { - let source = "type fooMsg =\n | Increment\n | Decrement"; - let lines = strip_comments(source); - let result = find_type_declaration(&lines, "fooMsg"); - assert!(result.is_some()); - assert_eq!(result.unwrap().number, 1); - } - - /// Verify type declaration does not false-positive on references. - #[test] - fn test_type_declaration_not_reference() { - let source = "let x: fooMsg = Increment"; - let lines = strip_comments(source); - let result = find_type_declaration(&lines, "fooMsg"); - assert!(result.is_none()); - } - - /// Verify variant detection. - #[test] - fn test_find_variant() { - let source = "type msg =\n | PanelFoo\n | PanelBar(int)"; - let lines = strip_comments(source); - assert!(find_variant(&lines, "PanelFoo").is_some()); - assert!(find_variant(&lines, "PanelBar").is_some()); - // Should not match partial names. - assert!(find_variant(&lines, "Panel").is_none()); - } - - /// Verify include detection. - #[test] - fn test_find_include() { - let source = "include FooModel\ninclude BarModel"; - let lines = strip_comments(source); - assert!(find_include(&lines, "FooModel").is_some()); - assert!(find_include(&lines, "BazModel").is_none()); - } - - /// Verify registry entry detection. - #[test] - fn test_find_registry_entry() { - let source = " {id: PanelFoo, name: \"Foo\"}"; - let lines = strip_comments(source); - assert!(find_registry_entry(&lines, "PanelFoo").is_some()); - assert!(find_registry_entry(&lines, "PanelBar").is_none()); - } -} diff --git a/tools/pcc/src/scanner.rs b/tools/pcc/src/scanner.rs index 5ea10eb7..9f8bdc7e 100644 --- a/tools/pcc/src/scanner.rs +++ b/tools/pcc/src/scanner.rs @@ -1,15 +1,14 @@ // SPDX-License-Identifier: MPL-2.0 -//! Repo fact extraction — structure-aware scanning of .res source files. +//! Repo fact extraction. //! -//! The scanner reads PanLL source files and uses a lightweight structural -//! scanner (`rescript_scanner`) to strip comments, understand declarations -//! vs references, and match on structural patterns rather than raw text. -//! This eliminates false positives on comments, strings, and partial matches -//! while preserving accurate line numbers for diagnostics. +//! The frontend-wiring checks (registry, model, msg, view, contractiles) +//! scanned ReScript `.res` sources. #107 removed those sources, so the +//! ReScript scanner is retired and each of those checks reports itself as +//! retired (`found: false`) until an AffineScript scanner exists (#148). +//! The test-bundle check does not read frontend sources and still runs. use crate::contract::PanelContract; -use crate::rescript_scanner::{self, StrippedLine}; use std::path::{Path, PathBuf}; /// Convert a PascalCase identifier to snake_case. @@ -65,154 +64,59 @@ impl Scanner { } } - /// Read a file relative to the repo root, returning None if it does not exist. - fn read_file(&self, relative_path: &str) -> Option { - let full_path = self.repo_root.join(relative_path); - std::fs::read_to_string(full_path).ok() - } - - /// Read and strip comments from a file, returning comment-free lines - /// with preserved original line numbers. - /// - /// Returns `None` if the file does not exist. - fn read_and_strip(&self, relative_path: &str) -> Option> { - self.read_file(relative_path) - .map(|content| rescript_scanner::strip_comments(&content)) - } - - /// Build a not-found `ScanResult` for a missing file. - fn file_not_found(relative_path: &str) -> ScanResult { + /// A frontend-wiring check whose ReScript source no longer exists. + fn retired(relative_path: &str) -> ScanResult { ScanResult { found: false, file: relative_path.to_string(), line: None, - evidence: Some(format!("File not found: {}", relative_path)), - } - } - - /// Build a not-found `ScanResult` when a structural query returned no match. - fn not_found(relative_path: &str) -> ScanResult { - ScanResult { - found: false, - file: relative_path.to_string(), - line: None, - evidence: None, - } - } - - /// Build a found `ScanResult` from a matched `StrippedLine`. - fn found_from_line(relative_path: &str, line: &StrippedLine) -> ScanResult { - ScanResult { - found: true, - file: relative_path.to_string(), - line: Some(line.number), - evidence: Some(line.raw.trim().to_string()), + evidence: Some(format!( + "Check retired: {relative_path} was ReScript, removed in #107; \ + an AffineScript scanner is tracked in #148" + )), } } /// Check that the panel is registered in PanelRegistry.res. /// - /// Uses structural `find_registry_entry` to locate `id: {view_route}` - /// in the registry file, ignoring commented-out entries. - pub fn check_registry(&self, contract: &PanelContract) -> ScanResult { - let path = "src/modules/PanelRegistry.res"; - match self.read_and_strip(path) { - None => Self::file_not_found(path), - Some(lines) => { - match rescript_scanner::find_registry_entry(&lines, &contract.view_route) { - Some(line) => Self::found_from_line(path, line), - None => Self::not_found(path), - } - } - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_registry(&self, _contract: &PanelContract) -> ScanResult { + Self::retired("src/modules/PanelRegistry.res") } /// Check that the model includes the panel's model module. /// - /// Uses structural `find_include` to locate `include {module_name}Model` - /// in Model.res, ignoring commented-out includes. - pub fn check_model_include(&self, contract: &PanelContract) -> ScanResult { - let path = "src/Model.res"; - let include_name = format!("{}Model", contract.module_name); - match self.read_and_strip(path) { - None => Self::file_not_found(path), - Some(lines) => match rescript_scanner::find_include(&lines, &include_name) { - Some(line) => Self::found_from_line(path, line), - None => Self::not_found(path), - }, - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_model_include(&self, _contract: &PanelContract) -> ScanResult { + Self::retired("src/Model.res") } /// Check that the model record contains the panel's state field. /// - /// Uses structural `find_record_field` to locate `{model_slice}:` in - /// Model.res, ignoring fields inside comments. - pub fn check_model_slice(&self, contract: &PanelContract) -> ScanResult { - let path = "src/Model.res"; - match self.read_and_strip(path) { - None => Self::file_not_found(path), - Some(lines) => { - match rescript_scanner::find_record_field(&lines, &contract.model_slice) { - Some(line) => Self::found_from_line(path, line), - None => Self::not_found(path), - } - } - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_model_slice(&self, _contract: &PanelContract) -> ScanResult { + Self::retired("src/Model.res") } /// Check that the message namespace type is defined in Msg.res. /// - /// Uses structural `find_type_declaration` to locate `type {msg_namespace}` - /// as a declaration (not a reference), ignoring type mentions in comments. - pub fn check_msg_type(&self, contract: &PanelContract) -> ScanResult { - let path = "src/Msg.res"; - match self.read_and_strip(path) { - None => Self::file_not_found(path), - Some(lines) => { - match rescript_scanner::find_type_declaration(&lines, &contract.msg_namespace) { - Some(line) => Self::found_from_line(path, line), - None => Self::not_found(path), - } - } - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_msg_type(&self, _contract: &PanelContract) -> ScanResult { + Self::retired("src/Msg.res") } /// Check that the message routing variant exists in the `msg` union. /// - /// Uses structural `find_variant_with_arg` to locate - /// `| {module_name}({msg_namespace})` in the stripped content, - /// ignoring commented-out variants. - pub fn check_msg_variant(&self, contract: &PanelContract) -> ScanResult { - let path = "src/Msg.res"; - match self.read_and_strip(path) { - None => Self::file_not_found(path), - Some(lines) => { - match rescript_scanner::find_variant_with_arg( - &lines, - &contract.module_name, - &contract.msg_namespace, - ) { - Some(line) => Self::found_from_line(path, line), - None => Self::not_found(path), - } - } - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_msg_variant(&self, _contract: &PanelContract) -> ScanResult { + Self::retired("src/Msg.res") } /// Check that the view routes to this panel. /// - /// Uses structural `find_match_arm` to locate `Some({view_route})` - /// in View.res, ignoring commented-out match arms. - pub fn check_view_route(&self, contract: &PanelContract) -> ScanResult { - let path = "src/View.res"; - let pattern = format!("Some({})", contract.view_route); - match self.read_and_strip(path) { - None => Self::file_not_found(path), - Some(lines) => match rescript_scanner::find_match_arm(&lines, &pattern) { - Some(line) => Self::found_from_line(path, line), - None => Self::not_found(path), - }, - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_view_route(&self, _contract: &PanelContract) -> ScanResult { + Self::retired("src/View.res") } /// Run the full model check (include + slice). @@ -272,60 +176,9 @@ impl Scanner { /// Check that the runtime contractile system is present and healthy. /// - /// Validates that `src/core/Contractiles.res` exists, exports a - /// `defaultContractiles` function, and declares at least `min_count` - /// contractile entries. Uses structure-aware scanning to ignore - /// commented-out contractile entries. - pub fn check_contractile_health(&self, min_count: usize) -> ScanResult { - let file_path = "src/core/Contractiles.res"; - - match self.read_and_strip(file_path) { - None => ScanResult { - found: false, - file: file_path.to_string(), - line: None, - evidence: Some(format!("File not found: {}", file_path)), - }, - Some(lines) => { - // Check that defaultContractiles function exists (comment-stripped). - let fn_line = lines - .iter() - .find(|l| l.content.contains("let defaultContractiles")); - - if fn_line.is_none() { - return ScanResult { - found: false, - file: file_path.to_string(), - line: None, - evidence: Some("defaultContractiles function not found".to_string()), - }; - } - - // Count contractile entries by searching stripped lines for `id: "`. - let count = lines.iter().filter(|l| l.content.contains("id: \"")).count(); - if count >= min_count { - ScanResult { - found: true, - file: file_path.to_string(), - line: fn_line.map(|l| l.number), - evidence: Some(format!( - "Found {} contractiles (minimum: {})", - count, min_count - )), - } - } else { - ScanResult { - found: false, - file: file_path.to_string(), - line: None, - evidence: Some(format!( - "Only {} contractiles found, minimum is {}", - count, min_count - )), - } - } - } - } + /// Retired with the ReScript scanner; see [`Scanner::retired`]. + pub fn check_contractile_health(&self, _min_count: usize) -> ScanResult { + Self::retired("src/core/Contractiles.res") } /// Run the full msg check (type definition + routing variant).