Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/audit-self.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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) ──────────────
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/eslint-rules/no-direct-time-randomness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
2 changes: 1 addition & 1 deletion packages/core/eslint-rules/no-unsafe-zod-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/core/eslint-rules/require-otel-span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/manifest/rules-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,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",
Expand Down
11 changes: 5 additions & 6 deletions packages/preset-next-15-canonical/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions templates/ts-server/github-actions-workflow-integrity.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/install-sh/audit-r4-shipped.test.sh
Original file line number Diff line number Diff line change
@@ -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 ]
Loading
Loading