diff --git a/.github/workflows/audit-self.yml b/.github/workflows/audit-self.yml index dc22cf9a9..ff4cca9a5 100644 --- a/.github/workflows/audit-self.yml +++ b/.github/workflows/audit-self.yml @@ -254,6 +254,18 @@ jobs: run: bash tests/install-sh/transform-internal-refs.test.sh - name: Run audit-ai-docs consumer-mode paired-negative (D3/D5 skip on consumer installs) run: bash tests/install-sh/audit-consumer-mode.test.sh + - name: Run install-sh audit-r4 shipped test (consumer-install-hardening F6) + run: bash tests/install-sh/audit-r4-shipped.test.sh + - name: Run install-sh pre-push TS-arm closure test (consumer-install-hardening F1) + run: bash tests/install-sh/f1-ts-arm.test.sh + - name: Run install-sh workflow-integrity shipped test (consumer-install-hardening F12) + run: bash tests/install-sh/f12-workflow-integrity-shipped.test.sh + - name: Run install-sh hook-activation test (consumer-install-hardening F2) + run: bash tests/install-sh/f2-hook-activation.test.sh + - name: Run install-sh stryker packageManager detect test (consumer-install-hardening F13) + run: bash tests/install-sh/f13-stryker-pm.test.sh + - name: Run install-sh CI node-version-file test (consumer-install-hardening F11) + run: bash tests/install-sh/f11-ci-node-version.test.sh # Static type gate. Closes the missing edit-time→CI typecheck channel: `tsc # --noEmit` was a local-only `package.json` script, never gated, so a type diff --git a/install.sh b/install.sh index 2d114056a..724ce2a04 100755 --- a/install.sh +++ b/install.sh @@ -207,6 +207,35 @@ chmod_safe() { chmod "$@" } +# The shipped stryker.config.json hardcodes "packageManager": "npm" (the template can't +# self-detect). Patch the COPIED config in place to match the consumer's lockfile so a +# pnpm/yarn consumer doesn't get an npm-locked mutation run. Non-destructive: rewrites only +# the packageManager key. Guarded on --dry-run and on node availability (no node → leave npm). +patch_stryker_package_manager() { + _cfg="$PROJECT_ROOT/stryker.config.json" + if [ "$DRY_RUN" = "--dry-run" ]; then + echo " [dry-run] would set stryker packageManager from consumer lockfile" + return 0 + fi + command -v node >/dev/null 2>&1 || return 0 + [ -f "$_cfg" ] || return 0 + if [ -f "$PROJECT_ROOT/pnpm-lock.yaml" ]; then + _pm="pnpm" + elif [ -f "$PROJECT_ROOT/yarn.lock" ]; then + _pm="yarn" + else + _pm="npm" + fi + AIF_STRYKER_CFG="$_cfg" AIF_STRYKER_PM="$_pm" node -e ' + const fs = require("fs"); + const p = process.env.AIF_STRYKER_CFG; + const cfg = JSON.parse(fs.readFileSync(p, "utf8")); + cfg.packageManager = process.env.AIF_STRYKER_PM; + fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n"); + ' + echo " ✓ stryker packageManager → $_pm" +} + # ─── 1. Skills ────────────────────────────────────────── echo "▶ Skills → .claude/skills/" mkdir_safe "$PROJECT_ROOT/.claude/skills" @@ -358,6 +387,8 @@ echo "▶ Scripts → scripts/" mkdir_safe "$PROJECT_ROOT/scripts" copy_safe "$PKG_ROOT/packages/core/audit-self/audit-ai-docs.sh" "$PROJECT_ROOT/scripts/audit-ai-docs.sh" chmod_safe +x "$PROJECT_ROOT/scripts/audit-ai-docs.sh" 2>/dev/null || true +# R4 probe (ts-morph) invoked by audit-ai-docs.sh via `npx tsx scripts/audit-r4.ts`. +copy_safe "$PKG_ROOT/packages/core/probes/audit-r4.ts" "$PROJECT_ROOT/scripts/audit-r4.ts" if [ "$STACK" = "react-next" ]; then copy_safe "$PKG_ROOT/packages/preset-next-15-canonical/audit-self/audit-ai-docs.react-next.sh" "$PROJECT_ROOT/scripts/audit-ai-docs.react-next.sh" chmod_safe +x "$PROJECT_ROOT/scripts/audit-ai-docs.react-next.sh" 2>/dev/null || true @@ -375,9 +406,37 @@ copy_safe "$PKG_ROOT/packages/core/templates/shared/husky-pre-push.sh" "$PROJECT # Wave 10.5: also install the bash critical-only fallback so the dispatcher can find it. # The runtime dispatcher (husky-pre-push.sh) selects between TS-core and fallback at each push. copy_safe "$PKG_ROOT/packages/core/hooks/pre-push.fallback.sh" "$PROJECT_ROOT/packages/core/hooks/pre-push.fallback.sh" +# cih-s1 F1: also ship the TS-core hook + its bounded static import closure so the +# dispatcher's Node≥20 arm is reachable (without these, husky-pre-push.sh always +# falls to the presence-only bash fallback). The relative layout under +# packages/core/hooks/ is preserved so the dispatcher resolves $REPO_ROOT/packages/ +# core/hooks/pre-push.ts. Closure (static, re-derived to fixpoint): pre-push.ts → +# {utils/run-check.ts, utils/git.ts, checks/prior-art.ts, checks/s17.ts}. NOT shipped: +# checks/guard-liveness.ts is dynamically import()ed and degrades gracefully when absent. +for ts_hook in \ + pre-push.ts \ + utils/run-check.ts \ + utils/git.ts \ + checks/prior-art.ts \ + checks/s17.ts; do + copy_safe "$PKG_ROOT/packages/core/hooks/$ts_hook" "$PROJECT_ROOT/packages/core/hooks/$ts_hook" +done chmod_safe +x "$PROJECT_ROOT/.husky/pre-commit" "$PROJECT_ROOT/.husky/pre-push" \ "$PROJECT_ROOT/packages/core/hooks/pre-push.fallback.sh" 2>/dev/null || true +# cih-s1 F2: activate the shipped hooks deterministically. Copying the files alone leaves them +# inert — git never calls .husky/* until core.hooksPath points there. We set it directly instead +# of `npx husky init` (which would CLOBBER the .husky/pre-commit + pre-push we just shipped). +# Guarded on DRY_RUN and on PROJECT_ROOT being a git repo (no-op in non-git dirs, e.g. some tests). +if [ -n "$DRY_RUN" ]; then + echo "▶ git hooks → [dry-run] would set core.hooksPath=.husky" +elif git -C "$PROJECT_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + git -C "$PROJECT_ROOT" config core.hooksPath .husky + echo "▶ Activated git hooks → core.hooksPath=.husky" +else + echo " ⚠ not a git repo — skipped core.hooksPath activation (run: git config core.hooksPath .husky)" +fi + # ─── 5b. Custom ESLint rules plugin (used by eslint.config.mjs) ─── echo "▶ Custom ESLint rules → eslint-rules-local/" mkdir_safe "$PROJECT_ROOT/eslint-rules-local" @@ -445,7 +504,10 @@ if [ "$STACK" = "ts-server" ]; then # with no config on the ./setup path — the template exists, just copy it). copy_safe "$PKG_ROOT/templates/ts-server/dependency-cruiser.cjs" "$PROJECT_ROOT/.dependency-cruiser.cjs" copy_safe "$PKG_ROOT/templates/ts-server/stryker.config.json" "$PROJECT_ROOT/stryker.config.json" + patch_stryker_package_manager copy_safe "$PKG_ROOT/templates/ts-server/github-actions-ci.yml" "$PROJECT_ROOT/.github/workflows/ci.yml" + # R11 branch-protection self-assertion (the executable arm RULES.md#r11 names alongside ci-success). + copy_safe "$PKG_ROOT/templates/ts-server/github-actions-workflow-integrity.yml" "$PROJECT_ROOT/.github/workflows/workflow-integrity.yml" elif [ "$STACK" = "react-next" ]; then copy_safe "$PKG_ROOT/packages/preset-next-15-canonical/templates/eslint.config.react.mjs" "$PROJECT_ROOT/eslint.config.mjs" copy_safe "$PKG_ROOT/packages/preset-next-15-canonical/templates/vitest.config.ts" "$PROJECT_ROOT/vitest.config.ts" @@ -454,7 +516,10 @@ elif [ "$STACK" = "react-next" ]; then # stack-agnostic; a react-tailored layering config is a follow-up (residual R-1). copy_safe "$PKG_ROOT/templates/ts-server/dependency-cruiser.cjs" "$PROJECT_ROOT/.dependency-cruiser.cjs" copy_safe "$PKG_ROOT/templates/ts-server/stryker.config.json" "$PROJECT_ROOT/stryker.config.json" + patch_stryker_package_manager copy_safe "$PKG_ROOT/packages/preset-next-15-canonical/templates/github-actions-ci-ui.yml" "$PROJECT_ROOT/.github/workflows/ci.yml" + # R11 branch-protection self-assertion (stack-agnostic — asserts ci-success stays required). + copy_safe "$PKG_ROOT/templates/ts-server/github-actions-workflow-integrity.yml" "$PROJECT_ROOT/.github/workflows/workflow-integrity.yml" fi # ─── 7. package.json scripts (FQA S1-A W4) ────────────── @@ -494,8 +559,23 @@ if [ -f "$PROJECT_ROOT/package.json" ]; then }; let added = 0; for (const [k, v] of Object.entries(want)) if (!(k in pkg.scripts)) { pkg.scripts[k] = v; added++; } + // cih-s1 F2: also merge the devDeps the SHIPPED HOOKS need so they run, not just exist. + // .husky/pre-commit calls `npx lint-staged`; the canonical scripts call `husky` (prepare) + // and sort-package-json. Without these the hooks are dead even after `npm install`. Same + // non-destructive guard as scripts: only keys the consumer lacks; caret ranges (not in the + // framework root package.json — no range to mirror, per orchestrator note) so consumers get + // patches. devDependencies object created if absent. + pkg.devDependencies = pkg.devDependencies || {}; + const wantDev = { + "husky": "^9.1.7", + "lint-staged": "^15.2.10", + "sort-package-json": "^2.10.1" + }; + let addedDev = 0; + for (const [k, v] of Object.entries(wantDev)) if (!(k in pkg.devDependencies)) { pkg.devDependencies[k] = v; addedDev++; } fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n"); process.stderr.write(" ✓ added " + added + " script(s); " + (Object.keys(want).length - added) + " already present (kept)\n"); + process.stderr.write(" ✓ added " + addedDev + " hook devDep(s); " + (Object.keys(wantDev).length - addedDev) + " already present (kept)\n"); ' else echo " ⚠ node not found — skipped scripts merge; add them manually per INSTALL.md §3" diff --git a/packages/core/eslint-rules/no-direct-time-randomness.ts b/packages/core/eslint-rules/no-direct-time-randomness.ts index acce394b3..488746fb4 100644 --- a/packages/core/eslint-rules/no-direct-time-randomness.ts +++ b/packages/core/eslint-rules/no-direct-time-randomness.ts @@ -3,7 +3,7 @@ import type { TSESTree } from '@typescript-eslint/utils'; const createRule = ESLintUtils.RuleCreator( () => - `https://github.com/Yhooi2/rules-as-tests-aif/blob/main/factory/RULES.md#r7-time-randomness-io`, + `https://github.com/Yhooi2/rules-as-tests-aif/blob/main/packages/preset-next-15-canonical/RULES.md#r7--time-randomness-io`, ); const FORBIDDEN_MODULES = new Set([ diff --git a/packages/core/eslint-rules/no-unsafe-zod-parse.ts b/packages/core/eslint-rules/no-unsafe-zod-parse.ts index b04b9a8f5..9c131a589 100644 --- a/packages/core/eslint-rules/no-unsafe-zod-parse.ts +++ b/packages/core/eslint-rules/no-unsafe-zod-parse.ts @@ -3,7 +3,7 @@ import type { TSESTree } from '@typescript-eslint/utils'; const createRule = ESLintUtils.RuleCreator( (_name) => - `https://github.com/Yhooi2/rules-as-tests-aif/blob/main/factory/RULES.md#r2-validation-at-boundaries`, + `https://github.com/Yhooi2/rules-as-tests-aif/blob/main/packages/preset-next-15-canonical/RULES.md#r2--validation-at-boundaries`, ); export const noUnsafeZodParse = createRule({ diff --git a/packages/core/eslint-rules/require-otel-span.ts b/packages/core/eslint-rules/require-otel-span.ts index cfdbf1f01..0d69c7466 100644 --- a/packages/core/eslint-rules/require-otel-span.ts +++ b/packages/core/eslint-rules/require-otel-span.ts @@ -4,7 +4,7 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; const createRule = ESLintUtils.RuleCreator( () => - `https://github.com/Yhooi2/rules-as-tests-aif/blob/main/factory/RULES.md#r8-observability`, + `https://github.com/Yhooi2/rules-as-tests-aif/blob/main/packages/preset-next-15-canonical/RULES.md#r8--observability`, ); // Keys that form circular refs or are not AST children diff --git a/packages/core/manifest/rules-manifest.json b/packages/core/manifest/rules-manifest.json index 7818dc5e0..cf14f5d9e 100644 --- a/packages/core/manifest/rules-manifest.json +++ b/packages/core/manifest/rules-manifest.json @@ -231,7 +231,7 @@ ], "check": { "type": "command", - "command": "audit-self.yml (actionlint + zizmor → ci-success aggregate) + workflow-integrity.yml (branch-protection-assertion)" + "command": "ci.yml (lint/typecheck/architecture/test/security/audit-ai-docs → ci-success aggregate) + workflow-integrity.yml (branch-protection-assertion)" }, "examples": { "bad": "# in .github/workflows/ci.yml:\n- uses: actions/checkout@main", diff --git a/packages/preset-next-15-canonical/RULES.md b/packages/preset-next-15-canonical/RULES.md index 78eea4360..9a3938524 100644 --- a/packages/preset-next-15-canonical/RULES.md +++ b/packages/preset-next-15-canonical/RULES.md @@ -25,7 +25,7 @@ automated check. Bypass via `/aif-rules` (with rationale), never via `--no-verif | **R8 Observability** | ts-server, react-next | ESLint `rules-as-tests/require-otel-span` | | **R9 Imports / dependencies** | ts-server, react-next | ESLint `no-restricted-imports` | | **R10 Naming** | ts-server, react-next | Manual review — Naming conventions are too project-specific to formalize reliably; sidecar runs ad-hoc grep on the diff. | -| **R11 CI integrity** | ts-server, react-next | `audit-self.yml (actionlint + zizmor → ci-success aggregate) + workflow-integrity.yml (branch-protection-assertion)` | +| **R11 CI integrity** | ts-server, react-next | `ci.yml (lint/typecheck/architecture/test/security/audit-ai-docs → ci-success aggregate) + workflow-integrity.yml (branch-protection-assertion)` | | **R12 Server vs Client Components** | react-next | ESLint `rules-as-tests/no-server-imports-in-client` | | **R13 Data fetching** | react-next | Manual review — AST grep on TanStack Query / SWR usage; no ESLint rule today. | | **R14 Forms** | react-next | ESLint `rules-as-tests/require-form-safe-parse` | @@ -265,12 +265,11 @@ import fs from 'fs'; // in src/domain/ - The `ci-success` job must remain a required check on main. - New jobs are added through PR with explicit rationale. -**Check:** three executable layers, all funnelled into the single required `ci-success` aggregate context: -1. `actionlint` — YAML/expression correctness, script-injection vectors, runner-label validity. Runs in `.github/workflows/audit-self.yml` (every PR) and feeds `ci-success` via `needs:`. -2. `zizmor` — supply-chain audits (`unpinned-uses`, `dangerous-triggers`, `excessive-permissions`, `template-injection`, `cache-poisoning`). Also in `audit-self.yml`, feeds `ci-success` via `needs:`. -3. `gh api repos/:owner/:repo/branches/main/protection | jq -e '.required_status_checks.contexts | contains(["ci-success"])'` — `.github/workflows/workflow-integrity.yml` asserts the `ci-success` gate remains a required status check on `main`. +**Check:** two executable layers, both shipped by `install.sh`: +1. `.github/workflows/ci.yml` — every quality job (`lint`, `typecheck`, `architecture`, `test`, `security`, `audit-ai-docs`) is funnelled into the single required `ci-success` aggregate via `needs:`. `ci-success` is the only context that must be a required check (it always runs and depends on all jobs). +2. `.github/workflows/workflow-integrity.yml` — `branch-protection-assertion` job asserts the `ci-success` gate stays a required status check on the default protected branch. Tri-states: pass when configured-and-present, fail when configured-but-missing, warn-and-pass when no protection is configured yet (so it never blocks a fresh consumer). -Why co-located: `needs:` aggregation works only within one workflow file, and a path-filtered required check (e.g. one scoped to `.github/workflows/**`) never reports on PRs that don't touch that path → the PR deadlocks. Requiring only `ci-success` (which always runs and `needs:` the linters) avoids both. +Why one aggregate context: `needs:` aggregation works only within one workflow file, and a path-filtered required check (e.g. one scoped to `.github/workflows/**`) never reports on PRs that don't touch that path → the PR deadlocks. Requiring only `ci-success` (which always runs and `needs:` every job) avoids both. ### Examples diff --git a/templates/ts-server/github-actions-workflow-integrity.yml b/templates/ts-server/github-actions-workflow-integrity.yml new file mode 100644 index 000000000..c15cbc265 --- /dev/null +++ b/templates/ts-server/github-actions-workflow-integrity.yml @@ -0,0 +1,68 @@ +name: workflow-integrity + +# R11 — CI integrity, executable form. +# See .ai-factory/RULES.md#r11 for policy. +# +# This workflow carries the R11 branch-protection self-assertion only. +# The lint/typecheck/architecture/test/security/audit-ai-docs jobs aggregate into +# the single required `ci-success` context inside ci.yml (cross-file `needs:` is +# impossible, and a path-filtered required check deadlocks PRs that don't touch +# the filtered path). This workflow asserts that `ci-success` stays a required +# status check on the default protected branch. + +on: + pull_request: + paths: + - '.github/workflows/**' + push: + branches: [main] + paths: + - '.github/workflows/**' + +permissions: + contents: read + +jobs: + branch-protection-assertion: + name: ci-success required-check assertion + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Assert ci-success is a required status check on the default branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + # Tri-state semantics: + # - protection or rulesets configured AND include ci-success → pass + # - protection or rulesets configured but ci-success missing → fail (R11 violated) + # - no protection AND no rulesets → warn (R11 not yet adopted), pass + # Rationale: don't block PRs in repos that haven't yet configured branch + # protection. Once configured, R11 enforcement kicks in automatically. + + protection_json="$(gh api "repos/${{ github.repository }}/branches/${DEFAULT_BRANCH}/protection" 2>/dev/null || echo 'null')" + rulesets_json="$(gh api "repos/${{ github.repository }}/rulesets" 2>/dev/null || echo '[]')" + + has_protection=$(echo "$protection_json" | jq 'if . == null or (type=="object" and has("message")) then false else true end') + has_rulesets=$(echo "$rulesets_json" | jq 'if type=="array" then [.[] | select(.enforcement == "active")] | length > 0 else false end') + + if [ "$has_protection" = "true" ]; then + if echo "$protection_json" | jq -e '.required_status_checks.contexts | contains(["ci-success"])' > /dev/null; then + echo "✅ ci-success is required on ${DEFAULT_BRANCH} (classic branch protection)" + exit 0 + fi + echo "::error::Branch protection exists on ${DEFAULT_BRANCH} but ci-success is NOT in required status checks (R11 violated)." + echo "::error::Settings → Branches → ${DEFAULT_BRANCH} → Require status checks → ci-success" + exit 1 + fi + + if [ "$has_rulesets" = "true" ]; then + echo "✅ rulesets active — manually verify ci-success in https://github.com/${{ github.repository }}/settings/rules" + exit 0 + fi + + echo "::warning::No branch protection or active rulesets on ${DEFAULT_BRANCH}. R11 not yet adopted in this repo." + echo "::warning::Configure: Settings → Branches → ${DEFAULT_BRANCH} → Require status checks → ci-success" + exit 0 diff --git a/tests/install-sh/audit-r4-shipped.test.sh b/tests/install-sh/audit-r4-shipped.test.sh new file mode 100755 index 000000000..3bff97b26 --- /dev/null +++ b/tests/install-sh/audit-r4-shipped.test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# consumer-install-hardening S1 — F6: install.sh must ship the R4 probe so the caller resolves. +# The shipped scripts/audit-ai-docs.sh runs `npx --no-install tsx scripts/audit-r4.ts`; without +# the probe file that line hard-fails on a missing file. This test runs the REAL full pipeline +# (lib-only mode does not expose copy_safe — see install-sh harness note) and asserts: +# F6-pos: scripts/audit-r4.ts lands in the consumer. +# F6-neg (load-bearing): the caller actually references scripts/audit-r4.ts — proves the file +# is the thing the audit needs, so shipping it isn't vacuous. +set -uo pipefail +REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +T=$(mktemp -d) +printf '{ "name": "f6t", "version": "0.0.0" }\n' > "$T/package.json" +( cd "$T" && git init -q && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 + +# F6-pos — the R4 probe lands next to its caller. +[ -f "$T/scripts/audit-r4.ts" ] && ok "F6: scripts/audit-r4.ts shipped" || bad "F6: scripts/audit-r4.ts missing" + +# F6-neg (load-bearing) — the shipped caller invokes exactly this file. If the audit script +# stops calling audit-r4.ts, this arm fails and the fix is revealed as no longer needed. +grep -q 'scripts/audit-r4\.ts' "$T/scripts/audit-ai-docs.sh" \ + && ok "F6-neg: shipped audit-ai-docs.sh references scripts/audit-r4.ts (fix is load-bearing)" \ + || bad "F6-neg: caller no longer references audit-r4.ts — fix may be vacuous" + +echo ""; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] diff --git a/tests/install-sh/f1-ts-arm.test.sh b/tests/install-sh/f1-ts-arm.test.sh new file mode 100755 index 000000000..04c808e64 --- /dev/null +++ b/tests/install-sh/f1-ts-arm.test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# cih-s1 F1 — "ship the dispatcher's TS arm". The shipped dispatcher +# (packages/core/templates/shared/husky-pre-push.sh) execs +# `$REPO_ROOT/packages/core/hooks/pre-push.ts` when Node≥20 + that file is present, +# else falls to the bash fallback. Before this fix install.sh shipped only the +# fallback, so the TS arm was unreachable in every consumer install. This test runs +# the REAL install pipeline and asserts pre-push.ts + its bounded static import +# closure land under packages/core/hooks/ with the relative layout the dispatcher +# resolves. PAIRED-NEGATIVE: the fallback must still land (we ADD the TS arm, we +# don't replace it) AND the dynamically-import()ed guard-liveness.ts must NOT ship +# (it degrades gracefully when absent) — that negative arm proves the closure is +# bounded, not "ship everything". +set -uo pipefail +REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +T=$(mktemp -d) +printf '{ "name": "f1t", "version": "0.0.0" }\n' > "$T/package.json" +( cd "$T" && git init -q && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 + +H="$T/packages/core/hooks" + +# TS-core entrypoint the dispatcher execs +[ -f "$H/pre-push.ts" ] && ok "pre-push.ts shipped (dispatcher TS arm reachable)" || bad "pre-push.ts missing" + +# Bounded static import closure (re-derived to fixpoint) +[ -f "$H/utils/run-check.ts" ] && ok "utils/run-check.ts shipped" || bad "utils/run-check.ts missing" +[ -f "$H/utils/git.ts" ] && ok "utils/git.ts shipped" || bad "utils/git.ts missing" +[ -f "$H/checks/prior-art.ts" ] && ok "checks/prior-art.ts shipped" || bad "checks/prior-art.ts missing" +[ -f "$H/checks/s17.ts" ] && ok "checks/s17.ts shipped" || bad "checks/s17.ts missing" + +# PAIRED-NEGATIVE arm 1 — fallback still lands (TS arm is additive, not a replacement) +[ -f "$H/pre-push.fallback.sh" ] && ok "neg: bash fallback still shipped (TS arm is additive)" || bad "neg: fallback lost" + +# PAIRED-NEGATIVE arm 2 — guard-liveness.ts must NOT ship (dynamically imported, degrades gracefully) +[ ! -f "$H/checks/guard-liveness.ts" ] && ok "neg: guard-liveness.ts NOT shipped (closure bounded)" || bad "neg: guard-liveness.ts leaked — closure not bounded" + +echo ""; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] diff --git a/tests/install-sh/f11-ci-node-version.test.sh b/tests/install-sh/f11-ci-node-version.test.sh new file mode 100755 index 000000000..b41b0bae6 --- /dev/null +++ b/tests/install-sh/f11-ci-node-version.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# cih-s1 F11 — the shipped CI workflow reads the Node version from .nvmrc +# (`node-version-file: '.nvmrc'`) instead of a hardcoded `node-version: NN`, so the +# AGENTS.md claim "Node pinned in .nvmrc — CI depends on it" is actually true and CI +# can never drift from .nvmrc. F11's fix landed upstream (FQA W6 rewrote the CI +# templates); this test LOCKS it on a real landed consumer so a future hardcode +# regresses loudly. Asserts via the REAL install pipeline (mirror f13-stryker-pm.test.sh) +# for BOTH stacks — ts-server (Hono/flat) and react-next. +set -uo pipefail +REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +# Substring note: `node-version:` (colon immediately after "version") matches ONLY the +# bare hardcoded form — `node-version-file:` has "-file" before its colon, so it is NOT +# a match. That makes the negative arm a clean regression guard. +assert_stack() { + local stack="$1" + local T; T=$(mktemp -d) + printf '{"name":"f11-%s","version":"0.0.0"}\n' "$stack" > "$T/package.json" + ( cd "$T" && git init -q && bash "$REPO_ROOT/install.sh" "$stack" --force ) >/dev/null 2>&1 + local CI="$T/.github/workflows/ci.yml" + + grep -q "node-version-file: '.nvmrc'" "$CI" 2>/dev/null \ + && ok "$stack: shipped ci.yml reads node-version-file: '.nvmrc'" \ + || bad "$stack: ci.yml does NOT use node-version-file ($(grep -n node-version "$CI" 2>/dev/null | head -1))" + + # neg (load-bearing): no bare `node-version: NN` hardcode survives in the shipped CI. + if grep -q "node-version:" "$CI" 2>/dev/null; then + bad "$stack-neg: a hardcoded 'node-version:' leaked into shipped ci.yml (F11 regressed)" + else + ok "$stack-neg: no hardcoded 'node-version:' — CI can only resolve via .nvmrc" + fi + + # consistency: .nvmrc must ship non-empty, else node-version-file has nothing to read. + [ -s "$T/.nvmrc" ] \ + && ok "$stack: .nvmrc shipped non-empty ($(tr -d '\n' < "$T/.nvmrc")) — CI + .nvmrc cannot drift" \ + || bad "$stack: .nvmrc absent/empty — node-version-file would fail" +} + +assert_stack ts-server +assert_stack react-next + +echo ""; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] diff --git a/tests/install-sh/f12-workflow-integrity-shipped.test.sh b/tests/install-sh/f12-workflow-integrity-shipped.test.sh new file mode 100755 index 000000000..13b6189ec --- /dev/null +++ b/tests/install-sh/f12-workflow-integrity-shipped.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# consumer-install-hardening S1 — F12: install.sh must ship workflow-integrity.yml so the R11 +# "check" claim in the shipped .ai-factory/RULES.md matches reality. R11 names ci.yml's ci-success +# aggregate + workflow-integrity.yml (branch-protection-assertion); before this fix only ci.yml +# shipped, so the RULES.md claim referenced an artefact install never delivered. This test runs the +# REAL full pipeline (lib-only mode does not expose copy_safe — see install-sh harness note) and asserts: +# F12-pos: .github/workflows/workflow-integrity.yml lands next to ci.yml (ts-server + react-next). +# F12-neg (load-bearing): the shipped RULES.md R11 row references workflow-integrity.yml AND does +# NOT claim actionlint/zizmor/audit-self.yml — proves the shipped workflow is the artefact +# the rule names, and that no R11 claim points at an unshipped artefact. +set -uo pipefail +REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +T=$(mktemp -d) +printf '{ "name": "f12t", "version": "0.0.0" }\n' > "$T/package.json" +( cd "$T" && git init -q && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 + +# F12-pos — the branch-protection self-assertion workflow lands next to ci.yml. +[ -f "$T/.github/workflows/workflow-integrity.yml" ] \ + && ok "F12: workflow-integrity.yml shipped (ts-server)" \ + || bad "F12: workflow-integrity.yml missing (ts-server)" +[ -f "$T/.github/workflows/ci.yml" ] \ + && ok "F12: ci.yml co-located" \ + || bad "F12: ci.yml missing" + +# react-next stack ships the same self-assertion (R11 applies to both stacks). +R=$(mktemp -d); printf '{ "name": "f12r", "version": "0.0.0" }\n' > "$R/package.json" +( cd "$R" && git init -q && bash "$REPO_ROOT/install.sh" react-next --force ) >/dev/null 2>&1 +[ -f "$R/.github/workflows/workflow-integrity.yml" ] \ + && ok "F12: workflow-integrity.yml shipped (react-next)" \ + || bad "F12: workflow-integrity.yml missing (react-next)" + +# F12-neg (load-bearing) — the shipped RULES.md R11 row names workflow-integrity.yml. If the rule +# stops referencing it, this arm fails and shipping the workflow is revealed as no longer needed. +r11_row=$(grep -E '^\| \*\*R11 CI integrity\*\*' "$T/.ai-factory/RULES.md" || true) +echo "$r11_row" | grep -q 'workflow-integrity.yml' \ + && ok "F12-neg: shipped RULES.md R11 row references workflow-integrity.yml (fix is load-bearing)" \ + || bad "F12-neg: R11 row no longer names workflow-integrity.yml — fix may be vacuous" + +# Negative honesty — R11 must NOT claim an artefact install does not ship (actionlint/zizmor/audit-self). +echo "$r11_row" | grep -qiE 'actionlint|zizmor|audit-self' \ + && bad "F12-honesty: R11 row claims actionlint/zizmor/audit-self — none are shipped" \ + || ok "F12-honesty: R11 row claims no unshipped actionlint/zizmor/audit-self artefact" + +echo ""; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] diff --git a/tests/install-sh/f13-stryker-pm.test.sh b/tests/install-sh/f13-stryker-pm.test.sh new file mode 100755 index 000000000..857367285 --- /dev/null +++ b/tests/install-sh/f13-stryker-pm.test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# cih-s1 F13 — install.sh patches the COPIED stryker.config.json "packageManager" to match +# the consumer's lockfile (the shipped template hardcodes "npm" and can't self-detect). Asserts +# via the REAL install pipeline (mirror c1-wiring.test.sh): pnpm consumer → "pnpm"; plain npm +# consumer → "npm" (the unchanged baseline). The pnpm arm is the load-bearing one — it fails if +# the patch stops mattering and the template's hardcoded "npm" leaks through to a pnpm consumer. +set -uo pipefail +REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +# pnpm consumer — lockfile present BEFORE install → packageManager rewritten to pnpm +P=$(mktemp -d) +printf '{ "name": "f13p", "version": "0.0.0" }\n' > "$P/package.json" +touch "$P/pnpm-lock.yaml" +( cd "$P" && git init -q && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 +grep -q '"packageManager": "pnpm"' "$P/stryker.config.json" 2>/dev/null \ + && ok "pnpm consumer → stryker packageManager pnpm" \ + || bad "pnpm consumer: packageManager not pnpm ($(grep packageManager "$P/stryker.config.json" 2>/dev/null))" + +# plain npm consumer — no lockfile → baseline npm (template default preserved) +N=$(mktemp -d) +printf '{ "name": "f13n", "version": "0.0.0" }\n' > "$N/package.json" +( cd "$N" && git init -q && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 +grep -q '"packageManager": "npm"' "$N/stryker.config.json" 2>/dev/null \ + && ok "npm consumer → stryker packageManager npm (baseline)" \ + || bad "npm consumer: packageManager not npm ($(grep packageManager "$N/stryker.config.json" 2>/dev/null))" + +echo ""; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] diff --git a/tests/install-sh/f2-hook-activation.test.sh b/tests/install-sh/f2-hook-activation.test.sh new file mode 100755 index 000000000..e0dc68e14 --- /dev/null +++ b/tests/install-sh/f2-hook-activation.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# cih-s1 F2 — "activate the shipped git hooks". Copying .husky/* + merging a "prepare": "husky" +# script left the hooks INERT: git never calls them (core.hooksPath unset) and the deps they need +# (husky/lint-staged for `npx lint-staged`, sort-package-json) were never declared, so even after +# `npm install` the chain was dead. This asserts install.sh now (a) sets core.hooksPath=.husky and +# (b) merges the 3 hook devDeps non-destructively. Runs the REAL pipeline via the canonical smoke. +# +# PAIRED-NEGATIVE (umbrella §5): a FRESH git repo that did NOT run install has core.hooksPath unset +# and no husky devDep — the exact dead state. The positive arm proves install flips both. The +# negative arm fails if activation stops mattering (i.e. if some other step already set it). +set -uo pipefail +REPO_ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } + +# ── PAIRED-NEGATIVE: pre-install state is dead ── +N=$(mktemp -d) +printf '{ "name":"n","version":"0.0.0" }\n' > "$N/package.json" +( cd "$N" && git init -q ) +[ -z "$(git -C "$N" config core.hooksPath)" ] && ok "neg: fresh repo → core.hooksPath UNSET (the dead state)" || bad "neg: hooksPath set without install?" +node -e 'process.exit((require(process.argv[1]).devDependencies||{}).husky?1:0)' "$N/package.json" \ + && ok "neg: fresh repo → no husky devDep (the dead state)" || bad "neg: husky devDep present without install?" + +# ── POSITIVE: install activates + declares deps ── +T=$(mktemp -d) +printf '{ "name":"t","version":"0.0.0" }\n' > "$T/package.json" +( cd "$T" && git init -q && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 + +[ "$(git -C "$T" config core.hooksPath)" = ".husky" ] && ok "pos: core.hooksPath=.husky (hooks now live)" || bad "pos: core.hooksPath not set to .husky" + +node -e ' + const d = require(process.argv[1]).devDependencies || {}; + const want = { "husky":"^9.1.7", "lint-staged":"^15.2.10", "sort-package-json":"^2.10.1" }; + for (const [k,v] of Object.entries(want)) if (d[k] !== v) { console.error("missing/wrong: "+k+"="+d[k]); process.exit(1); } + process.exit(0); +' "$T/package.json" \ + && ok "pos: 3 hook devDeps merged with caret ranges (husky/lint-staged/sort-package-json)" \ + || bad "pos: hook devDeps not merged correctly" + +# ── IDEMPOTENCY: a second install changes nothing ── +before=$(node -e 'console.log(JSON.stringify(require(process.argv[1]).devDependencies))' "$T/package.json") +( cd "$T" && bash "$REPO_ROOT/install.sh" ts-server --force ) >/dev/null 2>&1 +after=$(node -e 'console.log(JSON.stringify(require(process.argv[1]).devDependencies))' "$T/package.json") +[ "$before" = "$after" ] && ok "idempotent: 2nd install → devDeps unchanged (no dupes)" || bad "idempotent: devDeps drifted: $before -> $after" +[ "$(git -C "$T" config core.hooksPath)" = ".husky" ] && ok "idempotent: 2nd install → core.hooksPath still .husky" || bad "idempotent: hooksPath changed" + +echo ""; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]