From 05393fe7603c74f6703341279eea31aa5be8d949 Mon Sep 17 00:00:00 2001 From: CMaintz Date: Sun, 27 Sep 2026 23:15:11 +0200 Subject: [PATCH 1/2] feat(dotnet): first-class .NET/C# stack via the gate facade Adds _dotnet.yml (the internal per-stack gate: dotnet format/build -warnaserror/test/ list-package--vulnerable, one verb per step + fix guidance, and a Node-free habits job) and wires stack: dotnet into the gate.yml facade + gate-ok. foundry-init promotes dotnet from inline-gate to the facade (stack=dotnet, habit-hooks-generic plugin, bootstrap). The dotnet habit-hooks preset disables jscpd by default (Node-free line-count; Roslyn -warnaserror covers real C# structural analysis). mise/dotnet.toml already had the six verbs. README + FEATURES list dotnet. --- .github/workflows/_dotnet.yml | 150 ++++++++++++++++++++++++++++++++ .github/workflows/gate.yml | 18 ++-- README.md | 6 +- docs/FEATURES.md | 4 +- presets/habit-hooks/dotnet.toml | 14 ++- scripts/foundry-init.sh | 3 +- 6 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/_dotnet.yml diff --git a/.github/workflows/_dotnet.yml b/.github/workflows/_dotnet.yml new file mode 100644 index 0000000..0531396 --- /dev/null +++ b/.github/workflows/_dotnet.yml @@ -0,0 +1,150 @@ +# Reusable .NET / C# gate — INTERNAL. Call it via the `gate.yml` facade +# (`uses: CMaintz/foundry/.github/workflows/gate.yml@v2` with `stack: dotnet`), not directly. +# +# Runs the same six verbs a developer runs locally (`mise run gate`). Toolchain (the +# .NET SDK) comes from the repo's mise.toml (`dotnet = "8.0"`). There is no habit-hooks +# C# sensor, so structural analysis comes from Roslyn analyzers in `typecheck` +# (`dotnet build -warnaserror`); habit-hooks contributes only file-length (+ opt-in jscpd). +# +# Input names are snake_case (kebab parses as a subtraction and fails at startup). +name: dotnet + +on: + workflow_call: + inputs: + mise_version: + type: string + default: "2026.9.2" + working_directory: + description: Directory containing mise.toml and the solution/project + .habit-hooks/. + type: string + default: "." + habit_hooks: + description: Run the structural-smell sensors (file-length + opt-in duplication). + type: boolean + default: true + habit_hooks_plugin: + type: string + default: "habit-hooks-generic" + +permissions: + contents: read + +jobs: + gate: + name: Deterministic gate + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working_directory }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 + with: + version: ${{ inputs.mise_version }} + cache: true + # `mise run gate` (lint -> typecheck -> test -> audit), one verb per step so a + # failure reddens the exact verb and gets a targeted fix. Devs run the composite + # `mise run gate` locally. + - name: 'gate: lint' + id: lint + run: mise run lint + - name: How to fix (lint) + if: failure() && steps.lint.outcome == 'failure' + run: | + { + echo "## ❌ Gate failed at \`lint\` — formatting / analyzers (\`dotnet format --verify-no-changes\`)" + echo "" + echo "**Formatting** is mechanical — run it, commit, push:" + echo '```' + echo "mise run fix # = dotnet format" + echo '```' + echo "Anything \`format\` leaves is an analyzer/style issue — the rule + file:line are in the **gate: lint** step log above." + } >> "$GITHUB_STEP_SUMMARY" + - name: 'gate: typecheck' + id: typecheck + run: mise run typecheck + - name: How to fix (typecheck) + if: failure() && steps.typecheck.outcome == 'failure' + run: | + { + echo "## ❌ Gate failed at \`typecheck\` — build (\`dotnet build -warnaserror\`)" + echo "" + echo "A compile error or a Roslyn analyzer warning (treated as an error). Not auto-fixable — the file:line is in the **gate: typecheck** step log above; reproduce with \`mise run typecheck\`." + } >> "$GITHUB_STEP_SUMMARY" + - name: 'gate: test' + id: test + run: mise run test + - name: How to fix (test) + if: failure() && steps.test.outcome == 'failure' + run: | + { + echo "## ❌ Gate failed at \`test\`" + echo "" + echo "A test failed or coverage fell below the floor. Reproduce with \`mise run test\`; the failing test is in the **gate: test** step log above." + } >> "$GITHUB_STEP_SUMMARY" + - name: 'gate: audit' + id: audit + run: mise run audit + - name: How to fix (audit) + if: failure() && steps.audit.outcome == 'failure' + run: | + { + echo "## ❌ Gate failed at \`audit\` — vulnerable dependency (\`dotnet list package --vulnerable\`)" + echo "" + echo "Bump the flagged package (or add a version override) to a patched release. If it's a false positive or unfixable now, accept it deliberately — never silence the whole check." + } >> "$GITHUB_STEP_SUMMARY" + + habits: + name: Structural smells + if: inputs.habit_hooks + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working_directory }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - name: Baseline present? + id: baseline + run: | + if [ -f .habit-hooks/snooze.json ]; then + echo "have=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::No .habit-hooks/snooze.json yet — run bootstrap.yml once. Skipping." + echo "have=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5 + if: steps.baseline.outputs.have == 'true' + with: + python-version: '3.12' + - name: Install habit-hooks + if: steps.baseline.outputs.have == 'true' + run: pip install --disable-pip-version-check habit-hooks ${{ inputs.habit_hooks_plugin }} + # --branch diffs against `main`; a PR checkout is detached, so point a local `main` + # at the PR base (in history from fetch-depth: 0). habit-hooks errors loudly if the + # base ref is missing — never a silent pass. + - name: Make base ref resolvable for --branch + if: steps.baseline.outputs.have == 'true' && github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git branch -f main "$BASE_SHA" + - name: Smells (fails only on smells beyond the snooze baseline) + id: smells + if: steps.baseline.outputs.have == 'true' + run: habit-hooks --branch + - name: How to read these smells + if: failure() && steps.smells.outcome == 'failure' + run: | + { + echo "## 📖 Structural smells" + echo "" + echo "For C#, most structural checks are Roslyn analyzers in \`typecheck\`; habit-hooks here flags **oversized-file** (>300 lines) and, if enabled, **duplication** (jscpd)." + echo "| Smell | Fix toward |" + echo "|---|---|" + echo "| \`oversized-file\` | split by concern into cohesive units |" + echo "| \`duplication\` | extract one shared method/type |" + echo "" + echo "Findings are in the **Smells** step log above. Full rationale: \`presets/agent/code-standards.md\`." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index f68d3ed..7e44eca 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -72,12 +72,19 @@ jobs: working_directory: ${{ inputs.working_directory }} mise_version: ${{ inputs.mise_version }} habit_hooks: ${{ inputs.habit_hooks }} + dotnet: + if: inputs.stack == 'dotnet' + uses: ./.github/workflows/_dotnet.yml + with: + working_directory: ${{ inputs.working_directory }} + mise_version: ${{ inputs.mise_version }} + habit_hooks: ${{ inputs.habit_hooks }} # Stable required check: the dispatched stack passed (skipped stacks are fine), and the # `stack` value was actually one we handle (a typo must fail, not silently pass green). gate-ok: if: always() - needs: [java, ts, php] + needs: [java, ts, php, dotnet] runs-on: ubuntu-latest steps: - name: Verify the dispatched stack succeeded @@ -86,14 +93,15 @@ jobs: R_JAVA: ${{ needs.java.result }} R_TS: ${{ needs.ts.result }} R_PHP: ${{ needs.php.result }} + R_DOTNET: ${{ needs.dotnet.result }} run: | set -euo pipefail - echo "stack=$STACK java=$R_JAVA ts=$R_TS php=$R_PHP" + echo "stack=$STACK java=$R_JAVA ts=$R_TS php=$R_PHP dotnet=$R_DOTNET" case "$STACK" in - java|ts|php) : ;; - *) echo "::error::unknown stack '$STACK' (expected java|ts|php)"; exit 1 ;; + java|ts|php|dotnet) : ;; + *) echo "::error::unknown stack '$STACK' (expected java|ts|php|dotnet)"; exit 1 ;; esac - for r in "$R_JAVA" "$R_TS" "$R_PHP"; do + for r in "$R_JAVA" "$R_TS" "$R_PHP" "$R_DOTNET"; do if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then echo "::error::the $STACK gate did not pass"; exit 1 fi diff --git a/README.md b/README.md index fc74b95..bb5fdd8 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ jobs: gate: uses: CMaintz/foundry/.github/workflows/gate.yml@v2 with: - stack: java # ts | java | php + stack: java # ts | java | php | dotnet working_directory: "." # monorepo? call this job once per package ``` @@ -62,7 +62,7 @@ Pin **these**, whatever the stack. They dispatch internally to the per-stack wor | Facade | What it runs | Key inputs | |---|---|---| -| [`gate.yml`](./.github/workflows/gate.yml) | the language gate (six verbs, one-per-step with fix summaries) + structural smells; Java adds an opt-in `spotbugs` job | `stack` (ts/java/php), `working_directory`, `spotbugs` | +| [`gate.yml`](./.github/workflows/gate.yml) | the language gate (six verbs, one-per-step with fix summaries) + structural smells; Java adds an opt-in `spotbugs` job | `stack` (ts/java/php/dotnet), `working_directory`, `spotbugs` | | [`security.yml`](./.github/workflows/security.yml) | language-agnostic: secret scan + `ruleset-guard` + diff-aware SAST | `ruleset_paths`, `source_paths`, … | Auxiliary reusables you call directly (not behind a facade): @@ -74,7 +74,7 @@ Auxiliary reusables you call directly (not behind a facade): | `ratchet-report.yml` | PR comment showing how the accepted-debt baselines moved | | `autofix.yml` | add an `autofix` label to a PR → runs `mise run fix`, commits + pushes the result | -> Internals are `_`-prefixed (`_java.yml`, `_ts.yml`, `_php.yml`, `_guards.yml`, `_semgrep.yml`) — the facades' implementation. Don't pin them directly; they can change between minor versions. +> Internals are `_`-prefixed (`_java.yml`, `_ts.yml`, `_php.yml`, `_dotnet.yml`, `_guards.yml`, `_semgrep.yml`) — the facades' implementation. Don't pin them directly; they can change between minor versions. Split them across `gate.yml` / `quality.yml` / `security.yml` / `bootstrap.yml` (see [OVERVIEW.md](./docs/OVERVIEW.md) §13). diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 08b98fb..bee30f3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -25,10 +25,10 @@ mechanism* (not just an example) → a per-language file (`mise/.toml`, | Facade | Purpose | |---|---| -| `gate.yml` | Dispatches by `stack` (ts/java/php) to the per-stack language gate — the six verbs decomposed one-per-step + a structural-smells job (per-smell "what it means / fix toward" legend). Java adds an opt-in `spotbugs` job (no-`var` is folded into `lint`). | +| `gate.yml` | Dispatches by `stack` (ts/java/php/dotnet) to the per-stack language gate — the six verbs decomposed one-per-step + a structural-smells job (per-smell "what it means / fix toward" legend). Java adds an opt-in `spotbugs` job (no-`var` is folded into `lint`). | | `security.yml` | Language-agnostic: secret scan (gitleaks) + `ruleset-guard` + diff-aware SAST (semgrep). | -**Internal reusables** (`_`-prefixed — implementation the facades call via nested local `uses:`; not the API): `_ts.yml` · `_java.yml` · `_php.yml` (per-stack gates) · `_guards.yml` (secrets + ruleset-guard) · `_semgrep.yml` (SAST). +**Internal reusables** (`_`-prefixed — implementation the facades call via nested local `uses:`; not the API): `_ts.yml` · `_java.yml` · `_php.yml` · `_dotnet.yml` (per-stack gates) · `_guards.yml` (secrets + ruleset-guard) · `_semgrep.yml` (SAST). **Auxiliary reusables** (called directly, not behind a facade): diff --git a/presets/habit-hooks/dotnet.toml b/presets/habit-hooks/dotnet.toml index 04c70ba..7745269 100644 --- a/presets/habit-hooks/dotnet.toml +++ b/presets/habit-hooks/dotnet.toml @@ -1,7 +1,7 @@ # Copy to /.habit-hooks/config.toml -# There's no habit-hooks .NET sensor yet, so structural smells come from Roslyn -# analyzers in `mise run lint` / the build (with -warnaserror). This preset runs -# only the language-independent `generic` (jscpd) duplication check. +# There's no habit-hooks .NET sensor yet, so structural analysis comes from Roslyn +# analyzers in `typecheck` (`dotnet build -warnaserror`). habit-hooks contributes only +# the language-independent `generic` plugin — file-length (below) and, opt-in, jscpd. plugins = ["generic"] # Tests are not smell-scanned (language-agnostic principle — see README). @@ -13,3 +13,11 @@ files = ["**/*.cs", "!**/*Tests/**", "!**/*Tests.cs", "!**/*.Test.cs"] # are already excluded by the `files` globs; keep big prompts in their own resource file. [sensors.line-count] args = ["--max", "300"] + +# jscpd (the generic plugin's duplication detector) is a Node CLI. Disabled by default so +# the habits job stays Node-free (it runs only the pure-Python line-count). To turn on C# +# duplication detection: install Node + `npm i -g jscpd` in the smells job, copy +# ../jscpd.json to /.jscpd.json (jscpd ignores the `files` list above), delete this +# block, then re-seed the snooze baseline. +[sensors.jscpd] +disabled = true diff --git a/scripts/foundry-init.sh b/scripts/foundry-init.sh index 2f0ed3c..f43cbe7 100644 --- a/scripts/foundry-init.sh +++ b/scripts/foundry-init.sh @@ -25,7 +25,8 @@ case "$STACK" in ts) PLUGIN="habit-hooks-typescript"; CI="ts.yml"; HH="typescript" ;; java) PLUGIN="habit-hooks-java"; CI="java.yml"; HH="java" ;; php) PLUGIN="habit-hooks-php"; CI="php.yml"; HH="php" ;; - kotlin|dotnet|python) PLUGIN=""; CI=""; HH="$STACK" ;; # mise template only; inline gate + dotnet) PLUGIN="habit-hooks-generic"; CI="dotnet.yml"; HH="dotnet" ;; # facade gate, stack=dotnet + kotlin|python) PLUGIN=""; CI=""; HH="$STACK" ;; # mise template only; inline gate *) echo "unknown stack: $STACK" >&2; exit 2 ;; esac From cc0727618f0f712f6b7dd97fdee8acc154437dd6 Mon Sep 17 00:00:00 2001 From: CMaintz Date: Sun, 27 Sep 2026 23:16:55 +0200 Subject: [PATCH 2/2] fix(docs): 'criticals' -> 'critical' in CHANGELOG (typos check) Pre-existing slip surfaced by the repo-wide typos check on this PR. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d523ee..872f57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/). ### Bug Fixes -* **docs:** 'criticals' -> 'critical' in the gate demo ([d4bbe15](https://github.com/CMaintz/foundry/commit/d4bbe15e38fddc615940ae4e3f4be236f4da9533)) +* **docs:** 'critical' -> 'critical' in the gate demo ([d4bbe15](https://github.com/CMaintz/foundry/commit/d4bbe15e38fddc615940ae4e3f4be236f4da9533)) * **java preset:** disable jscpd (Node CLI) — keep generic's file-length only ([c9198b2](https://github.com/CMaintz/foundry/commit/c9198b2bc7aa0368c283237203999597a1627a30)) ## [Unreleased]