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
70 changes: 16 additions & 54 deletions .github/workflows/java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
# uses: CMaintz/foundry/.github/workflows/java.yml@<sha>
# with:
# spotbugs: true # opt-in bytecode analysis
# no_var: true # opt-in diff-scoped no-`var` rule
#
# no-`var` is not an input: it's folded into `lint` (mise.toml), enforced by the gate.
#
# Runs the same six verbs a developer runs locally (`mise run gate`). If this and
# a laptop ever disagree, that's a bug in the setup, not the code. Toolchain (JDK)
Expand Down Expand Up @@ -40,10 +41,6 @@ on:
description: Gradle task for SpotBugs.
type: string
default: "spotbugsMain"
no_var:
description: Enforce the diff-scoped no-`var` rule on changed source (opt-in).
type: boolean
default: false
osv_scanner_version:
type: string
default: "2.5.1"
Expand Down Expand Up @@ -91,17 +88,24 @@ jobs:
# gets a targeted fix. Devs still run the composite `mise run gate` locally.
- name: 'gate: lint'
id: lint
# FOUNDRY_BASE_REF is the ratchet base for lint's no-`var` check (see mise.toml):
# the PR base SHA on PRs (origin/main isn't a reliable ref in a PR checkout),
# empty on dispatch ⇒ novar falls back to origin/main. fetch-depth: 0 (above)
# supplies the history the merge-base needs.
env:
FOUNDRY_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: mise run lint
- name: How to fix (lint)
if: failure() && steps.lint.outcome == 'failure'
run: |
{
echo "## ❌ Gate failed at \`lint\` — formatting (Spotless)"
echo "## ❌ Gate failed at \`lint\` — formatting (Spotless) or no-\`var\`"
echo ""
echo "This is **100% mechanical**. Run it, commit, push — done:"
echo "**Formatting** is 100% mechanical — run it, commit, push:"
echo '```'
echo "mise run fix # = ./gradlew spotlessApply"
echo '```'
echo "**no-\`var\`** is not auto-fixable: replace \`var\` with the explicit type, or tag a justified use \`// foundry-allow-var: reason\`. It only flags \`var\` on files this PR changed."
echo "(Structural smells are a separate 'Structural smells' job, not this one.) The offending files are in the **gate: lint** step log above."
} >> "$GITHUB_STEP_SUMMARY"
- name: 'gate: typecheck'
Expand Down Expand Up @@ -231,50 +235,8 @@ jobs:
- uses: gradle/actions/setup-gradle@94bac82a5b62952e304b4f7a45a90e19c46c7e50 # v4
- run: ./gradlew ${{ inputs.spotbugs_task }}

no-var:
name: No unjustified var
if: inputs.no_var && github.event_name == 'pull_request'
runs-on: ubuntu-latest
# Runs from the repo root: `git diff` prints root-relative paths, so pmd's -d
# must resolve from root too (a non-root working-directory would double the
# prefix). The project dir is applied as a prefix below instead.
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- name: Cache PMD
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4
with:
path: ~/.local/opt/pmd-bin-${{ inputs.pmd_version }}
key: pmd-${{ inputs.pmd_version }}
- name: Install PMD
run: |
if [ ! -x "$HOME/.local/opt/pmd-bin-${{ inputs.pmd_version }}/bin/pmd" ]; then
mkdir -p "$HOME/.local/opt"
curl -fsSL -o /tmp/pmd.zip \
"https://github.com/pmd/pmd/releases/download/pmd_releases/${{ inputs.pmd_version }}/pmd-dist-${{ inputs.pmd_version }}-bin.zip"
unzip -q /tmp/pmd.zip -d "$HOME/.local/opt"
fi
echo "$HOME/.local/opt/pmd-bin-${{ inputs.pmd_version }}/bin" >> "$GITHUB_PATH"
# Diff from the merge-base (3-dot), not the raw base tip: pull_request events
# don't re-fire when main advances, so base.sha goes stale and a 2-dot diff
# would drag in files this PR never touched. Enforce no-`var` only on changed
# main source, so pre-existing uses are untouched until touched.
- name: Check changed Java files for unjustified var
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
WD: ${{ inputs.working_directory }}
run: |
set -euo pipefail
BASE=$(git merge-base "$BASE" "$HEAD")
prefix=""
if [ "$WD" != "." ]; then prefix="${WD%/}/"; fi
mapfile -t files < <(git diff --name-only --diff-filter=d "$BASE" "$HEAD" \
| grep -E "^${prefix}src/main/java/.*\.java$" || true)
if [ "${#files[@]}" -eq 0 ]; then
echo "No main Java files changed — nothing to check."; exit 0
fi
printf 'Checking %d changed file(s):\n' "${#files[@]}"; printf ' %s\n' "${files[@]}"
pmd check "${files[@]/#/-d=}" -R "${prefix}config/pmd/no-var.xml" \
-f text --no-progress --suppress-marker foundry-allow-var
# no-`var` used to be a separate job here. It's now folded into `lint` (mise.toml),
# so the deterministic gate enforces it in one place — locally and in CI — and it's
# no longer opt-in. The gate job's checkout uses fetch-depth: 0, and it passes
# FOUNDRY_BASE_REF (the PR base SHA) so novar diff-scopes correctly. See
# designs/verb-tiers.md and designs/changed-scope-gate.md.
4 changes: 4 additions & 0 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Every repo, regardless of language, exposes these six verbs:

A repo with no meaningful work for a verb still defines it as a no-op that exits 0. Absence is not permitted — a caller must never have to ask whether a verb exists.

## Auxiliary tasks

A repo may define additional `mise` tasks beyond the six (e.g. `spotbugs`, `novar`, `eval`, `setup:pmd`). These are **repo-local and not part of the contract**: no caller may assume they exist, and `gate` need not run them. They exist for work that is either not universal across repos or deliberately advisory (report-only). A capability graduates to a seventh contract verb only if it is **universal** — nearly every repo has real work for it — *and* cannot fit inside an existing verb. Until both hold, extend a verb's *composition* (what `lint`/`typecheck`/`test`/`audit` already run) or add an auxiliary task; do not grow the six.

## Rules for callers

**Callers invoke verbs, never tools.** A skill says `mise run lint`. It never says `eslint`, `phpcs`, or `ruff`. This is the whole reason a single skill library can serve a Kotlin repo and a React repo.
Expand Down
130 changes: 130 additions & 0 deletions designs/arch-fitness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Spec — Architecture fitness functions

**Status:** In progress · **Owner:** CMaintz · **Date:** 2026-09-19
**Home:** foundry (rule templates + presets + guard) · **Weight:** ⚖️ TS in-loop, JVM pre-push/CI

**Implemented:** `presets/arch/dependency-cruiser.cjs` — generic, architecture-agnostic
layer+cycle rules generated from an editable `LAYERS`/`ALLOW` map (executed here: rule
generation verified for hexagonal + modular maps); `presets/arch/ArchitectureTest.java`
— ArchUnit template (layered + framework-freedom + cycles, wrapped in `FreezingArchRule`);
`presets/arch/guides/{layering-violation,dependency-cycle}.md`; `ruleset_guard.py` gains
a `lines` kind for the ArchUnit freeze store (tested; dep-cruiser baseline reuses `snooze`);
`mise/ts.toml` adds an opt-in `arch` task. **Not yet:** live ArchUnit/dep-cruiser run on a
real project (no JVM/node project here); Python `.importlinter` preset; foundry-init
vendoring of the arch presets.

## Problem

AutoApplicant's backend is textbook hexagonal — `adapter/ port/ usecase/ domain/
config/` — and the docs *state* the rules (domain is framework-free, usecase must not
reach into adapter, no dependency cycles). Nothing *enforces* them. These are
module-graph-level structural properties, invisible to habit-hooks (which sees
function-level smells), and they're precisely what rots a documented architecture into
a big ball of mud one "just this once" import at a time. Neither ArchUnit nor
dependency-cruiser is present in AutoApplicant today — this is genuinely unbuilt.

## Design

Deterministic architecture rules, **folded into existing verbs** (tier-(b) verb
composition — no new contract verb; see the verb-tiers note):

- **Java → ArchUnit**, expressed as JUnit tests → runs under `test`.
- **TS → dependency-cruiser** (cycles + forbidden cross-layer imports) → under `lint`.
- **Python → import-linter** → under `lint`.

The placement asymmetry (arch-as-test on Java, arch-as-lint on TS) is invisible to
callers, who only ever call verbs — the verb interface earning its keep exactly as
CONTRACT.md intends.

### Architecture-agnostic by construction

The mechanism is **not hexagonal-specific** — hexagonal is one preset. A consumer
describes *their* architecture as a small model and every rule is generated from it:

- **LAYERS** — a name → path/package pattern map (what code belongs to each layer).
- **ALLOW** — per layer, which other layers it may import; anything else is forbidden.
- **no cycles** — always enforced.
- **framework-freedom** — optional "layer X must not import package regex Y".

foundry ships this as an editable preset per stack (`presets/arch/`), plus **coaching
guides** (`presets/arch/guides/{layering-violation,dependency-cycle}.md`) that render on
failure. The consumer fills the map; the layer *names* and edges are theirs.

Four worked example maps ship in the presets (swap one in, or write your own):
- **Hexagonal / ports-and-adapters** (AutoApplicant): domain ← port ← usecase ← adapter/config; domain framework-free.
- **Classic layered (n-tier):** web → service → data, one direction only.
- **Clean / onion:** entities ← usecases ← interfaces ← frameworks.
- **Modular / feature-sliced:** each feature may import only a shared kernel, never another feature (module isolation).

AutoApplicant's hexagonal rule set is just the default instance:
- `domain` imports no other layer (no Spring, no `adapter`, no `usecase`); `usecase` →
`domain`+`port`, never `adapter`; `adapter` implements `port`; no package cycles.

## Per-stack wiring

### Java (deep)
- Add an **ArchUnit test source set / package `…/arch/`** so the rules live in their
own place and are *always run* — changed-scope test selection (see changed-scope
spec) must never silently skip them (they're cheap and global). Wire into
`backend:test` (or a `backend:arch` auxiliary task that `test` depends on).
- Ratchet via `FreezingArchRule` backed by a committed violation store directory
(verify the exact store path/API at implementation — do not hard-code here).

### TypeScript (deep)
- `.dependency-cruiser.js` with `forbidden` rules for cycles + layer boundaries, run in
`lint`. Known pre-existing violations recorded in dependency-cruiser's
known-violations baseline (verify the exact `--ignore-known` flag/file at impl).

### Other stacks (recipe)
- **Python:** `.importlinter` contracts (layers/forbidden); run in `lint`.
- **Kotlin:** Konsist or ArchUnit-for-Kotlin, as `test`. **PHP:** deptry/phpat as
`lint`. **dotnet:** NetArchTest as `test`.

## Ratchet mechanics

- **Java:** `FreezingArchRule` violation store — existing violations frozen, store may
**only shrink**. Retrofits onto a dirty codebase without a red day-one wall.
- **TS:** dependency-cruiser known-violations baseline — same shrink-only property.
- Both are committed baseline files, same doctrine as eslint-suppressions/snooze.

## ruleset-guard changes

Two mechanisms, matching what each artifact is:

| Artifact | Guard mechanism | Loosening (needs `ruleset-change`) |
|---|---|---|
| ArchUnit freeze store (`archunit_store/*.txt`) | **`lines` kind** (new; multiset of frozen-violation lines) — done | a store file gains a line |
| dep-cruiser known-violations (`.dependency-cruiser-known-violations.json`) | existing **`snooze`** kind (value_counts over the JSON) | a violation added |
| rule config (`.dependency-cruiser.cjs`, ArchUnit rule classes, `.importlinter`) | existing **ruleset-file watch** (any change + source ⇒ label) | `ALLOW` widened / a rule removed |

The count classifiers catch *baseline* growth; the ruleset-file watch catches *rule*
weakening (widening `ALLOW`, deleting a rule), which isn't count-based — it rides the
existing "touched a ruleset file + source ⇒ needs a human label" control.

## Placement

| Placement | TS (dep-cruiser) | Java (ArchUnit) |
|---|---|---|
| In-loop | ✅ fast (parses source) | ❌ needs compiled classpath (JVM asymmetry, DESIGN §7.4) |
| Pre-push | ✅ | ✅ |
| CI | ✅ | ✅ |

## If-funded tier

None — fully deterministic. That's a *strength*: architecture enforcement never needs
a model, so it's pure oracle.

## Acceptance criteria

1. A PR making `domain` import Spring fails `test` (Java) / `lint` (TS).
2. An existing violation recorded in the frozen store does **not** fail the gate.
3. Removing a violation + pruning the store passes `ruleset-guard` **without** a label.
4. Adding a new forbidden cross-layer import fails, with a coaching guide printed.
5. Introducing a package cycle fails.

## Backport split

- **foundry:** rule templates, starter configs, coaching guides, the three
`ruleset_guard.py` classifiers, per-template verb wiring.
- **consumer:** the layer→folder map and which rules are enabled (AutoApplicant is the
reference; its hexagonal map ships as the worked example).
Loading
Loading