diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1db40f2c..91bfe5f2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -85,8 +85,7 @@ for the canonical statement. | Language/Tool | Use Case | Notes | |---------------|----------|-------| | **AffineScript** | Primary application code | Compiles to typed-wasm; affine/linear types. Replaces ReScript across the estate (RS/TS/JS → AffineScript → typed-wasm). | -| **Bun** | JS/TS runtime & package management (tier 1) | Default for all new work. Executes `.ts` directly, no build step. Uses an npm-compatible `package.json` plus `bun.lock` — both are expected, not anti-patterns. | -| **Deno** | JS/TS runtime (tier 2) | Grandfathered. Existing Deno projects need not migrate; prefer over pnpm/npm where Bun cannot be used. | +| **Bun** | JS runtime & package management (tier 1) | Default for all new work. Runs compiled ESM/JS directly — no bundler step. Uses an npm-compatible `package.json` plus `bun.lock` — both are expected, not anti-patterns. | | **Rust/SPARK** | Performance-critical, systems, WASM, CLI tools, safety-critical | "Rust" always means "Rust/SPARK" per terminology note above. Preferred over Ada where reachable. | | **Zig** | **APIs, FFIs, gateways, client SDKs (estate default 2026-05-28)**, memory-safe systems where Rust/SPARK is overkill | Zig is the estate-wide default for all API/FFI/gateway/client-SDK work unless explicitly special-cased; Idris2 owns ABIs. Completed V-lang→Zig migration 2026-05-28. | | **Idris2** | Formal verification (primary, ABI-style proofs) | ATS2 rejected. Proven-library status in `proven` repo. | @@ -120,18 +119,43 @@ for the canonical statement. > > The distinction that keeps both documents coherent: **Bun is the runtime, tier 1 > and unchanged; AffineScript is the language for new application code.** Those -> were run together in the withdrawn text. TypeScript is permitted only where -> AffineScript cannot reach — the same narrow, transitional carve-out JavaScript -> holds below. +> were run together in the withdrawn text. +> +> ⚠ **TIGHTENED 2026-08-27 — owner ruling.** Asked about the Bun row advertising direct +> `.ts` execution, the owner ruled: *"no typescript … that should not exist at all."* +> The previous sentence here read "TypeScript is permitted only where AffineScript cannot +> reach". That is now **too permissive**: TypeScript is not a fallback tier, and no tool +> description in this file may advertise TypeScript support. Every `.ts` reference has been +> removed from the Bun row, including "JS/TS" in its label. +> +> ⚠ **This collides with the "TypeScript Exemptions (Approved)" table below**, which +> documents real technical carve-outs — `.d.ts` declaration files, the VS Code extension +> host (npm/Node-native, `@vscode/test-electron` has no alternative), and MCP/LSP protocol +> glue. Those are **not** stylistic preferences and cannot simply be deleted. They are left +> standing and flagged for an explicit owner decision: either retire each carve-out with a +> migration path, or restate the rule as "no new TypeScript, these listed exemptions +> excepted". **Not resolved unilaterally.** > > ReScript's ban is uncontested, and its migration destination is **AffineScript**. +> **SUPERSEDED 2026-08-26 — Deno is no longer tier 2.** The owner ruled: +> *"deno is to go and bun is the way we are going, put it first everywhere unless not +> possible and explain why if not."* The "Bun > Deno > pnpm > npm" ordering above described +> a **preference**; it is now a **removal**. Deno has been **moved out of ALLOWED into BANNED** accordingly - a struck-through row in an ALLOWED table is ambiguous to the agents that read this file (codacy raised exactly that on #655). +> +> This matters because this file is what agents read first. While it said Deno was +> "grandfathered … need not migrate", agents correctly declined to migrate — and the +> dependency rules below compounded it: *"No package.json for runtime deps — use deno.json +> imports"* left repos with **no manifest at all**. `hyperpolymath/ubicity` could not build +> under any toolchain for exactly this reason (see ubicity#107). Both rules are corrected. + ### BANNED - Do Not Use | Banned | Replacement | Notes | |--------|-------------|-------| | TypeScript | AffineScript | RS/TS/JS → AffineScript → typed-wasm. | | **ReScript** | AffineScript | Banned in new code as of 2026-04-30. Existing `.res` files migrate to `.affine` directly (do not pass through ReScript). | +| **Deno** | Bun | **Being removed.** Owner ruling 2026-08-26: *"deno is to go and bun is the way we are going, put it first everywhere unless not possible and explain why if not."* Existing Deno projects must migrate to Bun; where Bun genuinely cannot be used, the reason must be documented in the repo. Assessment of all 30 remaining `deno.json` locations: #658. | | Node.js | Bun | Bun is Node-compatible; run the code, drop the runtime. | | npm | Bun | npm is tier 4 — *permitted, never preferred*, not banned. `package-lock.json` must still not be tracked (standards#67). | | yarn | Bun | yarn is not in the tier list at all. | @@ -179,13 +203,13 @@ Both are FOSS with independent governance (no Big Tech). (`docs/migrations/js-to-affinescript`) carves out MCP/LSP protocol glue and VSCode-host code (*"MCP glue … Should NOT appear in `portable now`"*). Those stay until the AffineScript MCP/LSP/VSCode bindings ship (affinescript#446). - Genuinely-portable Deno CLI scripts are the convert-now bucket. + Genuinely-portable Deno CLI scripts are the convert-now bucket; anything not yet portable to AffineScript moves to **Bun**, not left on Deno. - **Compile-verify, wire-first.** A port is not done until the `.affine` builds green (`just check`) and the compiled output is wired as the live entry with the original removed *in the same PR*. Never ship an unbuilt `.affine` or delete a working `.ts`/`.res` for one that has not compiled. -2. **No package.json for runtime deps** - Use deno.json imports -3. **No node_modules in production** - Deno caches deps automatically +2. **Use `package.json` + `bun.lock` for JS runtime deps** - Bun is npm-compatible; a manifest is REQUIRED. (This line previously said "No package.json - use deno.json imports", which left repos with undeclared dependencies that could not build under any toolchain.) +3. **`bun install --production` for production deps** - Bun resolves from `package.json` and pins via `bun.lock` 4. **No Go code** - Use Rust instead 5. **No Python** - All Python must be rewritten 6. **No Kotlin/Swift for mobile** - Use Tauri 2.0+ or Dioxus @@ -195,7 +219,7 @@ Both are FOSS with independent governance (no Big Tech). - **Primary**: Guix (guix.scm) - **Fallback**: Nix (flake.nix) -- **JS deps**: Deno (deno.json imports) +- **JS deps**: **Bun** (`package.json` + `bun.lock`); `bunx ` to run one-off tooling ### Documentation Format diff --git a/hooks/playbook-to-recipe/src/main.rs b/hooks/playbook-to-recipe/src/main.rs index 2783dff9..6fdb12bb 100644 --- a/hooks/playbook-to-recipe/src/main.rs +++ b/hooks/playbook-to-recipe/src/main.rs @@ -17,6 +17,16 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; +/// Application entry point for the playbook-to-recipe converter. +/// +/// Parses command-line arguments, reads a PLAYBOOK.a2ml file, extracts procedures, +/// and generates one Hypatia-compatible recipe file per procedure. +/// +/// # Exit codes +/// +/// - `0` (SUCCESS): All recipes written successfully. +/// - `1` (FAILURE): I/O error reading/writing files. +/// - `2`: Invalid command-line arguments. fn main() -> ExitCode { let args: Vec = env::args().skip(1).collect(); let (playbook, out_dir, repo_hint) = match parse_args(&args) { @@ -64,6 +74,18 @@ fn main() -> ExitCode { ExitCode::SUCCESS } +/// Parse command-line arguments into playbook path, output directory, and optional repo name. +/// +/// # Arguments +/// +/// Expected flags: +/// - `--playbook PATH` (required): Path to the PLAYBOOK.a2ml file to parse. +/// - `--out DIR` (required): Output directory for generated recipe files. +/// - `--repo NAME` (optional): Repository name to use in recipe IDs (defaults to inferred name). +/// +/// # Returns +/// +/// `Ok((playbook_path, output_dir, repo_name))` on success, or an error message string. fn parse_args(args: &[String]) -> Result<(PathBuf, PathBuf, Option), String> { let mut pb: Option = None; let mut out: Option = None; @@ -95,6 +117,14 @@ fn parse_args(args: &[String]) -> Result<(PathBuf, PathBuf, Option), Str )) } +/// Infer the repository name by walking up from the playbook path. +/// +/// Looks for a `.machine_readable/` directory in the path hierarchy and returns +/// the name of its parent directory as the repository name. +/// +/// # Returns +/// +/// The inferred repository name, or `"unknown"` if no `.machine_readable/` parent is found. fn infer_repo(playbook: &Path) -> String { // Find the repo name: look up from .machine_readable/ grandparent. let mut cur = playbook.parent(); @@ -259,6 +289,20 @@ fn balanced_sexp(s: &str) -> &str { s } +/// Render a recipe as A2ML text for a single procedure. +/// +/// Generates a Hypatia-compatible recipe file with SPDX header, metadata, +/// and the procedure's steps. +/// +/// # Arguments +/// +/// * `repo` - The repository name (used in the recipe ID). +/// * `proc_name` - The procedure name (e.g., `"deploy"`, `"rollback"`). +/// * `steps` - The ordered list of (step_name, command) pairs. +/// +/// # Returns +/// +/// A complete A2ML recipe document as a string. fn render_recipe(repo: &str, proc_name: &str, steps: &[(String, String)]) -> String { let mut s = String::new(); s.push_str("# SPDX-License-Identifier: MPL-2.0\n"); @@ -282,6 +326,17 @@ fn render_recipe(repo: &str, proc_name: &str, steps: &[(String, String)]) -> Str s } +/// Convert a hyphen/underscore-separated string to title case. +/// +/// Replaces hyphens and underscores with spaces and capitalises the first +/// character of each word. +/// +/// # Examples +/// +/// ``` +/// assert_eq!(title_case("deploy"), "Deploy"); +/// assert_eq!(title_case("run-tests"), "Run Tests"); +/// ``` fn title_case(s: &str) -> String { let mut out = String::new(); let mut cap = true; diff --git a/k9-coordination-protocol/tools/k9-init/src/main.rs b/k9-coordination-protocol/tools/k9-init/src/main.rs index 36929570..3f573584 100644 --- a/k9-coordination-protocol/tools/k9-init/src/main.rs +++ b/k9-coordination-protocol/tools/k9-init/src/main.rs @@ -15,6 +15,9 @@ use std::process::ExitCode; const SCHEMA_VERSION: &str = "1.0.0"; +/// Facts gathered from a repository's descriptile files. +/// +/// Used to populate the initial coordination.k9 scaffold. struct RepoFacts { repo_name: String, languages: Vec, @@ -25,6 +28,16 @@ struct RepoFacts { has_neurosym: bool, } +/// Application entry point for the k9-init scaffold tool. +/// +/// Reads descriptile files from `.machine_readable/descriptiles/` and generates +/// a starter `coordination.k9` file with language, practices, and metadata. +/// +/// # Exit codes +/// +/// - `0` (SUCCESS): coordination.k9 written successfully. +/// - `1` (FAILURE): Output file already exists and --force was not given, or write failed. +/// - `2`: Invalid command-line arguments. fn main() -> ExitCode { let args: Vec = env::args().skip(1).collect(); let (repo_root, out_path, force) = match parse_args(&args) { @@ -73,6 +86,18 @@ fn main() -> ExitCode { } } +/// Parse command-line arguments for k9-init. +/// +/// # Arguments +/// +/// Expected arguments: +/// - `[REPO_ROOT]` (positional, optional): Repository root directory (defaults to `.`). +/// - `--out PATH` (optional): Output path for coordination.k9 (defaults to `/coordination.k9`). +/// - `--force` (optional): Overwrite existing output file without prompting. +/// +/// # Returns +/// +/// `Ok((repo_root, output_path, force_flag))` on success, or an error message string. fn parse_args(args: &[String]) -> Result<(PathBuf, Option, bool), String> { let mut out: Option = None; let mut force = false; @@ -100,6 +125,19 @@ fn parse_args(args: &[String]) -> Result<(PathBuf, Option, bool), Strin Ok((root, out, force)) } +/// Gather repository facts from descriptile files. +/// +/// Reads AGENTIC.a2ml, META.a2ml, and NEUROSYM.a2ml from +/// `.machine_readable/descriptiles/` and extracts language lists, +/// banned items, and development practices. +/// +/// # Arguments +/// +/// * `repo_root` - The root directory of the repository to scan. +/// +/// # Returns +/// +/// A `RepoFacts` struct populated with extracted information, or defaults if files are missing. fn gather_facts(repo_root: &Path) -> RepoFacts { let a2ml_dir = repo_root.join(".machine_readable").join("descriptiles"); let agentic = a2ml_dir.join("AGENTIC.a2ml"); @@ -260,6 +298,19 @@ fn extract_pairs(text: &str, section: &str) -> Vec<(String, String)> { out } +/// Render a coordination.k9 file from gathered repository facts. +/// +/// Generates a complete K9 coordination document with metadata, project info, +/// invariants derived from practices and banned languages, architecture placeholders, +/// and innervation (signal/reflex) sections. +/// +/// # Arguments +/// +/// * `f` - The repository facts to render into K9 format. +/// +/// # Returns +/// +/// A complete coordination.k9 document as a string. fn render_k9(f: &RepoFacts) -> String { let today = "TODO-DATE"; let mut s = String::new(); @@ -349,6 +400,18 @@ fn render_k9(f: &RepoFacts) -> String { s } +/// Convert a string to a slug (lowercase, alphanumeric and hyphens only). +/// +/// Replaces non-alphanumeric characters (except hyphens) with hyphens and +/// lowercases all letters. +/// +/// # Arguments +/// +/// * `s` - The string to slugify. +/// +/// # Returns +/// +/// A slug version of the input string. fn slug(s: &str) -> String { s.chars() .map(|c| { @@ -361,6 +424,17 @@ fn slug(s: &str) -> String { .collect() } +/// Map a language name to its common file extension. +/// +/// Used to generate file-pattern rules for banned languages. +/// +/// # Arguments +/// +/// * `name` - The language name (e.g., `"typescript"`, `"python"`). +/// +/// # Returns +/// +/// The common file extension for that language (e.g., `"ts"`, `"py"`). fn lang_ext(name: &str) -> String { match name.to_ascii_lowercase().as_str() { "typescript" => "ts".into(), diff --git a/k9-svc/k9-sign/src/main.rs b/k9-svc/k9-sign/src/main.rs index 0e6768e7..10300a1b 100644 --- a/k9-svc/k9-sign/src/main.rs +++ b/k9-svc/k9-sign/src/main.rs @@ -81,10 +81,27 @@ struct KeyDirs { } impl KeyDirs { + /// Create a new KeyDirs using the default system config directory. + /// + /// # Errors + /// + /// Returns an error if the system config directory cannot be determined. fn new() -> Result { Self::with_config_dir(None) } + /// Create a new KeyDirs with an optional override config directory. + /// + /// Used for testing to isolate key storage from the user's actual config. + /// + /// # Arguments + /// + /// * `config_dir` - Override config directory, or None to use the default. + /// + /// # Errors + /// + /// Returns an error if the default config directory cannot be determined + /// when `config_dir` is None. #[cfg(test)] fn with_config_dir(config_dir: Option) -> Result { let config = match config_dir { @@ -100,11 +117,22 @@ impl KeyDirs { Ok(KeyDirs { keys, trusted }) } + /// Stub implementation that always uses the default config directory. + /// + /// In non-test builds, config directory override is not supported. #[cfg(not(test))] fn with_config_dir(_config_dir: Option) -> Result { Self::new() } + /// Ensure the keys and trusted directories exist with proper permissions. + /// + /// Creates both directories if they don't exist, and on Unix systems sets + /// permissions to 700 (owner-only access). + /// + /// # Errors + /// + /// Returns an error if directory creation or permission setting fails. fn ensure_created(&self) -> Result<()> { fs::create_dir_all(&self.keys).context("Failed to create keys directory")?; fs::create_dir_all(&self.trusted).context("Failed to create trusted directory")?; @@ -120,14 +148,41 @@ impl KeyDirs { Ok(()) } + /// Return the path to a private key file for the given key name. + /// + /// # Arguments + /// + /// * `name` - The key name (e.g., `"primary"`). + /// + /// # Returns + /// + /// Path to the private key file (e.g., `~/.config/k9/keys/primary.key`). fn private_key_path(&self, name: &str) -> PathBuf { self.keys.join(format!("{}.key", name)) } + /// Return the path to a public key file for the given key name. + /// + /// # Arguments + /// + /// * `name` - The key name (e.g., `"primary"`). + /// + /// # Returns + /// + /// Path to the public key file (e.g., `~/.config/k9/keys/primary.pub`). fn public_key_path(&self, name: &str) -> PathBuf { self.keys.join(format!("{}.pub", name)) } + /// Return the path to a trusted public key file for the given key name. + /// + /// # Arguments + /// + /// * `name` - The key name (e.g., `"primary"`). + /// + /// # Returns + /// + /// Path to the trusted public key file (e.g., `~/.config/k9/keys/trusted/primary.pub`). fn trusted_key_path(&self, name: &str) -> PathBuf { self.trusted.join(format!("{}.pub", name)) } diff --git a/tools/policy/check-language-policy.sh b/tools/policy/check-language-policy.sh new file mode 100755 index 00000000..a495cc96 --- /dev/null +++ b/tools/policy/check-language-policy.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Language-policy drift gate. +# +# WHY THIS EXISTS. The estate's language policy is duplicated into ~372 per-repo +# `.claude/CLAUDE.md` files across 131 repos. On 2026-08-26 a census found 868 of them +# still listed **Bun as BANNED** with Deno as its replacement - the exact inverse of the +# standing ruling - and nothing had ever detected it. Correcting `standards` fixes one copy; +# agents read the local one. +# +# WHY ASSERTIONS, NOT A DIFF. The copies are legitimately not identical: repos carry their +# own exemption tables, architecture notes and carve-outs. A byte-for-byte generator would +# be permanently red. So this gate asserts the INVARIANTS the policy must satisfy, whatever +# the surrounding wording. +# +# Exit 0 = compliant. Exit 1 = drift. Every failure prints file:line. +set -uo pipefail +status=0 +files=$(git ls-files '*CLAUDE.md' 2>/dev/null | grep -v node_modules) +[ -z "$files" ] && { echo "no CLAUDE.md tracked - nothing to check"; exit 0; } + +fail(){ printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "$2"; status=1; } + +for f in $files; do + echo "checking $f" + + # --- must NOT appear ------------------------------------------------------- + # 1. Bun banned. This is the inversion that went undetected across 868 files. + if grep -nF -- '| Bun | Deno |' "$f" >/dev/null; then + fail "$f:$(grep -nF -- '| Bun | Deno |' "$f" | head -1 | cut -d: -f1)" \ + 'Bun is listed as BANNED with Deno as replacement - inverted. Bun is tier 1.' + fi + # 2. The rule that told repos not to declare dependencies at all. hyperpolymath/ubicity + # a phrase inside a blockquote or quotation marks is HISTORY, not policy + live(){ grep -vE '^[[:space:]]*>' "$1" | grep -vE '"[^"]*'"$2"'[^"]*"|\u201c[^\u201d]*'"$2"'[^\u201d]*\u201d'; } + # imported zod and glob, shipped no manifest, and could not build under ANY toolchain. + if live "$f" 'No package.json for runtime deps' | grep -qF 'No package.json for runtime deps'; then + fail "$f:$(grep -nF 'No package.json for runtime deps' "$f" | head -1 | cut -d: -f1)" \ + 'Forbids declaring dependencies. Bun is npm-compatible; a manifest is REQUIRED.' + fi + if live "$f" 'deno.json imports' | grep -qF 'deno.json imports'; then + fail "$f:$(grep -nF 'deno.json imports' "$f" | head -1 | cut -d: -f1)" \ + 'Directs dependency declaration into deno.json. Use package.json + bun.lock.' + fi + # 3. No tool description may advertise TypeScript. Owner ruling 2026-08-27: + # "no typescript ... that should not exist at all." + if grep -nE 'Executes .\.ts. directly|JS/TS runtime' "$f" >/dev/null; then + fail "$f:$(grep -nE 'Executes .\.ts. directly|JS/TS runtime' "$f" | head -1 | cut -d: -f1)" \ + 'Advertises TypeScript execution. TypeScript is banned; do not describe tools as TS runtimes.' + fi + # 4. Blanking scars. A bulk purge substituted a token with an EMPTY STRING, which also + # produced `rm -rf /lib` in wordpress-tools (the lethal shape is /path -> /path). + if awk -F'|' 'NF==4 && $2 ~ /^[[:space:]]*$/{exit 0} END{exit 1}' "$f"; then + fail "$f" 'Policy table row with an EMPTY first cell - blanking scar from a bulk substitution.' + fi + if grep -nF '| **** |' "$f" >/dev/null; then + fail "$f:$(grep -nF '| **** |' "$f" | head -1 | cut -d: -f1)" \ + 'Empty bold cell (****) - the language name was blanked out.' + fi + if grep -nE '\*\*No new +files\*\*|Only where +cannot' "$f" >/dev/null; then + fail "$f" 'Enforcement rule with a blanked language name.' + fi + # 5. A rule may not ban the language it mandates. + if grep -nE '^\| AffineScript \| AffineScript \|' "$f" >/dev/null; then + fail "$f" 'BANNED table maps AffineScript to itself - it bans the mandated language.' + fi + + # --- must appear, if the file carries a language-policy table --------------- + if grep -qE '^### (ALLOWED|BANNED)' "$f"; then + { grep -qE '^\|[[:space:]]*\*\*Bun\*\*[[:space:]]*\|' "$f" || grep -qiE '^[-*][[:space:]]+\*{0,2}Bun\*{0,2}\b' "$f"; } || \ + fail "$f" 'No Bun row in ALLOWED. Bun is the tier-1 JS runtime and package manager.' + { grep -qE '^\|[[:space:]]*\*{0,2}Deno\*{0,2}[[:space:]]*\|[[:space:]]*\*{0,2}Bun\*{0,2}[[:space:]]*\|' "$f" || grep -qiE '^[-*][[:space:]]+Deno[[:space:]]*\(use Bun\)' "$f"; } || \ + fail "$f" 'Deno is not listed in BANNED with Bun as its replacement (ruling 2026-08-26).' + fi +done + +if [ $status -eq 0 ]; then echo "language policy OK"; else + echo + echo "Language-policy drift detected. Canonical source: hyperpolymath/standards .claude/CLAUDE.md" + echo "Fix the local copy; do not weaken this gate." +fi +exit $status diff --git a/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh new file mode 100755 index 00000000..773a906b --- /dev/null +++ b/tools/policy/check-workflows-parse.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Fail if any GitHub Actions workflow does not parse. +# +# WHY THIS EXISTS. Measured across the estate on 2026-08-27: **481 workflow files in +# 134 repos do not parse at all**. A workflow that cannot be loaded produces NO check +# run, so it is invisible to `?status=failure` sweeps and to `gh pr checks` — the gate +# simply never runs, and its absence looks exactly like success. +# +# One was root-caused to a literal BACKSPACE byte (0x08) committed inside a regex. +# The other 480 are structural YAML: 245 "mapping values are not allowed in this +# context", 137 "could not find expected ':'", 73 block-mapping errors, 6 unterminated +# quotes. +# +# Exit 0 = every workflow parses. Exit 1 = at least one does not. +set -uo pipefail + +parser="" +if command -v yq >/dev/null 2>&1; then parser=yq +elif command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then parser=python +elif command -v ruby >/dev/null 2>&1; then parser=ruby +else + echo "::warning::no YAML parser available (yq, python3+pyyaml, or ruby) — cannot verify workflows" + exit 0 +fi + +parse_ok() { + case "$parser" in + yq) yq '.' "$1" >/dev/null 2>&1 ;; + python) python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$1" >/dev/null 2>&1 ;; + ruby) ruby -ryaml -e 'YAML.safe_load(File.read(ARGV[0]), aliases: true)' "$1" >/dev/null 2>&1 ;; + esac +} + +status=0; checked=0 +while IFS= read -r f; do + [ -f "$f" ] || continue + checked=$((checked + 1)) + if ! parse_ok "$f"; then + status=1 + printf '::error file=%s::workflow does not parse — it produces NO check run, so this gate never executes\n' "$f" + case "$parser" in + yq) yq '.' "$f" 2>&1 | head -2 | sed 's/^/ /' ;; + python) python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$f" 2>&1 | tail -2 | sed 's/^/ /' ;; + ruby) ruby -ryaml -e 'YAML.safe_load(File.read(ARGV[0]), aliases: true)' "$f" 2>&1 | head -2 | sed 's/^/ /' ;; + esac + # control characters are a common, easily-missed cause + if grep -qP '[\x00-\x08\x0B\x0C\x0E-\x1F]' "$f" 2>/dev/null; then + echo " ⚠ contains CONTROL CHARACTERS — YAML forbids them; see empty-linter" + grep -nP '[\x00-\x08\x0B\x0C\x0E-\x1F]' "$f" | head -3 | cat -v | sed 's/^/ /' + fi + fi +done < <(git ls-files '.github/workflows/*.yml' '.github/workflows/*.yaml' '**/.github/workflows/*.yml' '**/.github/workflows/*.yaml' 2>/dev/null | sort -u) + +if [ "$checked" -eq 0 ]; then echo "no workflows tracked — nothing to check"; exit 0; fi +if [ "$status" -eq 0 ]; then echo "✅ all $checked workflow(s) parse"; else + echo + echo "A workflow that does not parse produces no check run. Its gate has never run," + echo "and its silence is indistinguishable from success. Fix the YAML; do not delete" + echo "the check." +fi +exit $status