diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 56842d18..7f30a7f3 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -193,9 +193,37 @@ jobs: enforce "Python files" "only allowed for SaltStack" "$PY_FILES" - name: Check for npm/bun artifacts + # standards#67 — npm-avoidant: package-lock.json must never be tracked + # estate-wide. Check recursively (not just root) to catch monorepo + # sub-packages. See docs/JS-RUNTIME-POLICY.adoc. run: | - if [ -f "package-lock.json" ] || [ -f "bun.lockb" ] || [ -f ".npmrc" ] || [ -f "yarn.lock" ]; then - echo "❌ npm/bun/yarn artifacts detected. Use Deno instead." + LOCK_FILES=$(git ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null || true) + BUN_FILES=$(find . -name "bun.lockb" -not -path "./.git/*" 2>/dev/null || true) + YARN_FILES=$(find . -name "yarn.lock" -not -path "./.git/*" 2>/dev/null || true) + NPMRC_FILES=$(find . -name ".npmrc" -not -path "./.git/*" 2>/dev/null || true) + FAILED="" + if [ -n "$LOCK_FILES" ]; then + echo "❌ Tracked package-lock.json detected (standards#67 — npm-avoidant):" + printf '%s\n' "$LOCK_FILES" + FAILED=1 + fi + if [ -n "$BUN_FILES" ]; then + echo "❌ bun.lockb detected. Use Deno instead." + printf '%s\n' "$BUN_FILES" + FAILED=1 + fi + if [ -n "$YARN_FILES" ]; then + echo "❌ yarn.lock detected. Use Deno instead." + printf '%s\n' "$YARN_FILES" + FAILED=1 + fi + if [ -n "$NPMRC_FILES" ]; then + echo "❌ .npmrc detected. Use Deno deno.json for JS config." + printf '%s\n' "$NPMRC_FILES" + FAILED=1 + fi + if [ -n "$FAILED" ]; then + echo "See hyperpolymath/standards docs/JS-RUNTIME-POLICY.adoc for remediation." exit 1 fi echo "✅ No npm/bun violations" diff --git a/docs/JS-RUNTIME-POLICY.adoc b/docs/JS-RUNTIME-POLICY.adoc new file mode 100644 index 00000000..318e5c55 --- /dev/null +++ b/docs/JS-RUNTIME-POLICY.adoc @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += JS Runtime & Package Management Policy +:revdate: 2026-05-19 +:status: ACTIVE +:issue-67: hyperpolymath/standards#67 +:issue-68: hyperpolymath/standards#68 + +This document is the canonical estate-wide policy for JavaScript/TypeScript +runtimes and package management. It is referenced by +`governance-reusable.yml` (enforcement) and the canonical template +`.gitignore` files (rsr-template-repo, v3-templater). + +See also: `scripts/purge-node-modules.sh` (remediation utility). + +== Runtime Hierarchy + +Tool selection MUST follow this hierarchy: + +[cols="1,2,3", options="header"] +|=== +| Priority | Tool | Rationale + +| 1 +| *Deno* +| Architectural standard. Secure-by-default, no `node_modules` pollution, +built-in TypeScript support. All new JS/TS work starts here. + +| 2 +| *Bun* +| Practical performance fallback. Drop-in Node compatibility. Use where +specific npm-ecosystem libraries are required and Deno cannot reach. + +| 3 +| *pnpm* +| Efficiency fallback. Prefer over npm when Bun is incompatible. + +| 4 +| *npm* +| Absolute last resort. Only permitted for platform-specific requirements +(e.g. VSCode Extension publishing) or legacy audits. Every npm use must +carry a `# hypatia:ignore` inline exemption comment. +|=== + +== Hard Rules (enforced by governance-reusable.yml) + +1. `package-lock.json` MUST NOT be tracked in any repo. ({issue-67}) + - Add to `.gitignore` via canonical template propagation. + - If already tracked: `git rm --cached package-lock.json` and add to + `.gitignore`. Use `purge-node-modules.sh --force` to clean local + copies. +2. `bun.lockb`, `yarn.lock`, `.npmrc` MUST NOT be tracked. +3. `node_modules/` MUST NOT be tracked (already in canonical `.gitignore`). +4. `.editorconfig` and `.claude/` MUST NOT be tracked. ({issue-68}) + - These are local-only (agent scaffolding + editor config). + - Add to `.gitignore` via canonical template propagation. + +== Canonical .gitignore Entries (template source of truth) + +The following entries MUST be present in every repo's `.gitignore` (carried +from rsr-template-repo and v3-templater via template propagation): + +---- +# npm-avoidant (standards#67): estate JS-runtime policy is Deno>Bun>pnpm>npm. +# npm lockfiles must never be committed estate-wide. +package-lock.json +**/package-lock.json + +# Agent & editor scaffolding (standards#68) — local-only, never committed +# estate-wide. Owner decision 2026-05-16: gitignore both rather than commit +# per-repo agent/editor config. +.editorconfig +.claude/ +---- + +== Remediation + +=== Remove a tracked package-lock.json + +[source,bash] +---- +git rm --cached package-lock.json +echo 'package-lock.json' >> .gitignore +echo '**/package-lock.json' >> .gitignore +git commit -m "chore(gitignore): remove tracked package-lock.json (standards#67)" +---- + +=== Bulk remediation (estate-wide audit) + +Use the propagation script at `scripts/propagate-gitignore-67-68.sh` in this +repo to identify and prepare per-repo branches. The script is READ-ONLY by +default (dry-run); pass `--fix` to stage changes for review. + +See also: `npm-avoidant/scripts/purge-node-modules.sh` for removing local +`node_modules/` and lockfile debris. + +== Consumer-repo Audit (2026-05-19 snapshot) + +Obtained via `git ls-files` sweep across `/home/hyperpolymath/dev/repos/`: + +[cols="2,1", options="header"] +|=== +| Metric | Count + +| Repos with tracked `package-lock.json` +| 10 + +| Repos with tracked `.editorconfig` +| 118 + +| Repos with tracked `.claude/` files +| 56 +|=== + +Detailed list of repos with tracked `package-lock.json` (primary blocker for +standards#67): + +* `ci` (nested: `firefox-lsp/vscode-extension/package-lock.json`) +* `claude-integrations` (nested: `firefox-lsp/vscode-extension/package-lock.json`) +* `developer-ecosystem` (nested: `rescript-ecosystem/rescript-tea/package-lock.json`) +* `git-scripts` (nested: `ui/package-lock.json`) +* `hyperpolymath-archive` (nested: `flatracoon-netstack/interface/package-lock.json`) +* `panll` (root) +* `repos-monorepo` (nested: multiple `boj-cartridges/` sub-packages) +* `rescript-tea` (root) +* `typed-wasm` (root) +* `v3-templater` (root — RESOLVED by PR #68/69, now in `.gitignore`) + +NOTE: `.editorconfig` (118 repos) and `.claude/` (56 repos) are already-committed +historical files. The gitignore fix prevents new commits; removing existing +tracked copies is a separate per-repo chore, owner-gated. See +`scripts/propagate-gitignore-67-68.sh` for the propagation recipe. diff --git a/scripts/propagate-gitignore-67-68.sh b/scripts/propagate-gitignore-67-68.sh new file mode 100755 index 00000000..1a6cf668 --- /dev/null +++ b/scripts/propagate-gitignore-67-68.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# propagate-gitignore-67-68.sh — Propagate canonical .gitignore additions +# for standards#67 (package-lock.json) and standards#68 (.editorconfig / +# .claude/) to consumer repos. +# +# Mode: READ-ONLY (audit) by default. +# Pass --fix to stage changes on a branch in each affected repo. +# The script NEVER commits or pushes — that is the human's job. +# +# Principles (do not violate): +# * Non-destructive: only modifies .gitignore (never removes tracked files). +# * Idempotent: entries already present are not duplicated. +# * No auto-commit / auto-push (estate guardrail: no unattended mutations). +# * Shell-only: no Python, no SaltStack, no Ruby. +# +# Usage: +# bash standards/scripts/propagate-gitignore-67-68.sh [--fix] [REPO_DIR] +# +# REPO_DIR defaults to ~/dev/repos. +# +# With --fix, for each affected repo this script: +# 1. Creates branch chore/gitignore-67-68 off default branch (if not exists). +# 2. Appends missing entries to .gitignore. +# 3. git rm --cached any tracked package-lock.json (staged, not committed). +# Leaves commits + push to the human operator. +# +# Output: tab-separated audit lines suitable for review. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +ISSUES_REF="Refs hyperpolymath/standards#67\nRefs hyperpolymath/standards#68" +BRANCH="chore/gitignore-67-68" +FIX_MODE=false +REPO_ROOT="${HOME}/dev/repos" + +while [[ $# -gt 0 ]]; do + case "$1" in + --fix) FIX_MODE=true; shift ;; + *) REPO_ROOT="$1"; shift ;; + esac +done + +# Canonical .gitignore block to inject (only entries not already present) +BLOCK_67='# npm-avoidant (standards#67): estate JS-runtime policy is Deno>Bun>pnpm>npm. +# npm lockfiles must never be committed estate-wide. +package-lock.json +**/package-lock.json' + +BLOCK_68='# Agent & editor scaffolding (standards#68) — local-only, never committed +# estate-wide. Owner decision 2026-05-16: gitignore both rather than commit +# per-repo agent/editor config. +.editorconfig +.claude/' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +log() { printf '%s\n' "$*" >&2; } +sep() { log "---"; } + +# Returns 0 if pattern is already in .gitignore (or if no .gitignore present) +already_ignored() { + local gi="$1" pattern="$2" + [ -f "$gi" ] && grep -qxF "$pattern" "$gi" +} + +append_if_missing() { + local gi="$1"; shift + local header="$1"; shift + local -a entries=("$@") + local needs_header=false + for entry in "${entries[@]}"; do + already_ignored "$gi" "$entry" || needs_header=true + done + if $needs_header; then + printf '\n%s\n' "$header" >> "$gi" + for entry in "${entries[@]}"; do + if ! already_ignored "$gi" "$entry"; then + printf '%s\n' "$entry" >> "$gi" + fi + done + fi +} + +# --------------------------------------------------------------------------- +# Main audit loop +# --------------------------------------------------------------------------- + +echo "# propagate-gitignore-67-68.sh audit — $(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "# mode: $([ "$FIX_MODE" = true ] && echo FIX || echo DRY-RUN)" +echo "# repo_root: $REPO_ROOT" +echo "#" +printf '%-50s\t%s\t%s\t%s\t%s\n' "REPO" "PKG_LOCK_TRACKED" "EDITORCONFIG_TRACKED" "CLAUDE_TRACKED" "GITIGNORE_HAS_67_68" + +for repo in "$REPO_ROOT"/*/; do + [ -d "$repo/.git" ] || continue + name="${repo%/}" + name="${name##*/}" + + pkg_lock_tracked=$(git -C "$repo" ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null | wc -l | tr -d ' ') + editorconfig_tracked=$(git -C "$repo" ls-files '.editorconfig' 2>/dev/null | wc -l | tr -d ' ') + claude_tracked=$(git -C "$repo" ls-files '.claude/' 2>/dev/null | wc -l | tr -d ' ') + + gi="$repo/.gitignore" + gi_has_67=true + gi_has_68=true + { already_ignored "$gi" "package-lock.json" && already_ignored "$gi" "**/package-lock.json"; } || gi_has_67=false + { already_ignored "$gi" ".editorconfig" && already_ignored "$gi" ".claude/"; } || gi_has_68=false + gi_status="$([ "$gi_has_67" = true ] && echo y || echo N)/$([ "$gi_has_68" = true ] && echo y || echo N)" + + printf '%-50s\t%s\t%s\t%s\t%s\n' \ + "$name" "$pkg_lock_tracked" "$editorconfig_tracked" "$claude_tracked" "$gi_status" + + if $FIX_MODE; then + needs_work=false + $gi_has_67 || needs_work=true + $gi_has_68 || needs_work=true + [ "$pkg_lock_tracked" -gt 0 ] && needs_work=true + + if $needs_work; then + default_branch=$(git -C "$repo" remote show origin 2>/dev/null \ + | grep 'HEAD branch' | cut -d' ' -f5 || echo main) + current_branch=$(git -C "$repo" branch --show-current 2>/dev/null || echo "") + + # Create fix branch if not already on it + if [ "$current_branch" != "$BRANCH" ]; then + if git -C "$repo" rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then + git -C "$repo" checkout "$BRANCH" 2>/dev/null + else + git -C "$repo" fetch origin "$default_branch" 2>/dev/null || true + git -C "$repo" checkout -b "$BRANCH" "origin/$default_branch" 2>/dev/null \ + || git -C "$repo" checkout "$BRANCH" 2>/dev/null + fi + fi + + # Ensure .gitignore exists + [ -f "$gi" ] || touch "$gi" + + # Append missing blocks + if ! $gi_has_67; then + append_if_missing "$gi" \ + "# npm-avoidant (standards#67): estate JS-runtime policy is Deno>Bun>pnpm>npm." \ + "# npm lockfiles must never be committed estate-wide." \ + "package-lock.json" \ + "**/package-lock.json" + fi + if ! $gi_has_68; then + append_if_missing "$gi" \ + "# Agent & editor scaffolding (standards#68) — local-only, never committed" \ + "# estate-wide. Owner decision 2026-05-16: gitignore both rather than commit" \ + "# per-repo agent/editor config." \ + ".editorconfig" \ + ".claude/" + fi + + # Un-track package-lock.json if tracked + if [ "$pkg_lock_tracked" -gt 0 ]; then + git -C "$repo" ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null \ + | while IFS= read -r f; do + git -C "$repo" rm --cached "$f" 2>/dev/null && log " [rm-cached] $name: $f" + done + fi + + log " [fix applied] $name — branch: $BRANCH" + fi + fi +done + +echo "#" +echo "# Audit complete." +echo "# --fix mode: review per-repo branches and open draft PRs manually." +echo "# References: $ISSUES_REF"